Связать разные модели в одном отношении

#ruby-on-rails #ruby-on-rails-4

#ruby-on-rails #ruby-on-rails-4

Вопрос:

Я пытаюсь связать разные модели в одном и том же отношении, вот так; У User может быть много разных друзей, но каждая модель друзей отличается как в отношении проверки, так и в отношении данных.

 User.first.friends # => [BestFriend, RegularFriend, CloseFriend, ...]
  

Каждый класс friend имеет разные столбцы, но все они реагируют на один и тот же метод #to_s .

 class User < ActiveRecord::Base
  has_many :friends # What more to write here?
end

class BestFriend < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :shared_interests # Shared between all friend models
  validates_presence_of :favourite_colour # Does only exists in this model

  def to_s
    "This is my best friend whos favourite colour is #{favourite_colour} ..."
  end
end

class RegularFriend < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :shared_interests # Shared between all friend models
  validates_presence_of :address # Does only exists in this model

  def to_s
    "This is a regular friend who lives at #{address} ..."
  end
end

class CloseFriend < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :shared_interests # Shared between all friend models
  validates_presence_of :car_type # Does only exists in this model

  def to_s
    "This is a close friend who drives a #{car_type} ..."
  end
end
  

Это то, чего я пытаюсь достичь.

  1. Должна быть возможность разбиения на страницы и загрузки друзей пользователя user.friends.page(4).per(10) .
  2. Друг должен содержать user_id , чтобы гарантировать уникальность записи. Атрибуты у друга без user_id не могут быть уникальными, поскольку значения могут быть общими для друзей.
  3. У каждого друга есть своя проверка и столбцы, что означает, что им нужен свой собственный класс.

Вот пример того, как это можно использовать.

 user = User.create!

CloseFriend.create!({ car_type: "Volvo", shared_interests: "Diving", user: user })
RegularFriend.create!({ country: "Norway", shared_interests: "Swim", user: user })
BestFriend.create!({ favourite_colour: "Blue", shared_interests: "Driving", user: user })
BestFriend.create({ shared_interests: "Driving", user: user }) # => Invalid record

user.friends.page(1).per(3).order("created_at ASC").each do |friend|
  puts friend.to_s
  # This is a close friend who drives a Volvo ...
  # This is a regular friend who lives in Norway ...
  # This is my best friend whos favourite colour is Blue ...
end
  

Как можно было бы это решить?

Комментарии:

1. Два метода: STI и полиморфный. Слишком долго объяснять здесь, и тонны объяснений легко найти. Начните отсюда: guides.rubyonrails.org/association_basics.html

2. @BenjaminSinclaire STI и полиморфная ассоциация не будут работать «из коробки» в моем случае (если вы не знаете чего-то, чего не знаю я). Таблица friends ( close_friends например) должна иметь хотя бы один внешний ключ для проверки ее уникальности. Использование только belongs_to ..., polymorphic: true on User позволит использовать внешний ключ только для Friend , User отношения on User .

3. Я согласен с @BenjaminSinclaire. Не уверен, почему STI не будет работать. Можете ли вы объяснить это подробнее?

4. @Oleander — Сколько друзей у пользователя в этой системе? Часто ли обновляются данные? В зависимости от этого вы можете либо присвоить идентификаторы друга, либо создать представление базы данных

5. Я предоставил исчерпывающий ответ, который касается вашего вопроса, и вы проголосовали против, несмотря на то, что он взят из реального приложения, не использующего полиморфные объединения, использующего отдельные классы для проверки и функциональность to_s, не требующего отдельных запросов и поддерживающего объединение в цепочки и разбивку на страницы (я прилагаю большие усилия, чтобы объяснить, где у вас возникнут проблемы с AR в будущем). Ваше поведение отражает уровень невежества и оскорбляет всех тех, кто потратил время на предоставление законных ответов. У меня был этот вопрос, помеченный для модерации

Ответ №1:

Мне нравится этот вопрос. Мой ответ будет длинным 🙂

