Um Caso para Objetos de Consulta no Rails


Translated by Thiago Ara√ļjo Silva
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.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!