Visores con Ransack

Combinando Scopes y Ransack gracias a Virtus & Siphon

Hay una solicitud de función común en la página de problemas de saqueo y es para integrar sus ámbitos de ActiveRecord habituales dentro de la búsqueda. De hecho, ransack es una herramienta fantástica para configurar rápidamente un formulario para seleccionar filas de tablas en función de los valores de las columnas. Pero se queda un poco corto cuando desea búsquedas relacionales más complejas. Por lo tanto, es casi natural que agregue un alcance en su formulario de búsqueda de ransack antes de darse cuenta de que no funciona y que no hay una forma clara de hacerlo.

Ransack no se lo llevará todo

El problema con esa solicitud de característica atractiva y algo obvia es que el saqueo se basa en el encasillado que activerecord realiza en sus atributos, para coaccionar todos los valores de cadena del hash del parámetro en su valor real. Cuando params[:user][:age_gt] => "18"irrumpe en su modelo, sabe que “18” debe ser un número entero (porque la columna correspondiente de la base de datos lo dice) y seguirá en consecuencia.

Por otro lado, su modelo no tiene ninguna razón para saber cuál es el tipo de argumento que enviaría a su alcance.
Toma esto :

scope :active, ->(bool) { where(active: bool) } 

A diferencia de los atributos de ActiveRecord, no dice ningún lugar que boolsea ​​booleano.

params[:user][:active] # => "false"
# and since "false" is just a string
!!"false" # => true

Sí … ¡Ups! Dado que solo obtiene cadenas de sus parámetros , necesita una capa de configuración para saber qué tipo es su argumento antes de que alcance su alcance.

Por supuesto, puede delegar el trabajo de coerción a todos y cada uno de los ámbitos haciendo que solo tomen cadenas. Luego convertirían los argumentos en el tipo correcto. Pero eso no es muy elegante: primero corre el riesgo de romper el código heredado y segundo está agregando otra capa de responsabilidad a activerecord que todos estamos tratando de desbloquear . Finalmente ransack ya está siendo un objeto Form y una extensión ActiveRecord, agregar más responsabilidad parece bien … irresponsable.

Por qué Siphon

Una vez que saqueaste tu base de datos, es posible que desees huir con tu auto, pero Dios mío, se ha quedado sin gasolina y todos los datos están en él … Así que sacas ese pequeño tubo de plástico de tu bolsillo y metes en otro auto, succiona las primeras gotas y luego déjelo fluir en su automóvil … El sifón es una actividad muy discreta junto al saqueo y se muestra en el código base: la esencia es de alrededor de 50 líneas.

Entonces, Siphon es solo una pequeña joya de conveniencia similar a [has_scope] que aún es experimental, pero hace su trabajo de aplicar alcances a un modelo ActiveRecord gracias a un objeto de formulario (creado con Virtus ) que contiene la información coercitiva.

Ahora veámoslo en acción. Imagina que tienes el conjunto de datos canónico “Pedidos, productos y artículos”. ¿Cuál sería un caso en el que el saqueo por sí solo no cubre las condiciones que desea aplicar? Bueno, tuve que buscar ransack nuevamente porque no podía recordar dónde se quedó corto. Cubre las condiciones complejas de salida con ‘O’, ‘Y’, coincidencias, mayor que, incluso se une, etc. Si, por supuesto, podría presentar consultas complejas para ilustrar la necesidad de un alcance, pero ¿cuál sería la situación más simple en la que ransack no lo cortaría y necesitaría un visor personalizado. Bueno, para uno, puede combinar columnas pero no puede combinar predicados (por ejemplo, iguales y mayores que). Entonces, si desea mostrar todos los pedidos obsoletos y necesita separar 2 columnas con diferentes tipos:

Con un alcance viene naturalmente (observe el ‘OR’):

class Order < ActiveRecord::Base
scope
:stale, -> { where(["state = 'onhold' OR submitted_at < ?", 2.weeks.ago]) }
end

Solo con saqueo:

= search_form_for @q do |f|
= f.text_field :description_or_name_cont #=> Ok but...
= f.text_field :state_eq_or_submitted_at_gt #=> Impossible.

… ¡estás jodido!

Y si desea ponerlos en diferentes campos y confiar en que el usuario hará la combinación correcta