Прежде всего, у вас возникают проблемы, потому что ваша архитектура Ruby ООП противоречит принципам реляционной базы данных. Просто подумайте о таблицах, в которых вы храните свои данные. Обычно вы не можете хранить данные в разных таблицах и извлекать их одним запросом. Потому что SQL отвечает вам записями одного типа, но строки таблиц имеют разные типы.

Давайте посмотрим с другой стороны. У вас есть пользовательская модель. У вас их несколько?Подружите модели, и все они друзья на самом деле. Таким образом, даже если они не имеют общего поведения, вы должны унаследовать их от одного базового класса (называемого Friend, я полагаю), потому что вы хотите использовать их в разбивке на страницы как объекты с одинаковым поведением (это не требуется ruby, но требуется классическим дизайном ООП). Теперь у вас есть что-то подобное в RoR:

 class User < AR::Base
  has_many :friends
end

class Friend < AR::Base
  belongs_to :user
end

class BestFriend < Friend
end

class OldFriend < Friend
end
  

К счастью, вы не первый 🙂 Существуют некоторые шаблоны, которые проецируют наследование ООП в RDBMS. Они описаны Мартином Фаулером в его книге «Архитектура корпоративных приложений» (она же PoEAA).

  1. Здесь упоминается первое STI (наследование одной таблицы). Это предпочтительный способ, потому что Rails уже имеет встроенную поддержку STI. Также STI позволяет извлекать все данные одним запросом к БД, поскольку это запрос к одной таблице. Но STI имеет некоторые недостатки. Главное, что вы должны хранить все свои данные в одной таблице. Это означает, что все ваши модели будут совместно использовать поля одной таблицы. Например, у вашей модели BestFriend есть alias атрибут, а у OldFriend его нет, но в любом случае ActiveRecord создаст для вас alias и alias= методы. Это не чисто с точки зрения ООП. Все, что вы можете сделать, это отменить определение этих методов или переопределить их с помощью raise 'Not Available for %s' % self.class.name .
  2. Второе — это наследование конкретной таблицы. Это не совсем подходит для вас, потому что у вас не будет одной таблицы со всеми записями и вы не сможете выполнить простой запрос для разбивки на страницы (на самом деле вы делаете, но только с некоторыми странными материалами DB). Но неоспоримым преимуществом наследования конкретной таблицы является то, что все ваши знакомые модели будут иметь независимые поля. Это чисто с точки зрения ООП.
  3. Третье — это наследование таблицы классов. Это несовместимо с шаблоном ActiveRecord без использования представлений базы данных и вместо триггеров, но слишком сложно для вашей проблемы. Наследование таблицы классов совместимо с шаблоном DataMapper, но Rails сильно зависит от ActiveRecord.

Вы можете легко достичь своей цели с помощью решений NoSQL. Вы просто создаете коллекцию друзей в чем-то вроде MongoDB и просто сохраняете все данные в документах коллекции. Но не используйте Mongo 🙂

Итак, я рекомендую вам использовать STI, но у меня есть несколько приятных трюков. Если вы используете PostgreSQL, вы можете хранить все данные, относящиеся к вашей модели, в столбце JSON или hstore и сочетать гибкость NoSQL со строгостью схемы RDBMS и возможностью ActiveRecord. Просто создайте установщики / геттеры для столбцов, специфичных для вашей модели. Вот так:

 class Friend < AR::Base
  def self.json_accessor(*names)
    names.each do |name|
      class_eval(<<-RUBY, __FILE__, __LINE__   1)
        def #{name}
          friend_details['#{name}']
        end

        def #{name}=(value)
          # mark attribute as dirty
          friend_details_will_change!
          friend_details['#{name}'] = value
        end
      RUBY
    end
  end
end

class BestFriend < Friend
  json_accessor :alias
end
  

Это не единственный способ, но я надеюсь, что все это поможет вам создать надежное решение.

P.S. Извините. Я думаю, что это почти публикация в блоге, а не просто ответ. Но я очень увлечен всеми этими вещами, связанными с DB 🙂

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

DB:

 CREATE VIEW FRIENDS
AS
SELECT ID,
       USER_ID,
       NAME,
       'best' TYPE
FROM   BEST_FRIENDS
UNION ALL
SELECT ID,
       USER_ID,
       NAME,
       'old' TYPE
