Мне тут понадобилось написать функциональность для сайта, которая представляет из себя Консультантов, которым можно задавать вопросы.
О чем я хочу рассказать:
- Как использовать state_machine в моделях ActiveRecord
- Как использовать counter_cache “с условиями”
Итак, распишем задачу более подробно:
- Будет две модели, Consultor и ConsultorQuestion
- ConsultorQuestion может быть опубликованным и неопубликованным. Используем state_machine.
- По-умолчанию показываются только опубликованные вопросы. Используем default_scope.
- У Consultor будет счетчик counter_cache, с количеством опубликованных вопросов
Так как контроллеры никому особо не интересны, я их не буду описывать. Начну сразу с моделей.
Модель Consultor (консультант):
# Консультант наследуется от Пользователя (STI).
class Consultor < User
has_many :questions, :class_name => 'ConsultorQuestion', :dependent => :destroy
default_scope where('type = ?', 'Consultor')
end
Модель Consultor использует STI (Single Table Inheritance) - наследуется от модели User, так как консультант это такой же пользователь, как и все остальные. Я создал отдельную модель для консультантов потому, что у консультантов есть вопросы (has_many :questions), а у обычных пользователей их нет. Незачем засорять модель User ненужными связями.
Я также определил default_scope, чтобы всегда по-умолчанию выбирались только консультанты, исключая других пользователей. С default_scope сразу начинаются проблемы, так как этот скоуп добавляет условие в каждый SQL-запрос к таблице consultors. И счетчик counter_cache перестаёт работать. Но об этом позже.
Об использовании state_machine
Давайте посмотрим на модель ConsultorQuestion. Я приведу сразу полную и законченную версию модели, конечно убрав оттуда то, что к делу не относится:
class ConsultorQuestion < ActiveRecord::Base
state_machine :state, :initial => :unpublished do
# Состояния
state :unpublished
state :published
# Коллбэки переходов
after_transition :unpublished => :published, :do => :inc_counter_cache
after_transition :published => :unpublished, :do => :dec_counter_cache
# События
event :publish do
transition :unpublished => :published
end
event :unpublish do
transition :published => :unpublished
end
end
# Перед удалением снимаем с публикации. Если состояние изменится
# на unpublished, то автоматически произойдет декремент counter_cache
before_destroy { unpublish! }
belongs_to :consultor
attr_protected :state, :consultor_id
validates :questioner_name, :questioner_email, :question, :presence => true
default_scope with_state(:published).order('created_at DESC')
def inc_counter_cache
Consultor.unscoped.increment_counter('questions_count', self.consultor.id)
end
def dec_counter_cache
Consultor.unscoped.decrement_counter('questions_count', self.consultor.id)
end
end
Первое, на что обращаешь внимание, это state_machine. У вопроса могут быть два состояния: опубликован или не опубликован. state_machine очень облегчает переходы от состояния к состоянию, хотя это можно сделать и без неё. У вас получится странный код с кучей условий. Я попытаюсь изобразить, что может получиться, если не использовать state_machine:
class ConsultorQuestion < ActiveRecord::Base
scope :published, where('state = ?', :published)
scope :unpublished, where('state = ?', :unpublished)
default_scope published.order('created_at DESC')
before_save :set_counter_cache
after_destroy :set_counter_cache_for_destroy
belongs_to :consultor
attr_protected :state, :consultor_id
validates :questioner_name, :questioner_email, :question, :presence => true
def published?
state == 'published'
end
def unpublished?
state == 'unpublished'
end
def state_was?(value)
state_was == value.to_s
end
def set_counter_cache
# Для добавляемых записей со статусом published
if new_record? && published?
# инкрементируем счетчик
method = :increment_counter
end
# Для изменяемых записей.
# Если запись была снята с публикации, декрементируем счетчик
# Здесь можно воспользоваться aasm и переходом состояния (transition)
if persisted? && state_was?('published') && unpublished?
method = :decrement_counter
end
# Если запись была опубликована, декрементируем счетчик
if persisted? && state_was?('unpublished') && published?
method = :increment_counter
end
# Обновляем счетчик
Consultor.unscoped.send(method, 'questions_count', self.consultor.id) if method
end
# Для удаляемых записей, которые были опубликованы
def set_counter_cache_for_destroy
Consultor.unscoped.decrement_counter('questions_count', self.consultor.id) if published?
end
end
Ужасно, не правда ли? Зацените, сколько условий в коде и сравните это с предыдущим листингом, где нет ни одного условия и все реализуется при помощи коллбэков! В этом и есть сила state_machine - в вызове коллбэков при смене состояния. Чтобы сменить состояние, нам нужно всего лишь вызвать событие publish! или unpublish! в нужный момент. В нашем же случае мы используем коллбэки чтобы менять значение счетчика counter_cache. Помните, в условии было сказано, что нам нужно подсчитывать только опубликованные вопросы?
О счётчике counter_cache
Вы уже заметили в коде вызовы Consultor.unscoped.decrement_counter и Consultor.unscoped.increment_counter. Так мы меняем значение счетчика counter_cache вручную. Почему? Это стало необходимо потому, что мы используем default_scope в модели и этот скоуп добавляет своё условие к запросу, который обновляет счетчик counter_cache. В результате, счетчик не то что не обновляется, а даже происходит ошибка при выполнении SQL-запроса. Чтобы это исправить, мы вызываем через unscoped стандартные методы increment_counter и decrement_counter. Unscoped отменяет действие default_scope и запрос выполняется без ошибки.
Еще хочется заметить, что метод sizе берет значение из колонки counter_cache. Чтобы получить реальное количество записей в таблице, можно использовать метод length или метод count.
Немного о default_scope
Стоит с осторожностью пользоваться default_scope и не забывать, что он добавляет условие к каждому запросу. Поэтому, что-то может сломаться или работать не так, как вы ожидаете. Но зато, default_scope помогает улучшить безопасность вашего приложения, помогает защитить данные, которые требуют защиты от случайного просмотра.
Also interesting
Tags: ActiveRecord, counter_cache, default_scope, rails, rails 3, ruby, state_machine, unscoped
Использовал гем state_machine в парочке проектов, в последнем решил от него отказаться.
Напрягает, что он при смене состояния сразу же сохраняет модель, это порой нарушает логику, приходится постоянно про это помнить, в итоге код получается еще более странным и костлявым.
>> Напрягает, что он при смене состояния сразу же сохраняет модель, это порой нарушает логику, приходится постоянно про это помнить, в итоге код получается еще более странным и костлявым.
К тому же, в сложных моделях со сменой сразу нескольких состояний, SM сильно роняет производительность. Плюс сложно держать объект валидным, если для разных состояний нужно делать разные валидаторы. Так же решили от него отказаться на критичном к производительности направлении проекта. Хотя, в конкретно нашем случае архитектура данной части приложения тоже не самая удачная.
>> Напрягает, что он при смене состояния сразу же сохраняет модель…
Автоматическое сохранение при смене состояний можно отключить, передав в state_machine параметры :action => nil, :use_transactions => false.