Um Caso para Objetos de Consulta no Rails

This article is also available in: English

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.

  1. 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;

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

  3. 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.new(vendor.service_offerings).call
  • 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!