среда, 1 октября 2008 г.

Keep DRY для ActiveRecord

Итак, сегодня в нашей программе: keep DRY для объектов ActiveRecord.

DRY - это одна из концепций (или, как модно говорить, паттернов), расшифровывается как Don't Repeat Yourself. Если инициализировать три значения, так в цикле. Если есть общая функциональность в двух классах - вынести отдельно. И это ruby way.

Пусть у нас на сайте есть фотки и видео:
class Photo < ActiveRecord::Base
belongs_to :author, :foreign_key => :author_id
validates_presence_of :author_id

belongs_to :album, :foreign_key => :album_id

has_one :comment_list, :foreign_key => :id
has_many :comments, :through => :comment_list, :class_name => 'Comment'
end

class Video < ActiveRecord::Base
belongs_to :author, :foreign_key => :author_id
validates_presence_of :author_id

belongs_to :playlist, :foreign_key => :playlist_id

has_one :comment_list, :foreign_key => :id
has_many :comments, :through => :comment_list, :class_name => 'Comment'
end


То есть у каждой фотки есть автор, она в альбоме, и она имеет некоторый набор комментариев. У каждой видюшки есть автор, она в плейлисте, и она имеет некоторый набор комментариев.

Дальше. При создании фотки мы хотим автоматом создать и комментарии к ней (c помощью хука after_initialize, например):
class Photo
after_initialize :create_comments

private
def create_comments
self.comment_list ||= CommentList.new.save
end
end

И то же самое мы делаем для видео.

Стоп.

Количество одинакового кода должно перейти в качество. Вынесем-ка "комментируемость" и "принадлежность автору" отдельно. Но как?

1. Наследование.
Самый простой способ -
class SocialEntity < ActiveRecord::Base
belongs_to :author, :foreign_key => :author_id
validates_presence_of :author_id

has_one :comment_list, :foreign_key => :id
has_many :comments, :through => :comment_list, :class_name => 'Comment'

after_initialize :create_comments

private
def create_comments
self.comment_list ||= CommentList.new.save
end
end

class Photo < SocialEntity
belongs_to :album, :foreign_key => :album_id
end

class Video < SocialEntity
belongs_to :playlist, :foreign_key => :playlist_id
end


Однако, это не сработает. ActiveRecord попытается найти таблицу social_entities и обломится.

Впрочем, мы можем ее создать. И даже хранить там и фотки, и видео. Этот подход называется Single Table Inheritance и описан тут.

2. Mixin.
Это интересный способ:
class Photo < ActiveRecord::Base
include SocialEntity
belongs_to :album, :foreign_key => :album_id
end

class Video < ActiveRecord::Base
include SocialEntity
belongs_to :playlist, :foreign_key => :playlist_id
end

module SocialEntity
belongs_to :author, :foreign_key => :author_id, :class_name => 'User'

has_one :comment_list, :foreign_key => :id
has_many :comments, :through => :comment_list, :class_name => 'Comment'

validates_presence_of :author_id

after_initialize :create_comments

private
def create_comments
self.comment_list ||= CommentList.new.save
end
end

То есть общую функциональность мы оформляем в виде модуля и примешиваем к нашим классам. Классы при этом наследуются от ActiveRecord::Base и соотносятся каждый со своей таблицей, как и вначале. Хорошо, но опять не работает! Модуль не знает, что такое belongs_to, after_initialize и другие вкусные вещи ActiveRecord. И require 'active_record' не помогает.

Решение подсказали тут . Вместо описания нужных методов прямо в модуле, опишем их при его присоединении к классу!

module SocialEntity
def self.included(base)
base.belongs_to :author, :foreign_key => :author_id, :class_name => 'User'

base.has_one :comment_list, :foreign_key => :id
base.has_many :comments, :through => :comment_list, :class_name => 'Comment'

base.validates_presence_of :author_id

base.after_initialize :create_comments
end

private
def create_comments
self.comment_list ||= CommentList.new.save
end
public
end


Вот так.

Теперь, если к новой сущности Post тоже понадобятся эти функции (автор и комментарии), написать это будет просто:
class Post < ActiveRecord::Base
include SocialEntity
end


P.S. Есть и третий способ. Можно модифицировать ActiveRecord::Base и включить к него что-нибудь вроде хелпера has_social_features, наряду с has_one и belongs_to. Так мы на руби пришли к перловому принципуTIMTOWTDI.