Pull to refresh

has_many :through => Как быстро обратиться к join-объектам?

Reading time3 min
Views7.2K
Вы знаете, что когда требуется организовать many-to-many отношения между двумя моделями, прогрессивная часть человечества применяет join-таблицы и метод has_many с опцией :through => :join_model_name. Каждая связь между двумя ActiveRecord-объектами представляет собой ActiveRecord-объект.

И это чудесно, ибо в join-таблице можно насоздавать полезных (так называемых «extra») полей с дополнительной информацией о связях между объектами.

Вопрос в том, как красиво достучаться до этих extra атрибутов.

Все скринкасты и книжки, как назло, оперируют простыми примерами. Например, дружат между собой модели Article и Category. Само собой, для join-класса интуитивно напрашивается имя Categorization или ArticleCategorization.

has_many through

Соответственно, если у нас есть два объекта — article и category, и мы хотим найти AR-объект (или объекты), олицетворяющий связь между ними, то авторы книжек с чистым сердцем предлагают делать так:

relations = article.article_categorizations.find_by_category_id(category)


В жизни все сложнее. Модели нередко имеют длинные составные имена, либо между моделями такая связь, что придумывание имени для каждой join-модели превращается в маленькую пытку. Представим, что у нас модели не Article и Category, а UserGroup и Community, или Preorder и CustomerNotification. Как должна называться связующая модель? Возможны варианты.


Поэтому программистов так и тянет как-то стандартизировать их названия в рамках проекта, чтобы не держать в голове. Шаблоны выбирают по вкусу, к примеру:

1) FirstmodelSecondmodelRelation: ArticleCategoryRelation, UserGroupCommunityRelation или
2) FirstmodelVsSecondmodel: ArticleVsCategory, UserGroupVsCommunity
3)…

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


preorder, message = Preorder.first, CustomerNotification.first
relations = preorder.preorder_customer_notification_relations.find_by_customer_notification_id(message)

То есть «вариант из книжки» выглядит очень многословно. А я бы хотел видеть нечто вроде:


preorder.relation_to(message) # это если запись заведомо одна
=> объект класса PreorderCustomerNotificationRelation

preorder.relations_to(message) # если допускается, что join-записей может быть несколько
=> объект класса ActiveRecord::Relation


аналогично, должно работать и в обратную сторону, симметрично:


message.relations_to(preorder).where(:extra_field => "value")


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

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

Потому что опыт подсказывает:
1) чаще всего между любыми двумя моделями находится лишь одна join-модель. И ее можно вычислить.
2) к ее объектам приходится часто обращаться, особенно если в них есть extra-атрибуты.
3) не страшно иметь длинные имена join-моделей — если они не влияют на читаемость кода.

Добавляем в наш Rails-проект два файла:

/lib/ext/active_record/base.rb — это собственно расширение ActiveRecord::Base

module MyExtensions
module ActiveRecord
module Base

  # применяется в моделях, использующих has_many->through
  # возвращает объект класса ActiveRecord::Relation либо nil
  def relations_to(target)
    return nil unless target.kind_of? ::ActiveRecord::Base
    reflection = self.class.reflections.find do |r|
      r[1].instance_of? ::ActiveRecord::Reflection::ThroughReflection and r[1].klass == target.class
    end.at 1 rescue nil # потому что вернётся Array
    return nil unless reflection
    self.send(reflection.through_reflection.name).where(reflection.foreign_key.to_sym => target.id)
  end

  def relation_to(target)
    rels = relations_to(target)
    if rels.instance_of? ::ActiveRecord::Relation
      return (rels.count > 0) ? rels.first : nil
    end
    rels
  end

end
end
end

class ActiveRecord::Base
  include MyExtensions::ActiveRecord::Base
end


/config/initializers/ext.rb

# Load extensions to existing classes.
Dir["lib/ext/**/*.rb"].each do |fn|
  require File.expand_path( fn )
end


Буду рад любым дополнениям и фиксам, упрощающим код.

Также буду рад комментариям «уважаемый, есть способ проще, делай так: ...», ибо планирую жить век и столько же учиться :)
Tags:
Hubs:
Total votes 36: ↑32 and ↓4+28
Comments11

Articles