Posts Tagged ‘ActiveRecord’

Кое-что из моей работы

Вторник, Январь 17th, 2012

Мне тут понадобилось написать функциональность для сайта, которая представляет из себя Консультантов, которым можно задавать вопросы.

О чем я хочу рассказать:

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

Подробнее о хранении товаров с различными свойствами в БД

Суббота, Март 5th, 2011

В прошлой статье меня попросили подробнее рассказать, как устроено хранение товаров с различными свойствами в проекте, о котором я упомянул.

Первый класс - это класс, который обеспечивает бестабличную ActiveRecord модель, если от него отнаследоваться. Он лежит в app/lib/tableless.rb

class Tableless
  include ActiveModel::Validations
  include ActiveModel::Conversion
  extend ActiveModel::Naming

  def initialize(props = {})
    props.each do |name, value|
      send("#{name}=", value)
    end
  end

  def persisted?
    false
  end
end

Этот код лежит в app/models/product_kinds/base.rb. Код обеспечивает поддержку групп свойств и свойств для моделей товаров (:group, :prop), а так же содержит базовую модель Base с двумя общими для всех типов товаров свойствами :name и :price.

Все остальные классы типов товаров должны наследоваться именно от Base. Смотрим код:

module ProductKinds
  module Props
    class PropOptions
      attr_accessor :as, :default, :collection

      def add(options)
        options.each_pair do |option, value|
          send "#{option}=".to_sym, value
        end
      end
    end

    class Prop
      attr_accessor :name

      def options
        @options
      end

      def options=(options)
        @options = PropOptions.new
        @options.add(options)
      end
    end

    module ClassMethods
      def self.extended(base)
        class_eval { attr_accessor :props, :current_group, :options }
      end

      # Set up group
      def group(name, &block)
        self.props       ||= {}
        self.options     ||= {}
        self.current_group = name;

        if block_given?
          instance_eval(&block)
        end
      end

      def prop(name, *options)
        self.props[self.current_group] ||= []

        prop = Prop.new
        prop.name    = name;
        prop.options = options.extract_options!

        self.props[self.current_group] << prop
        attr_accessor name
      end

      def grouped_prop_names
        if self == ProductKinds::Base
          self.props
        else
          properties = superclass.grouped_prop_names

          # Dirty, but works
          properties[:common] ||= []
          properties[:other]  ||= []
          self.props[:common] ||= []
          self.props[:other]  ||= []

          h = {
            :common => properties[:common] + self.props[:common],
            :other  => properties[:other] + self.props[:other]
          }

          h
        end
      end
    end

    module InstanceMethods
      def grouped_prop_names
        self.class.grouped_prop_names
      end
    end
  end

  class Base < Tableless
    extend  Props::ClassMethods
    include Props::InstanceMethods

    group :common do
      prop :name,  :as => :string
      prop :price, :as => :numeric
    end

    validates :name, :presence => true
    validates :price, :presence => true, :numericality => true
  end
end

Так мы описываем тип товара “Автомобиль”. Этот код лежит в app/models/product_kinds/automobile.rb

module ProductKinds
  class Automobile < Base
    group :common do
      prop :color, :as => :string
    end

    group :other do
      prop :max_speed, :as => :numeric
    end

    validates :color,     :presence => true
    validates :max_speed, :presence => true, :numericality => true
  end
end

Вот модель, описывающая тип товара. В БД у нас есть табличка со всеми существующими типами товаров. Этот код лежит в app/models/product_kind.rb

class ProductKind < ActiveRecord::Base
  validates :name, :presence => true, :uniqueness => true
  validates :classname, :presence => true, :uniqueness => true

  has_many :products

  def self.find_model_by_kind(kind)
    self.require_model_by_kind(kind)
    eval("ProductKinds::#{kind.camelize}")
  end

  def self.require_model_by_kind(kind)
    ProductKind.find_by_classname!(kind) # Проверяем существование
    require File.dirname(__FILE__) + "/product_kinds/#{kind}"
  end
end

А это - непосредственно модель, описывающая сам товар. У товара есть свойство :props, в котором в сериализованном виде хранится объект со свойствами товара, например, тот же Automobile. Мы видим геттер и сеттер для props, которые сериализуют и десериализую объект и, соответственно, строку:

require File.dirname(__FILE__) + '/product_kinds/base'

class Product < ActiveRecord::Base
  validates :props,           :presence => true
  validates :product_kind_id, :presence => true

  belongs_to :product_kind

  def props=(model)
    write_attribute(:props, Marshal::dump(model))
  end

  # Restore concrete product model from :props attr
  def props
    return @props_cache unless @props_cache.nil?
    props = read_attribute(:props)

    unless new_record?
      ProductKind.require_model_by_kind(product_kind.classname)
    end

    @props_cache = Marshal::restore(props) if props
    @props_cache
  end
end