FROM   OLD_FRIENDS
UNION ALL
...
  

Рельсы:

 class User < AR::Base
  has_many :friends
end

class Friend < AR::Base
  belongs_to :user

  def to_concrete
    ('%sFriend' % type.camelize).find(id)
  end
end

class BestFriend < Friend
end

class OldFriend < Friend
end
  

Использование:

 user = User.create!
BestFriend.create!(user_id: user.id, alias: 'Foo', name: 'Bar')
OldFriend.create!(user_id: user.id, name: 'Baz')

user.friends
# One SQL query to FRIENDS view
# [#Friend(id: 1, type: 'best', name: 'Bar'), #Friend(id: 2, type: 'old', name: 'Baz')]
user.friends.map(amp;:name) 
# ['Bar', 'Baz']
user.first.alias
# NoMethodError
user.first.to_concrete
# #BestFriend(id: 1, name: 'Bar', alias: 'Foo')
user.first.to_concrete.alias
# 'Foo'
user.second.to_concrete
# #OldFriend(id: 2, name: 'Baz')
user.second.to_concrete.alias
# NoMethodError
  

Ответ №2:

Я бы предложил использовать комбинацию store_accessor и STI. Средства доступа к хранилищу позволят вам добавлять атрибуты, специфичные для модели, с поддержкой проверки. Если вы используете PostgreSQL, вы можете воспользоваться индексом GIN / GiST для эффективного поиска по пользовательским полям.

 class User < ActiveRecord::Base
  has_many :friends
end

class Friend < ActiveRecord::Base      
  belongs_to :user

  # exclude nil values from uniq check 
  validates_uniqueness_of :user_id, allow_nil: true


  # The friends table should have a text column called  
  # custom_attrs. 
  # In Postgres you can use hstore data type. Then
  # next line is not required.
  store      :custom_attrs

  # Shared between all friend models
  store_accessor [ :shared_interests ]
  validates_presence_of :shared_interests

  # bit of meta-programming magic to add helper
  # associations in User model.
  def self.inherited(child_class)
    klass = child_class.name 
    name = klass.pluralize.underscore.to_sym
    User.has_many name, -> {where(type: klass)}, class_name:  klass
  end
end
  

Определите различные дружественные модели с атрибутами, специфичными для модели.

 class BestFriend < Friend
  store_accessor [ :favourite_colour ]
  validates_presence_of :favourite_colour

  def to_s
    "This is my best friend whos favourite colour is #{favourite_colour} ..."
  end
end

class RegularFriend  < Friend
  store_accessor [ :address ]
  validates_presence_of :address

  def to_s
    "This is a regular friend who lives at #{address} ..."
  end
end

class CloseFriend <  Friend
  store_accessor [ :car_type ]
  validates_presence_of :car_type

  def to_s
    "This is a close friend who drives a #{car_type} ..."
  end
end
  

Использование

 user = User.create!

user.close_friends.create!(
  car_type: "Volvo", 
  shared_interests: "Diving"
)

user.regular_friends.create!(
  country: "Norway", 
  shared_interests: "Diving"
)

user.best_friends.create!(
  favourite_colour: "Blue", 
  shared_interests: "Diving"
)

# throws error
user.best_friends.create!(
  shared_interests: "Diving"
)

# returns all friends
user.friends.page(4).per(10)
  

Ссылка

Хранить документацию

Документация PostgreSQL Hstore:

Комментарии:

1. 1 Это почти именно то, с чем я экспериментировал последние пару дней. Единственное отличие заключается в том, что я использовал flex_columns для хранения своих данных.

2. @Oleander, store функция встроена в rails. В базе данных Postgres, store_accessor наряду с hstore типом данных, позволяет реализовать NoSQL в СУБД.

3. Для этого нет причин использовать hstore. Вы не можете обеспечить ссылочную целостность, если ваши атрибуты ссылаются на другую информацию в связанных таблицах, все является строкой, вы не можете использовать ограничения проверки на уровне базы данных. Обычно я прибегаю к hstore / JSON / JSONB только тогда, когда заранее не знаю всех атрибутов. Поскольку вопрос очень связан с проверкой определенных атрибутов у определенных типов друзей, этот ответ не соответствует этим целям.

