Как использовать Query Objects для рефакторинга SQL-запросов Rails

Illustration of a person with a remote control looking at a cat wearing a helmet with an antenna, suggesting the cat is being controlled or searched for by the device.
Обновлено: | Опубликовано:
Illustration of a person with a remote control looking at a cat wearing a helmet with an antenna, suggesting the cat is being controlled or searched for by the device.

Уже больше года прошло с момента публикации моих статей про базовые сервисные объекты в Ruby on Rails. Надо бы продолжить!

В последнем номере Ruby weekly я наткнулся на классную статью про паттерн команда. В самом начале там написано:

Паттерн-команда иногда называют сервисным объектом, operation'ом, action'ом и наверняка еще каким-либо именем, о котором я не знаю. Как ни назови, суть подобного паттерна достаточно проста: взять некое бизнес-действие и обернуть в объект с простейшим интерфейсом.

От себя добавлю, что статья про интеракторы — про то же самое. В Trailblazer'e их называют operation'ами, а в dry-экосистеме — ближе всего к такому определению dry-transaction.

В телеграм-чате я как-то упомянул про "сервисные объекты", и один товарищ зацепился за это имя и стал расспрашивать, что же такое эти самые "сервисные объекты". Закончилось тем, что мы сошлись на названии PORO (Plain Old Ruby Object). И я с ним полностью согласен. Это всего-лишь обычный объект, никакой в этом магии нет. А сервисными я их называю потому, что они ("serve") обслуживают и выполняют какую-то небольшую задачу и прячут внутри себя логику по её свершению.

Помимо интеракторов есть и другие сервисы. Их полно и это лишь зависит от твоей фантазии. Например:

и так далее.

Помимо этого, я в своих проектах выделяю и другие сервисы, например: отчеты (reports), компоненты (cells от trailblazer), метрики (metrics).

Ну, и, конечно, есть уже настолько привычные нашему взгляду сервисы, которыми мы постоянно пользуемся, но просто не думали о них, как о севрисных-объектах в rails:

  • uploaders (carrierwave);
  • validators (active-model);
  • jobs/workers (activejob/sidekiq);
  • subscribers (wisper);
  • enums (classy-enum).

И так далее. Понятно, что список можно продолжать, но на сегодня есть конкретная тема — это Query-объекты.

Query-object

Что же такое query-object? Да это просто объект, который позволяет создать большой и сложный sql-запрос (query) с помощью используемого ORM. И говоря про rails, мы подразумеваем ActiveRecord.

query-object

Разберём, как мы можем воспользоваться таким классом, на примере интернет-магазина и каталога товаров в нём. Сначала как это могло бы выглядеть в контроллере, а потом с использованием сервисного объекта.

class CatalogController < ApplicationController
  def index
    @products = Product.all
  end
end

Вот так будет выглядеть выборка всего списка товаров из базы. Но нам, конечно, нужно не все, а лишь те товары, что для этой страницы (вспоминаем про пагинацию).

def index
  page_number = params[:page] || 0
  @products = Product.page(page_number)
end

Вспоминаем, что сам пользователь может сортировать товары по цене.

def index
  price_sort_direction = params[:price_sort_direction].to_sym || :desc
  page_number = params[:page] || 0
  @products = Product.order(price: price_sort_direction).page(page_number)
end

Но кстати, ведь сортировать можно и не только по цене.

def index
  sort_direction = params[:sort_direction].to_sym || :desc
  sort_type = params[:sort_type].to_sym || :price
  page_number = params[:page] || 0
  @products = Product.order(sort_type => sort_direction).page(page_number)
end

Конечно же, нужно выбирать товары определенной категории.

def index
  @products = Product.all

  category_id = params[:category_id]
  @products = @products.where(category_id: category_id) if category_id

  # ... Здесь я спрятал весь предыдущий код, который, конечно, никуда не делся ...
end

Добавим же теперь парочку критериев для фильтрации товаров.

def index
  @products = Product.all

  property_ids = params[:properties]
  if properties
    @products = @products.joins(:product_properties)
                         .where(property_id: property_ids)
  end

  # ... Здесь я спрятал весь предыдущий код, который, конечно, никуда не делся ...
end

А может, добавим еще ценовой диапазон?

def index
  @products = Product.all

  from_price = params[:from_price]
  @products = @products.where('price > ?', from_price) if from_price

  to_price = params[:to_price]
  @products = @products.where('price < ?', to_price) if to_price

  # ... Здесь я спрятал весь предыдущий код, который, конечно, никуда не делся ...
end

Да и куда же без самого обычного поиска? Возможность вбить пару букв и найти похожее слово в названии товара.

def index
  @products = Product.all

  search = params[:search]
  @products = @products.where("title ILIKE '%?%'", search) if search

  # ... Здесь я спрятал весь предыдущий код, который, конечно, никуда не делся ...
end

Вот так. Всего пара итераций, и наш простейший фильтрованный каталог готов. Посмотрим, что же вышло в итоге:

