Você provavelmente já ouviu falar em objetos de consulta, popularmente conhecidos como “query objects”: o principal propósito desse tipo de objeto é encapsular uma consulta de banco de dados de forma reutilizável, potencialmente combinável e parametrizável. Quando usar esse tipo de objeto e como eles podem ser estruturados? Vamos explorar esse tópico a seguir.
Reutilizando filtros
No Rails, é fácil criar consultas de banco de dados. Com um modelo Active Record, podemos facilmente construir uma consulta de maneira ad-hoc:
ServiceOffering
.where(state: "CA")
.joins(:vendor)
.where(vendors: {education_level: "Kindergarten"})
Se espalharmos consultas como essa por nossa aplicação mas surgir a necessidade de mudar o filtro “education level”, teremos muitos lugares no código para atualizar. A forma mais simples de corrigir esse problema é criando métodos de classe para representar os filtros, o que os torna reutilizáveis:
class ServiceOffering < ApplicationRecord
def self.by_state(state)
where(state: state)
end
def self.by_education_level(education_level)
joins(:vendor)
.where(vendors: {education_level: education_level})
end
# ...
end
E podemos chamar a consulta acima da seguinte maneira:
ServiceOffering
.by_state("CA")
.by_education_level("Kindergarten")
Lidando com filtros opcionais
E se tivermos filtros opcionais? Vamos tornar by_state
um deles:
def self.by_state(state)
where(state: state) if state.present?
end
Infelizmente isso quebra o encadeamento de filtros. No exemplo abaixo,
teremos um erro se params[:state]
for nulo:
# undefined method `by_education_level' for nil:NilClass
ServiceOffering
.by_state(params[:state])
.by_education_level(params[:education_level])
E a solução são os Escopos do Active Record, que são indulgentes
para com valores nulos de forma a preservar a concatenabilidade dos
escopos quando um dos elos retornar nil
:
class ServiceOffering < ApplicationRecord
scope :by_state, ->(state) { where(state: state) if state.present? }
scope :by_education_level, ->(education_level) do
if education_level.present?
joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
# ...
end
Um caso para objetos de consulta no Rails: consultas de domínio
Nossa situação melhorou em relação ao primeiro snippet de código, mas a última versão tem code smells bem sutis.
Por se tratar de uma consulta com filtros encadeáveis, é bem possível que os filtros sejam utilizados sempre em conjunto; queremos ter certeza que eles serão testados nas mesmas combinações em que se encontram no ambiente de produção, mas não existe encapsulamento adequado para facilitar os testes;
Nossos filtros são opcionais; a lógica para pular um filtro é bem específica e pode não fazer sentido no contexto geral do modelo
ServiceOffering
. Ao utilizar um escopo como esse, podemos acidentalmente introduzir um bug em nossa aplicação se não contarmos com a possibilidade de filtros vazios.Estamos fazendo
JOIN
com outras tabelas, o que não faz parte da responsabilidade central do nosso modelo. Sempre que uma consulta engloba mais de uma tabela ou atinge uma certo grau de complexidade, é sinal que pode ser representada através de um objeto de consulta.
Um dos problemas com o mindset do Rails é que novos filtros tendem a ser representados através de métodos adicionais nos modelos, sendo essa a solução padrão oferecida pelo framework. Com o passar do tempo, os modelos tendem a ficar poluídos com filtros inexpressivos, e o código de cada modelo a não formar um todo coerente.
Muitas vezes, um subconjunto de filtros é circunscrito a um subdomínio particular da aplicação, então pode fazer sentido agrupá-los em uma unidade coerente e de propósito único, de forma a preservar a integridade dos modelos. E melhor ainda se usarmos nomes que façam sentido dentro do domínio da nossa aplicação!
Construindo um objeto de consulta
Dado que as “ofertas de serviço” (service offerings) pertencem ao
contexto de “mercado” (marketplace) da nossa aplicação, podemos muito
bem criar uma classe MarketplaceItems
para representá-las. Hoje,
MarketplaceItems
retorna objetos da classe ServiceOffering
, mas
amanhã pode retornar outro tipo – então abstrair nossa operação como
uma entidade de domínio certamente tem seus benefícios.
# Por motivos de simplicidade, não estamos aplicando um namespace
# nessa classe
class MarketplaceItems
def self.call(filters)
scope = ServiceOffering.all
if filters[:state].present?
scope = scope.where(state: filters[:state])
end
if filters[:education_level].present?
scope = scope
.joins(:vendor)
.where(vendors: {education_level: filters[:education_level]})
end
scope
end
end
Chamar esse objeto de consulta ficou bem fácil:
MarketplaceItems.call(state: "CA", education_level: "Kindergarten")
O nosso objeto funciona, mas não é muito escalável. Reatribuir uma
variável local repetidamente dentro de um if
é cansativo e polui o
aspecto visual do código, especialmente ao lidar com um número
considerável de filtros. Será que métodos privados poderiam melhorar a
nossa semântica? Vamos ver:
class MarketplaceItems
class << self
def call(filters)
scope = ServiceOffering.all
scope = by_state(scope, filters[:state])
scope = by_education_level(scope, filters[:education_level])
scope
end
private
def by_state(scope, state)
return scope if state.blank?
scope.where(state: state)
end
def by_education_level(scope, education_level)
return scope if education_level.blank?
scope
.joins(:vendor)
.where(vendors: {education_level: filters[:education_level]})
end
end
end
Nem tanto. Ainda é necessário passar scope
para os métodos privados
e gerenciar o estado da mesma variável. Isso não é tão ruim, mas
existe uma forma melhor de resolver o problema.
Um objeto de consulta melhorado
A nossa sorte é que o Ruby é uma linguagem dinâmica, e ao contrário do
que alguns acreditam, estender um objeto (com extend
) em tempo de
execução não é custoso. O Rails nos dá o método extending
para
estender um objeto do tipo ActiveRecord::Relation
em tempo de
execução, o que podemos usar em benefício próprio. Com tal truque em
mente, podemos refatorar o nosso objeto de consulta:
class MarketplaceItems
module Scopes
def by_state(state)
return self if state.blank?
where(state: state)
end
def by_education_level(education_level)
return self if education_level.blank?
joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
def self.call(filters)
Marketplace
.extending(Scopes)
.by_state(filters[:state])
.by_education_level(filters[:education_level])
end
end
Perceba como a nossa classe está mais limpa! A única responsabilidade
do método call
é construir a consulta e concatenar os filtros, e os
escopos encontram-se isolados dentro de um módulo. Ainda é necessário
retornar self
nos escopos para não quebrar os filtros opcionais, no
entanto isso não é um demérito à solução que conseguimos construir até
agora.
Essa solução se destaca ao lidar com um número considerável de filtros.
Formas de estruturar um objeto de consulta
Existem muitas formas de estruturar um objeto de consulta, por exemplo:
- Injetar um escopo no construtor para dar mais flexibilidade ao objeto cliente:
class MarketplaceItems
def self.call(scope, filters)
new(scope).call(filters)
end
def initialize(scope = ServiceOffering.all)
@scope = scope
end
def call(filters = {})
# ...
end
end
# Aqui estamos limitando os itens do mercado a um
# vendedor em particular
MarketplaceItems.call(vendor.service_offerings, filters)
- Fazer o objeto retornar dados brutos ao invés de escopos Active Record, o que é útil em queries com requerimentos de performance:
class MarketplaceItems
COLUMNS = [:title]
# Uma alternativa é ter um segundo método para retornar
# dados brutos, além do método que retorna
# ActiveRecord::Relation
def self.call(filters)
Marketplace
.extending(Scopes)
.by_state(filters[:state])
.by_education_level(filters[:education_level])
.pluck(*COLUMNS)
.map { |row| COLUMNS.zip(row).to_h }
end
end
- Construir a query com uma string SQL ao invés do construtor de queries do Active Record.
E muitas outras! O importante é que cada alternativa seja utilizada com um real propósito que satisfaça as necessidades da aplicação sem overengineering, a não ser que uma convenção particular exista no projeto.
Bonus: Escopos com comportamento Rails
Te incomoda retornar self
ao lidar com filtros vazios? Se sim, é
possível melhorar a situação com um módulo Scopeable
:
module Scopeable
def scope(name, body)
define_method name do |*args, **kwargs|
relation = instance_exec(*args, **kwargs, &body)
relation || self
end
end
end
E agora podemos incluir (com extend
) o Scopeable
dentro do módulo
Scopes
:
module Scopes
extend Scopeable
scope :by_state, ->(state) { state && where(state: state) }
scope :by_education_level, ->(education_level) do
education_level && joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
Não sou muito fã dessa DSL (e de escopos Active Record no geral) porque ela é capaz de prejudicar a descoberta de métodos dentro do código, então tendo a preferir métodos normais do Ruby – e acho ainda melhor marcar explicitamente os métodos que desejo transformar em escopos, da seguinte maneira:
module Scopes
extend Scopeable
def by_state(state)
state.present? && where(state: state)
end
scope :by_state # Redefina by_state para que se comporte como um escopo
# ...
end
Mas isso fica como um exercício para o leitor.
Conclusão
Sou fã de DDD (domain-driven design) e objetos de propósito único, então a minha preferência é manter os modelos livres de responsabilidades não relacionadas às entidades representadas pelos mesmos.
Modelos Active Record com métodos ou escopos de consulta são toleráveis ao lidar com filtros genéricos e não encadeáveis, caso contrário um objeto de consulta bem nomeado é uma excelente alternativa a considerar!