Mira com Ransack

Combinando Scopes e Ransack graças a Virtus & Siphon

Há uma solicitação de recurso comum na página de problemas do ransack e é para integrar seus escopos de ActiveRecord usuais na pesquisa. Na verdade, o ransack é uma ferramenta fantástica para configurar rapidamente um formulário para selecionar as linhas da tabela dependendo dos valores das colunas. Mas fica um pouco aquém quando você deseja pesquisas relacionais mais complexas. Então, quase que naturalmente, você adiciona um escopo ao seu formulário de busca com base em saque antes de perceber que não funciona e que não há uma maneira clara de fazer isso.

Ransack não vai levar tudo

O problema com essa solicitação de recurso atraente e um tanto óbvia é que o ransack depende do activerecord do typecasting realizado em seus atributos, para forçar todos os valores de string do hash do parâmetro em seu valor real. Quando params[:user][:age_gt] => "18"invade seu modelo, ele sabe que “18” deve ser um número inteiro (porque a coluna do banco de dados correspondente o diz) e seguirá em conformidade.

Por outro lado, seu modelo não tem razão para saber qual é o tipo de argumento que você enviaria para o seu osciloscópio.
Pegue isso :

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

Ao contrário dos atributos ActiveRecord, ele não diz em nenhum lugar que boolseja um Booleano.

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

Sim …. Oups! Uma vez que você só obtém strings de seus parâmetros, você precisa de uma camada de configuração para dizer qual é o tipo de seu argumento antes de atingir seu escopo.

É claro que você poderia delegar o trabalho de coação para cada um e todos os escopos fazendo com que eles usem apenas strings. Eles então transformariam os argumentos no tipo certo. Mas isso não é muito elegante: primeiro você corre o risco de quebrar o código legado e, segundo, você está adicionando outra camada de responsabilidade ao registro ativo que todos nós estamos tentando desbloquear . Finalmente ransack já é um objeto Form e uma extensão ActiveRecord, adicionando mais responsabilidade a ele parece bem .. irresponsável.

Por que Sifão

Depois de vasculhar seu banco de dados, você pode querer fugir com seu carro, mas, OMG, está sem gasolina e todos os dados estão nele … Então, você retira aquele pequeno tubo de plástico do bolso e enfia em outro carro, chupando as primeiras gotas e depois deixe fluir em seu carro … Sifonagem é uma atividade muito discreta próxima ao saque e aparece na base de código: a essência disso é cerca de 50 linhas.

Então Siphon é apenas uma pequena jóia de conveniência semelhante a [has_scope] que ainda é experimental, mas faz seu trabalho de aplicar escopos a um modelo ActiveRecord graças a um Form Object (criado com Virtus ) contendo as informações de coação.

Agora vamos ver isso em ação. Imagine que você tenha o conjunto de dados canônico “Pedidos, Produtos e Itens”. Qual seria o caso em que o saque por si só não cobrisse as condições que você deseja aplicar? Bem, eu tive que procurar ransack novamente porque não conseguia me lembrar onde falhou. Abrange condições complexas de saída com ‘OR’, ‘AND’, correspondências, maior que, mesmo junta, etc. Se claro, eu poderia fazer pesquisas complexas para ilustrar a necessidade de um escopo, mas qual seria a situação mais simples em que o saque não funcionaria e você precisaria de um escopo personalizado. Bem, por exemplo, você pode combinar colunas, mas não pode combinar predicados (por exemplo, igual a e maior que). Portanto, se você deseja exibir todos os pedidos obsoletos e precisa separar 2 colunas com tipos diferentes:

Com um escopo vem naturalmente (observe o ‘OU’):

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

Só com o saque:

= 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.

… você está ferrado!

E se você deseja colocá-los em campos diferentes e confiar que o usuário fará a combinação certa

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

… você ainda está ferrado porque campos diferentes só fazem conjunções exclusivas (também conhecidas como: condição1 E condição2) e não disjunções (também conhecidas como: condição1 OU condição2).

Ok ponto, vamos continuar com a aplicação de escopos em um formulário.

Sifão em ação

Os escopos:

# 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

A 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

O objeto do formulário:

# 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ão TADA:

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

Você pode querer ler algumas dicas sobre o que o sifão faz ou vamos mergulhar de cabeça nisso …

Ransack de mãos dadas com Siphon e Virtus

A ideia principal é separar os campos de ransack dos campos de sifão / escopo e, portanto, aninhar um deles. Então, vamos aninhar os campos de ransack no parâmetro q (já que é a convenção de ransack) e deixar os escopos no topo:

-# 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 agora tem os escopos e tem a bondade de saquear. Precisamos encontrar uma maneira, agora, de distribuir esses dados para o objeto de formulário. Portanto, primeiro deixe o ProductSearch engoli-lo no 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

E agora a essência disso:

# 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 você pode ver aqui, o Virtus tratará todos os atributos do sifão de forma automagicamente (graças ao superqual realmente merece o seu nome aqui). Então a linha:

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

… atribuirá um objeto de formulário Ransack a q que corajosamente conterá os valores em:

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

Em seguida, chamá -lo fornecerá um ActiveRelation que você mesclará com o outro ActiveRelation fornecido pelo sifão:@q.result

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

E voilà, o controlador apenas coleta todos os frutos do árduo objeto Form:

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

… e você pode continuar aplicando mais escopo (como paginação), ainda é seu bom e velho ActiveRelation normal …


Para encerrar rapidamente: não há mágica e simplesmente funciona!
Sinta-se à vontade para fazer perguntas ou sugerir coisas para melhorar o artigo.
Terei o maior prazer em atualizá-lo.