Связь Rails с несколькими внешними ключами

84

Я хочу иметь возможность использовать два столбца в одной таблице для определения отношения. Итак, на примере приложения-задачи.

Попытка 1:

class User < ActiveRecord::Base
  has_many :tasks
end

class Task < ActiveRecord::Base
  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end

Итак, тогда Task.create(owner_id:1, assignee_id: 2)

Это позволяет мне выполнить, Task.first.ownerкоторый возвращает пользователя один и Task.first.assigneeкоторый возвращает пользователя два, но User.first.taskничего не возвращает. Причина в том, что задача не принадлежит пользователю, а принадлежит владельцу и исполнителю . Так,

Попытка 2:

class User < ActiveRecord::Base
  has_many :tasks, foreign_key: [:owner_id, :assignee_id]
end

class Task < ActiveRecord::Base
  belongs_to :user
end

Это просто не работает, поскольку два внешних ключа, похоже, не поддерживаются.

Поэтому я хочу иметь возможность говорить User.tasksи получать как принадлежащие пользователям, так и назначенные им задачи.

В основном как-то построить отношения, которые будут равняться запросу Task.where(owner_id || assignee_id == 1)

Это возможно?

Обновить

Я не собираюсь использовать finder_sql, но непринятый ответ на эту проблему выглядит примерно так, как я хочу: Rails - Multiple Index Key Association

Итак, этот метод будет выглядеть так:

Попытка 3:

class Task < ActiveRecord::Base
  def self.by_person(person)
    where("assignee_id => :person_id OR owner_id => :person_id", :person_id => person.id
  end 
end

class Person < ActiveRecord::Base

  def tasks
    Task.by_person(self)
  end 
end

Хотя я могу заставить его работать Rails 4, я продолжаю получать следующую ошибку:

ActiveRecord::PreparedStatementInvalid: missing value for :owner_id in :donor_id => :person_id OR assignee_id => :person_id
ДжонатанСиммонс
источник
2
Это жемчужина то, что вы ищете? github.com/composite-primary-keys/composite_primary_keys
mus
Спасибо за информацию, но я не это ищу. Я хочу, чтобы запрос для столбца или был заданным значением. Не составной первичный ключ.
JonathanSimmons
да, обновление дает понять. Забудьте о драгоценном камне. Мы оба думали, что вы просто хотите использовать составной первичный ключ. Это должно быть возможно, по крайней мере, путем определения настраиваемой области видимости отношения. Интересный сценарий. Я посмотрю позже
dre-hh
FWIW, моя цель здесь - получить заданную задачу пользователя и сохранить формат ActiveRecord :: Relation, чтобы я мог продолжать использовать области задач в результате для поиска / фильтрации.
JonathanSimmons

Ответы:

80

TL; DR

class User < ActiveRecord::Base
  def tasks
    Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
  end
end

Удалите has_many :tasksв Userклассе.


Использование has_many :tasksвообще не имеет смысла, поскольку user_idв таблице нет ни одного столбца tasks.

Что я сделал для решения проблемы в моем случае:

class User < ActiveRecord::Base
  has_many :owned_tasks,    class_name: "Task", foreign_key: "owner_id"
  has_many :assigned_tasks, class_name: "Task", foreign_key: "assignee_id"
end

class Task < ActiveRecord::Base
  belongs_to :owner,    class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
  # Mentioning `foreign_keys` is not necessary in this class, since
  # we've already mentioned `belongs_to :owner`, and Rails will anticipate
  # foreign_keys automatically. Thanks to @jeffdill2 for mentioning this thing 
  # in the comment.
end

Таким образом, вы можете позвонить User.first.assigned_tasksтак же, как User.first.owned_tasks.

Теперь вы можете определить метод, tasksкоторый возвращает комбинацию assigned_tasksи owned_tasks.

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

Итак, чтобы получить задачи, принадлежащие пользователю, мы должны определить собственный tasksметод в Userклассе следующим образом:

def tasks
  Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
end

Таким образом, он получит все результаты в одном запросе, и нам не придется объединять или объединять какие-либо результаты.

Арслан Али
источник
Это отличное решение! Это подразумевает требование отдельного доступа для назначения, владения и всех задач соответственно. Что-то, что было бы отличным предвестником для большого проекта. Однако в моем случае этого не требовалось. Что касается has_many«бессмысленности». Если вы прочитаете принятый ответ, то увидите, что мы вообще не использовали has_manyдекларацию. Если предположить, что ваши требования соответствуют потребностям любого другого посетителя, это непродуктивно, этот ответ мог бы быть менее осуждающим.
Джонатан
При этом я считаю, что это лучшая долгосрочная установка, которую мы должны защищать людям с этой проблемой. Если вы измените свой ответ, включив в него базовый пример других моделей и пользовательский метод для получения объединенного набора задач, я пересмотрю его как выбранный ответ. Благодаря!
JonathanSimmons
Да, я согласен с вами. Использование owned_tasksи assigned_tasksдля получения всех задач будет иметь подсказку по производительности. В любом случае, я обновил свой ответ и включил в Userкласс метод для получения всего связанного tasks, он будет получать результаты в одном запросе, и никакого слияния / комбинирования выполнять не нужно.
Арслан Али
2
Просто к сведению, внешние ключи, указанные в Taskмодели, на самом деле не нужны. Поскольку у вас есть belongs_toотношения с именами :ownerи :assignee, Rails по умолчанию предполагает, что внешние ключи имеют имена owner_idи assignee_id, соответственно.
jeffdill2
1
@ArslanAli, без проблем. :-)
jeffdill2
48