4. Хочу добавить, что, хотя это отличные функции и postgres имеет отличную поддержку индексации, они все еще работают не так быстро, как обычный столбец для чтения, а индексы GIst / gin намного больше индексов и намного медленнее при создании / обновлении.

5. @AndrewHacking, OP был заинтересован в проверке уникальности, которая достигается с помощью belongs_to ассоциации, предложенной в моем ответе. Из описания проблемы пользователя я понял, что он пытается сохранить расширенные атрибуты в отдельных моделях. Если вы видите комментарий OP, он собирался использовать github.com/ageweke/flex_columns . Мой ответ предлагает собственное решение для такого варианта использования.

Ответ №3:

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

 def BestFriend < ActiveRecord::Base
  self.table_name "friends"
  default_scope -> { where(friendship: 'best') }
end

def RegularFriend < ActiveRecord::Base
  self.table_name "friends"
  default_scope -> { where(friendship: 'regular') }
end
  

у вас все еще есть has_many :friends в классе User, надеюсь, когда записи будут прочитаны, область видимости будет соответствовать записи таблицы соответствующей модели.

Редактировать

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

 class Friend < ActiveRecord::Base
  def to_s
    case friendship
    when 'best'
      object = BestFriend.find(self.id)
    when 'regular'
     object = RegularFriend.find(self.id)
    end
    object.to_s
  end
end
  

Комментарии:

1. Я не думаю, что это сработает. Пользовательский класс по-прежнему будет загружать данные в Friend класс, который не существует. Пользовательский класс должен просмотреть (в вашем случае) friendship столбец и передать данные этому классу после загрузки содержимого из базы данных.

2. Дай мне попробовать … Подожди.

3. Да, вы правы. 🙁 неинициализированный постоянный пользователь::Friend … Я не верю, что мы можем получить обратный вызов after_find для возврата записи замены. Но имеет ли значение, есть ли у вас класс Friend? Поскольку единственное, на что вам нужно отвечать для всех записей, это :to_s, у вас мог бы быть класс User с … def to_s; case friendship; когда ‘best’; object = BestFriend.find(self.id );когда ‘regular’;объект = RegularFriend.find(self.id );end;object.to_s; end

4. Трудно прочитать мой комментарий… Я изменил ответ, чтобы сделать его более понятным!

