Мне тут понадобилось написать функциональность для сайта, которая представляет из себя Консультантов, которым можно задавать вопросы.
О чем я хочу рассказать:
- Как использовать 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 помогает улучшить безопасность вашего приложения, помогает защитить данные, которые требуют защиты от случайного просмотра.