Используется в контроллере это примерно так (создание нового товара):

class ProductsController < ApplicationController
  respond_to :html

  def create
    @kind          = params[:product][:kind]
    @product       = Product.new
    @props         = ProductKind.find_model_by_kind(@kind).new(params[:product][:props])
    @product.props           = @props
    @product.product_kind_id = ProductKind.find_by_classname!(@kind).id

    if @props.valid? && @product.valid?
      @product.save!

      flash[:notice] = 'Product was successfully created.'
      redirect_to new_product_image_path(@product)
    else
      render :new
    end
  end
end

P.S.: Знаю, что некоторые куски кода далеко не оптимальны и можно написать лучше и проще. Все в ваших руках - этот код только для примера, как можно сделать :)

Надеюсь, я сумел разъяснить некоторые детали, которые читатели сего блога хотели знать.

Бестабличные модели в Rails 3 и как их можно использовать

Четверг, Март 3rd, 2011

По-умолчанию модель ActiveRecord маппится на таблицу в БД. Но, бывают случаи, когда нужна модель без привязки к таблице - так удобно генерировать формы и вообще - где еще хранить бизнес-логику, как не в модели?

В Rails 3 ребята подошли к этому с умом. Разработчики вынесли часть логики ActiveRecord в отдельные модули и классы, обеспечив этим слабую связанность компонентов. Теперь ничто не мешает подмешать нужную логику в свой класс. Давайте посмотрим, как может выглядеть такая бестабличная модель:

class Tableless
  # Подмешиваем валидаторы, методы преобразований
  # и методы соглашения об именовании для ActiveRecord
  include ActiveModel::Validations
  include ActiveModel::Conversion
  extend ActiveModel::Naming

  # Полезняшка, чтобы создавать свойства из конструктора
  def initialize(props = {})
    props.each do |name, value|
      send("#{name}=", value)
    end
  end

  # Переопределяем метод persisted?, чтобы он всегда
  # возвращал false. Это сообщает ActiveRecord,
  # что данные модели не сохраняются в БД.
  def persisted?
    false
  end
end

Всё, теперь можно спокойно наследовать модели от класса Tableless - вы получите нормальную модель с валидацией, но без привязки к БД:

class Person < Tableless
  validates :name, :presence => true
  attr_accessible :name
end

Где подобное может пригодиться? Ну, например в форме отправки письма с сайта. Мне это пригодилось, когда нужно было сделать функциональность на сайте, которая позволяла бы создавать различные типы товаров.

У каждого товара, например у мобильного телефона или автомобиля, могут быть как общие свойства (такие как цена или масса), так и свои собственные, как наличие bluetooth для телефона или максимальная скорость для автомобиля.

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

Получилось очень удобно - теперь создать новый тип товара стало действительно быстро и просто, к тому же из моделей типов товара стало очень удобно генерировать формы и списки свойств, а еще каждый тип товара был обеспечен удобной рельсовой валидацией. Вот пример двух таких моделей, для сотового телефона и автомобиля:

module ProductKinds
  class Cellphone < Base
    group :common do
      prop :color,  :as => :string
      prop :weight, :as => :numeric
    end

    validates :color,  :presence => true
    validates :weight, :presence => true, :numericality => true
  end
end

module ProductKinds
  class Automobile < Base
    group :common do
      prop :color, :as => :string
    end

    group :other do
      prop :max_speed, :as => :numeric
    end

    validates :color,     :presence => true
    validates :max_speed, :presence => true, :numericality => true
  end
end

Классы моделей наследуются не напрямую от Tableless, а от класса базовой модели Base, она же в свою очередь наследуется от Tableless.

Это связано с тем, что необходимо было наличие общих валидируемых свойств (цена) для всех дочерних моделей. Еще вы можете заметить вызов методов group и prop - они подмешиваются в модели Base. Эти методы определяют свойства товара и разбивают эти свойства по группам. Надеюсь, из кода моделей все понятно. Я не буду тут выкладывать код базового класса моделей Base, так как там нет ничего экстраординарного, но если кому потребуется, то попросите в комментах.

Конечно, у такого подхода (хранение сериализованных моделей в БД) есть и минусы. Например, если код модели изменится, то вам нужно будет пройтись по всем сериализованным моделям в БД и обновить их в соответствии с новым кодом. Это, конечно же, можно и нужно автоматизировать в виде rake-задачи.

Второй минус это поиск. Вы не сможете напрямую искать по БД (кто-то еще так ищет, серьезно?) при помощи SQL запросов, но по-моему, это не так уж важно, когда есть такие офигенные инструменты как sphinx и thinking_sphinx. Можно отдавать данные сфинксу в виде XML, рассериализовав модели - не так уж трудно добавить такой функционал.

Хотя, я считаю, плюсов тут гораздо больше. Вроде это все, о чем я хотел рассказать.