5. Хотя я ценю, что это дополнительный ввод-вывод … : (

Ответ №4:

Наследование одной таблицы

Вероятно, вам лучше всего использовать STI , согласно комментариям

Проблема в том, что вы ссылаетесь на один и тот же набор данных в разных вариантах. «Лучший», «Близкий» и «Обычный» — это все типы друзей, что означает, что будет намного лучше (и в соответствии с принципом единого источника истины) хранить их в одной таблице / модели

Вы могли бы сделать это:

 #app/models/user/close.rb
Class User::Close < Friend
   ...
end

#app/models/user/best.rb
Class User::Best < Friend
   ...
end

#app/models/user/regular.rb
Class User::Regular < Friend
   ...
end
  

Это позволит вам связать вашу User модель с разными типами друзей следующим образом:

 #app/models/user.rb
Class User < ActiveRecord::Base
    has_many :bffs, class: "User::Best"
    has_many :regs, class: "User::Regular"
    has_many :closies, class: "User::Close"
end
  

Не так СУХО, как вы можете надеяться, но позволяет использовать единую таблицу данных для хранения соответствующих файлов, которые вам нужны

Условный

Альтернативой является использование условной ассоциации:

 #app/models/user.rb
Class User < ActiveRecord::Base
   has_many :friends, ->(type="regular") { where(type: type) }
end
  

Это позволило бы вам вызывать:

 @user = User.find params[:id]
@user.friends("best")
  

Комментарии:

1. Как я объяснил выше относительно первого ответа; проблема в том, что у меня есть n классы друзей. В вашем первом примере это заставит меня добавить n строк has_many и выполнить n запросов для извлечения всех данных (разбивка на страницы невозможна), что не является относительным. Второй пример больше не будет работать, так как мне нужны n разные классы, а не только один дружественный класс.

2. Кто сказал, что вам нужны n разные классы? в чем причина?

3. Каждая дружественная модель требует своих собственных проверок, поскольку у них разные столбцы. Вот почему для вашего решения требуются n строки, по одной для каждого класса друзей.

Ответ №5:

Я сделал это с помощью STI (наследование одной таблицы), но с ActiveRecord проблема заключается в том, что у вас ограниченная способность выражать условия объединения и вы не можете выполнять такие вещи, как union all, когда дело доходит до поиска связанной информации, даже если вы точно знаете, какой sql вам нужен.

Например, найти всех друзей и подруг друзей, которые размещали публикации за последние x дней с ‘foo’ в названии или в тренде. Поиск простых связей между организацией и пользователем невозможен, если соответствующая информация не перенесена в таблицу. Конечно, AR / Arel будет генерировать запросы, но не тот, который вы хотите, независимо от того, что вы делаете.

И вы не можете использовать строки SQL для объединения, поскольку вы получаете массив, а не обратную связь, что означает, что объединение в цепочку не работает.

Я перешел к Sequel, где я действительно могу составлять свои запросы и получать доступ к полезным функциям sql, выходящим за рамки простых шаблонов crud, для которых AR хорош.

Вот пример AR (с оговоркой выше, что AR имеет серьезные ограничения по сравнению с sequel), который определяет участник (люди, Организации, пользователи и их подтипы — использует STI) и богатые отношения между ними (BestFriend и т.д.), Которые Содержат атрибуты отношений (отношения также используют STI).

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

Сначала модель участника:

 class Party < ActiveRecord::Base

  ###################################################
  # relationships

  has_many :left_relationships,
    class_name: 'Relationship',
    foreign_key: :left_party_id,
    inverse_of: :left_party

  has_many :right_parties,
    through: :left_relationships,
    source: :right_party

  has_many :right_relationships,
    class_name: 'Relationship',
    foreign_key: :right_party_id,
    inverse_of: :right_party

  has_many :left_parties,
    through: :right_relationships,
    source: :left_party

  def relationships
    Relationship.involving_party(self)
  end

  def peers
    left_parties.merge(right_parties).uniq
  end
end
  

Теперь модель отношений, в которой есть несколько полезных областей для поиска отношений, относящихся к любой стороне, сообществу сторон или между группами сторон:

 class Relationship < ActiveRecord::Base
  before_save :save_parties
  belongs_to :left_party,
    class_name: 'Party',
    inverse_of: :left_relationships
  belongs_to :right_party,
    class_name: 'Party',
    inverse_of: :right_relationships

  # scopes
  #

  # relationships for the given party/parties
  def self.involving_party(party)
    any_of({left_party_id: party}, {right_party_id: party})
  end

  # all relationships between left and right parties/groups
  def self.between_left_and_right(left_group, right_group)
    where(left_party_id: left_group, right_party_id: right_group)
  end

protected
  def save_parties
    left_party.save if left_party
    right_party.save if right_party
  end
end
  

Некоторые необходимые и очень полезные расширения AR:

 module ARExtensions
  extend ActiveSupport::Concern
    module ClassMethods
      # return a relation/scope that matches any of the provided
      # queries. This is basically OR on steroids.
      def any_of(*queries)
        where(
          queries.map do |query|
            query = where(query) if [String, Hash].any? { |type| query.kind_of? type }
            query = where(*query) if query.kind_of? Array
            query.arel.constraints.reduce(:and)
          end.reduce(:or)
        )
      end

      # polyfil for AR scope removal which has no equivalent
      def scoped(options=nil)
        options ? where(nil).apply_finder_options(options, true) : where(nil)
      end
    end
end
  

Теперь Person модель, вы можете называть ее User , если хотите, но для меня важна семантика, у вас могут быть люди, которые не являются пользователями, и они могут отделить концепцию системного пользователя от концепции человека / стороны:

 class Person < Party

  # BestFriend relationships
  has_many :best_friend_relationships,
    class_name: 'BestFriend',
    foreign_key: :right_party_id,
    inverse_of: :left

  has_many :best_friends,
    class_name: 'Person',
    through: :best_friend_relationships,
    source: :best_friend

  has_many :best_friend_of_relationships,
    class_name: 'BestFriend',
    foreign_key: :left_party_id,
    inverse_of: :right

  has_many :best_friends_of,
    class_name: 'Person',
    through: :client_relationships,
    source: :client

  # etc for whatever additional relationship types you have
end
  

Отношения BestFriend:

 class BestFriend < Relationship
  belongs_to :best_friend_of,
    class_name: 'Person',
    foreign_key: :left_party_id,
    inverse_of: :best_friend_relationships,
    autosave: true

  belongs_to :best_friend,
    class_name: 'Person',
    foreign_key: :right_party_id,
    inverse_of: :best_friend_of_relationships,
    autosave: true

  validates_existence_of :best_friend_of
  validates_existence_of :best_friend, allow_new: true

protected
  def save_parties
    best_friend_of.save if best_friend_of
    self.left_party = best_friend_of
    best_friend.save if best_friend
    self.right_party = best_friend
  end
end
  

Миграция (использует расширение SchemaPlus)

 class CreateParties < ActiveRecord::Migration
  def change

    create_table :parties do |t|
      t.timestamps
      t.string :type, index: true
      t.string :name, index: true

      # Person attributes
      t.date :date_of_birth, index: true
      t.integer :gender

      # and so on as required for your different types of parties
    end

    create_table :relationships do |t|
      t.timestamps
      t.string :type, index: true
      t.belongs_to :left_party, index: true, references: :parties, on_delete: :cascade
      t.belongs_to :right_party, index: true, references: :parties, on_delete: :cascade
      t.date :began, index: true
      t.date :ended, index: true

      # attributes for best friends

      # attributes for family members

      # attributes for enemies

    end
  end
end
  

Комментарии:

1. -1 Извините, но это не решает ни одной из моих проблем.

2. Это предназначено для решения проблем, о которых вы, вероятно, еще не знаете. Я прошел через то, что вы делаете, все работало с расширенными отношениями, а затем обнаружил, что AR не может эффективно запрашивать таблицы из-за недостатков. Способ, которым AR выполняет свои объединения, не причинит вам бесконечной боли. Желаю удачи.

3. @Oleander Я обновил, чтобы включить модели AR и миграции из рабочего приложения, пожалуйста, уберите голосование «Против».

Ответ №6:

На самом деле вы не можете сделать это с помощью наследования одной таблицы и полиморфных ассоциаций… эти методы предназначены для записи определенного класса, которая может принадлежать записи для одного из нескольких разных классов.

То, что вы делаете, в значительной степени противоположно… записи во многих разных классах могут принадлежать записи в общем классе.

Я думаю, вам нужно будет указать отношения has_many по отдельности, но создать метод экземпляра «friends», который возвращает сумму массивов.

 class User < ActiveRecord::Base
  has_many :best_friends
  has_many :regular_friends
  has_many :close_friends
  def friends
    best_friends   regular_friends   close_friends
  end
end
  

Если вы хотите попытаться обобщить это (я не тестировал это), вы могли бы попробовать…

 class User < ActveRecord::Base
  ActiveRecord::Base.connection.tables.each { |t| has_many t.to_sym if t.ends_with?("_friends") }

  def friends
    friends = ActiveRecord::Base.connection.tables.inject("[]") {  |all, t| all << self.send(t) if t.ends_with?("_friends"); all }
    friends
  end
end
  

Комментарии:

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

2. Хорошо, я отредактировал ответ, чтобы включить обобщенный метод, но я его не тестировал. У кого-то могут быть предложения получше.

3. Для этого потребовались бы n запросы к базе данных. Извините.

4. Ну, да, у вас есть n таблицы, которые вы хотите запросить. Я все еще склонен думать, что вашим лучшим решением является одна таблица с атрибутом type… которое вы можете использовать для проверки условий. Удачи!

5. Я надеялся избежать условных проверок, используя один единственный класс. Было бы лучше, если бы можно было использовать одну таблицу (как вы сказали) и разделить логику на разные классы.