Расширение ответа @ dre-hh, приведенного выше, который, как я обнаружил, больше не работает должным образом в Rails 5. Похоже, что Rails 5 теперь включает предложение where по умолчанию для эффекта WHERE tasks.user_id = ?, которое не работает, поскольку user_idв этом сценарии нет столбца.

Я обнаружил, что все еще возможно заставить его работать с has_manyассоциацией, вам просто нужно отменить это дополнительное предложение where, добавленное Rails.

class User < ApplicationRecord
  has_many :tasks, ->(user) {
    unscope(:where).where(owner: user).or(where(assignee: user)
  }
end
Дуайт
источник
Спасибо, @Dwight, я бы никогда об этом не подумал!
Гейб Копли
Не могу заставить это работать для belongs_to(где у родителя нет идентификатора, поэтому он должен быть основан на многоколоночном ПК). Он просто говорит, что отношение равно нулю (и, глядя на консоль, я не вижу, чтобы лямбда-запрос когда-либо выполнялся).
fgblomqvist
@fgblomqvist принадлежит_to имеет параметр, называемый: primary_key, который указывает метод, который возвращает первичный ключ связанного объекта, используемого для ассоциации. По умолчанию это id. Думаю, это могло бы вам помочь.
Ахмед Камаль
@AhmedKamal Я вообще не понимаю, чем это полезно?
fgblomqvist 09
24

Рельсы 5:

вам нужно отменить область действия по умолчанию, см. ответ @Dwight, если вам все еще нужна ассоциация has_many.

Хотя User.joins(:tasks)дает мне

ArgumentError: The association scope 'tasks' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported.

Поскольку это больше невозможно, вы также можете использовать решение @Arslan Ali.

Рельсы 4:

class User < ActiveRecord::Base
  has_many :tasks, ->(user){ where("tasks.owner_id = :user_id OR tasks.assignee_id = :user_id", user_id: user.id) }
end

Update1: Что касается комментария @JonathanSimmons

Необходимость передать объект пользователя в область видимости модели User кажется обратным подходом.

Вам не нужно передавать пользовательскую модель в эту область. Текущий пользовательский экземпляр автоматически передается этой лямбде. Назовите это так:

user = User.find(9001)
user.tasks

Обновление2:

если возможно, вы могли бы расширить этот ответ, чтобы объяснить, что происходит? Я хотел бы лучше понять это, чтобы реализовать нечто подобное. благодаря

Вызов has_many :tasksкласса ActiveRecord сохранит лямбда-функцию в некоторой переменной класса и представляет собой просто интересный способ сгенерировать tasksметод для его объекта, который будет вызывать эту лямбда. Сгенерированный метод будет похож на следующий псевдокод:

class User

  def tasks
   #define join query
   query = self.class.joins('tasks ON ...')
   #execute tasks_lambda on the query instance and pass self to the lambda
   query.instance_exec(self, self.class.tasks_lambda)
  end

end
dre-hh
источник
1
так здорово, куда бы я ни посмотрел, люди продолжали пытаться предложить использовать этот устаревший гем для составных ключей
FireDragon
2
Если возможно, вы могли бы расширить этот ответ, чтобы объяснить, что происходит? Я бы хотел лучше понять это, чтобы реализовать нечто подобное. спасибо
Стивен Лид
1
хотя это сохраняет ассоциацию, это вызывает другие проблемы. из документации: Примечание. Присоединение, активная загрузка и предварительная загрузка этих ассоциаций не полностью возможны. Эти операции выполняются до создания экземпляра, и область видимости будет вызываться с аргументом nil. Это может привести к неожиданному поведению и не рекомендуется. api.rubyonrails.org/classes/ActiveRecord/Associations/…
s2t2
1
Это выглядит многообещающе, но в Rails 5 все еще используется foreign_key с whereзапросом, а не просто лямбда,
указанная
1
Да, похоже, что это больше не работает в Rails 5.
Дуайт
13

Я разработал решение для этого. Я открыт для любых указаний о том, как я могу это улучшить.

class User < ActiveRecord::Base

  def tasks
    Task.by_person(self.id)
  end 
end

class Task < ActiveRecord::Base

  scope :completed, -> { where(completed: true) }   

  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"

  def self.by_person(user_id)
    where("owner_id = :person_id OR assignee_id = :person_id", person_id: user_id)
  end 
end

Это в основном переопределяет ассоциацию has_many, но по-прежнему возвращает ActiveRecord::Relationобъект, который я искал.

Итак, теперь я могу сделать что-то вроде этого:

User.first.tasks.completed и в результате вся выполненная задача принадлежит или назначена первому пользователю.

ДжонатанСиммонс
источник
Вы все еще используете этот метод для решения своего вопроса? Я в той же лодке, и мне интересно, научились ли вы новому пути или это все еще лучший вариант.
Дэн
Тем не менее лучший вариант, который я нашел.
JonathanSimmons
Вероятно, это остатки старых попыток. Отредактировал ответ, чтобы удалить.
JonathanSimmons
1
Будет ли это и ответ dre-hh ниже сделать то же самое?
dkniffin
1
> Необходимость передать объект пользователя в область видимости модели User кажется обратным подходом. Вам не нужно ничего передавать, это лямбда, и текущий пользовательский экземпляр передается ему автоматически
dre-hh
2

Мой ответ ассоциациям и (множественным) внешним ключам в рельсах (3.2): как описать их в модели и написать миграции - именно для вас!

Что касается вашего кода, вот мои модификации

class User < ActiveRecord::Base
  has_many :tasks, ->(user) { unscope(where: :user_id).where("owner_id = ? OR assignee_id = ?", user.id, user.id) }, class_name: 'Task'
end

class Task < ActiveRecord::Base
  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end

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

current_user.tasks.build(params)

Причина в том, что rails попытается использовать current_user.id для заполнения task.user_id, но обнаружит, что нет ничего похожего на user_id.

Итак, рассматривайте мой метод взлома как выход за рамки стандартного, но не делайте этого.

SunSoft
источник
Не уверен, что этот ответ дает то, чего еще не было в утвержденном ответе. Более того, это похоже на рекламу другого вопроса.
JonathanSimmons
@JonathanSimmons Спасибо. Я удалю этот ответ, если будет плохо вести себя как реклама.
sunsoft
2

Начиная с Rails 5 вы также можете делать то, что является более безопасным способом ActiveRecord:

def tasks
  Task.where(owner: self).or(Task.where(assignee: self))
end
user2309631
источник