= search_form_for @q do |f|
= f.text_field :state_eq
= f.date_field :submitted_at_gt

… todavía estás jodido porque diferentes campos solo hacen conjunciones exclusivas (también conocido como: condición1 Y condición2) no disyunciones (también conocido como: condición1 O condición2).

Bien, apuntemos, sigamos aplicando ámbitos dentro de un formulario.

Sifón en acción

Los alcances:

# order.rb
class Order < ActiveRecord::Base
scope
:stale, ->(duration) { where(["state='onhold' OR (state != 'done' AND updated_at < ?)", duration.ago]) }
scope
:unpaid -> { where(paid: false) }
end

La forma :

= form_for @order_form do |f|
= f.label :stale, "Stale since more than"
= f.select :stale, [["1 week", 1.week], ["3 weeks", 3.weeks], ["3 months", 3.months]], include_blank: true
= f.label :unpaid
= f.check_box :unpaid

El objeto de formulario:

# order_form.rb
class OrderForm
include
Virtus.model
include
ActiveModel::Model
#
# attribute are the named scopes and their value are :
# - either the value you pass a scope whith arguments
# - either a Siphon::Nil value to apply (or not) on a scope whith no argument
#
attribute
:stale, Integer
attribute
:unpaid, Siphon::Nil
end

Aaaaand … Sifón TADA:

# orders_controller.rb
def search
@order_form = OrderForm.new(params[:order_form])
@orders = siphon(Order.scoped).scope(@order_form)
end

Es posible que desee leer algunas ideas sobre lo que hace el sifón o profundicemos en ello …

Saquea mano a mano con Siphon & Virtus

La idea principal es separar los campos de saqueo de los campos de sifón / alcance y, por lo tanto, anidar uno de ellos. Así que anidemos los campos de ransack en el parámetro q (ya que es la convención de ransack) y dejemos los ámbitos en la parte superior:

-# admin/products/index.html
= form_for @product_search, url: "/admin/products", method: 'GET' do |f|
= f.label "has_orders"
= f.select :has_orders, [true, false], include_blank: true
-#
-# And the ransack part is right here...
-#
= f.fields_for @product_search.q, as: :q do |ransack|
= ransack.select :category_id_eq, Category.grouped_options

ok, ahora sostiene los alcances y tiene la bondad del saqueo. Necesitamos encontrar una manera, ahora, de distribuir esos datos al objeto de formulario. Así que primero deje que ProductSearch lo trague en el controlador:params[:product_search]params[:product_search][:q]

# products_controller.rb
def index
@product_search = ProductSearch.new(params[:product_search])
@products ||= @product_search.result.page(params[:page])
end

Y ahora lo esencial:

# product_search.rb
class ProductSearch
include
Virtus.model
include
ActiveModel::Model

# These are scopes for the siphon part
attribute
:has_orders, Boolean
attribute
:sort_by, String

# The q attribute is holding the ransack object
attr_accessor
:q

def initialize(params = {})
@params = params || {}
super
@q = Product.search( @params.fetch("q") { Hash.new } )
end

# siphon takes self since its the formobject
def siphoned
Siphon::Base.new(Product.scoped).scope( self )
end

# and here we merge everything
def result
Product.scoped.merge(q.result).merge(siphoned)
end
end

Como ves aquí Virtus manejará todos los atributos del sifón de forma automática (gracias a lo supercual realmente merece su nombre aquí). Luego la línea:

@q = Product.search( @params.fetch("q") { Hash.new } )

… asignará un objeto de formulario Ransack a q que valientemente mantendrá los valores en:

= f.fields_for @product_search.q, as: :q do |ransack|

Luego, al llamarlo , obtendrá una ActiveRelation que fusionará con la otra ActiveRelation proporcionada por sifón:@q.result

Siphon::Base.new(Product.scoped).scope( self )

Y voilà, el controlador simplemente recolecta todos los frutos del trabajador Form Object:

@products ||= @product_search.result.page(params[:page])

… y puede seguir aplicando más alcance (como la paginación), sigue siendo su buena ActiveRelation regular …


Para terminar rápidamente: ¡no hay magia y simplemente funciona!
Siéntase libre de hacerme preguntas o sugerir cosas para mejorar el artículo.
Estaré encantado de actualizarlo.