def index
  @products = Product.all

  search = params[:search]
  @products = @products.where("title ILIKE '%?%'", search) if search

  from_price = params[:from_price]
  @products = @products.where('price > ?', from_price) if from_price

  to_price = params[:to_price]
  @products = @products.where('price < ?', to_price) if to_price

  property_ids = params[:properties]
  if properties
    @products = @products.joins(:product_properties)
                         .where(property_id: property_ids)
  end

  category_id = params[:category_id]
  @products = @products.where(category_id: category_id) if category_id

  sort_direction = params[:sort_direction].to_sym || :desc
  sort_type = params[:sort_type].to_sym || :price
  page_number = params[:page] || 0
  @products = @products.order(sort_type => sort_direction).page(page_number)
end

Достаточно аккуратно, если учесть, что тут вовсе не все сценарии описаны, а самые базовые. Да и к тому же, у меня есть опыт написания подобных штук, а чаще встречаются куда более громоздкие и странные варианты.

Если же взглянуть на весь экшен, то по сути, происходит просто формирование большого запроса к базе данных.

Query-object

Ну что же, давай поместим всё вот это в отдельный класс, который будем вызывать. Тем самым спрячем логику за "ёмким" названием FindProducts. Вот так будет выглядеть наш контроллер:

class CatalogController < ApplicationController
  def index
    @products = FindProducts.new(Product.all).call(permitted_params)
  end

  def permitted_params
    params.permit(:search, :from_price, :to_price,
                  :properties, :category_id,
                  :sort_direction, :sort_type, :page)
  end
end

А вот как может выглядеть в таком случае наш объект:

# app/queries/find_products.rb
class FindProducts
  attr_accessor :initial_scope

  def initialize(initial_scope)
    @initial_scope = initial_scope
  end

  def call(params)
    scoped = search(initial_scope, params[:search])
    scoped = filter_by_price(scoped, params[:from_price], params[:to_price])
    scoped = filter_by_properties(scoped, parmas[:properties])
    scoped = filter_by_category(scoped, params[:category_id])
    scoped = sort(scoped, params[:sort_type], params[:sort_direction]
    scoped = paginate(scoped, params[:page]
    scoped
  end

  private def search(scoped, query = nil)
    query ? scoped.where("title ILIKE '%?%'", query) : scoped
  end

  private def filter_by_price(scoped, from = nil, to = nil)
    from ? scoped.where('price > ?', from_price) : scoped
    to ? scoped.where('price < ?', to_price) : scoped
  end

  private def filter_by_properties(scoped, properties = nil)
    if properties
      scoped.joins(:product_properties).where(property_id: properties)
    else
      scoped
    end
  end

  private def filter_by_category(scoped, category_id = nil)
    category_id ? scoped.where(category_id: category_id) : scoped
  end

  private def sort(scoped, sort_type = :desc, sort_direction = :price)
    scoped.order(sort_type => sort_direction)
  end

  private def paginate(scoped, page_number = 0)
    scoped.page(page_number)
  end
end

Вот такой здоровый класс получился. Зато один класс — одна задача, да и читать его гораздо проще. Вся логика объяснена в методе call:

  1. сначала делаем поиск в дефолтной выборке;
  2. после чего фильтруем по цене;
  3. фильтруем по параметрам;
  4. фильтруем по категории;
  5. сортируем;
  6. и наконец получаем нужную нам страницу в этой выборке.

Автор статьи готов стать твоим персональным наставником, и помочь прокачать твои навыки Ruby on Rails Подробнее

Стоит так же наверно пояснить про initial_scope. Вполне можно было бы сделать Product.all прямо в этом классе, но как мне кажется, таким образом мы сделали наш сервис гибче. Например, мы можем передавать сразу только те товары, что есть в наличии или предварительно отобранный скоуп товаров по данной геолокации. Разумеется и то и другое можно так же внести в этот сервисный объект.

Query-object specs

Особенно любимы сервисные объекты тем, что их достаточно просто тестировать (в отличии от спеков на контроллеры или им подобные штуки). Однако я слышал критику в адрес того, как я тестирую query-объекты. А делаю я следующим образом: я проверяю что в результирующем запросе в базу есть та или иная строка.

Например:

RSpec.describe FindProducts do
  let(:initial_scope) { Product.all }

  let(:params) { {} }

  subject { described_class.new(initial_scope).call(params) }

  context 'with empty params' do
    it 'sorts' do
      expect(subject.to_sql).to include('ORDER BY "products"."price" DESC')
    end

    it 'paginates' do
      expect(subject.to_sql).to include('LIMIT')
      expect(subject.to_sql).to include('OFFSET')
    end
  end
end

То есть, я делаю SQL из ActiveRecord::Relation объекта и проверяю, что при нужных мне параметрах появляется та или иная строка в результирующем SQL. Критика заключается в том, что я, на самом деле, не тестирую результат, ведь я мог ошибиться в самом SQL, и тогда спека ничего не проверит.

Противоположностью такому решению является создание реальных объектов в базе, когда будет производиться настоящий запрос в неё и получение объектов. После чего, просто нужно сравнить те ли это объекты, что ожидаешь, или нет.

Subscribe to our Newsletter

Let us send you the best of what we've discovered in DevOps, Cloud and Kubernetes, as well us occasional event announcements.

We are also preparing some ways to learn together: weekly challenges, free courses and more. Subscribe now to be the first to get those.