Skojarzenie szyn z wieloma kluczami obcymi

84

Chcę mieć możliwość użycia dwóch kolumn w jednej tabeli do zdefiniowania relacji. Na przykład na przykładzie aplikacji zadań.

Próba 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

A następnie Task.create(owner_id:1, assignee_id: 2)

To pozwala mi wykonać Task.first.owneroperację, która zwraca użytkownika pierwszego i Task.first.assigneezwraca użytkownika drugiego, ale User.first.tasknic nie zwraca. Dzieje się tak, ponieważ zadanie nie należy do użytkownika, należą one do właściciela i cesjonariusza . Więc,

Próba 2:

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

class Task < ActiveRecord::Base
  belongs_to :user
end

To po prostu kończy się niepowodzeniem, ponieważ dwa klucze obce nie wydają się być obsługiwane.

Chcę więc móc powiedzieć User.tasksi uzyskać zarówno należące do użytkowników, jak i przydzielone im zadania.

Zasadniczo w jakiś sposób zbuduj relację, która równałaby się zapytaniu Task.where(owner_id || assignee_id == 1)

Czy to jest możliwe?

Aktualizacja

Nie chcę używać finder_sql, ale niedopuszczalna odpowiedź tego problemu wydaje się być bliska tego, czego chcę: Rails - Multiple Index Key Association

Więc ta metoda wyglądałaby następująco:

Próba 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

Chociaż mogę to uruchomić Rails 4, pojawia się następujący błąd:

ActiveRecord::PreparedStatementInvalid: missing value for :owner_id in :donor_id => :person_id OR assignee_id => :person_id
JonathanSimmons
źródło
Dzięki za informację, ale nie tego szukam. Chcę, aby zapytanie dla jednej z kolumn lub kolumny miało podaną wartość. To nie jest złożony klucz podstawowy.
JonathanSimmons,
tak, aktualizacja wyjaśnia to. Zapomnij o klejnocie. Oboje myśleliśmy, że chcesz po prostu użyć złożonego klucza podstawowego. Powinno to być możliwe przynajmniej poprzez zdefiniowanie zakresu niestandardowego relacji o określonym zakresie. Ciekawy scenariusz. Przyjrzę się temu później
dre-hh
Moim celem FWIW jest uzyskanie danego zadania użytkownika i zachowanie formatu ActiveRecord :: Relation, aby móc nadal używać zakresów zadań na wynikach do wyszukiwania / filtrowania.
JonathanSimmons,

Odpowiedzi:

80

TL; DR

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

Usuń has_many :tasksw Userklasie.


Używanie w has_many :tasksogóle nie ma sensu, ponieważ nie mamy żadnej kolumny nazwanej user_idw tabeli tasks.

To, co zrobiłem, aby rozwiązać problem w moim przypadku, to:

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

W ten sposób, można nazwać User.first.assigned_tasks, jak również User.first.owned_tasks.

Teraz możesz zdefiniować metodę o nazwie, tasksktóra zwraca kombinację assigned_tasksi owned_tasks.

To mogłoby być dobre rozwiązanie, jeśli chodzi o czytelność, ale z punktu widzenia wydajności nie byłoby tak dobre, jak teraz, aby uzyskać tasksdwa zapytania zamiast raz, a następnie wynik z tych dwóch zapytań również należy połączyć.

Aby więc pobrać zadania należące do użytkownika, zdefiniowalibyśmy tasksw Userklasie własną metodę w następujący sposób:

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

W ten sposób pobierze wszystkie wyniki w jednym zapytaniu i nie będziemy musieli scalać ani łączyć żadnych wyników.

Arslan Ali
źródło
To świetne rozwiązanie! Oznacza to wymóg oddzielnego dostępu do przypisywania, posiadanych i wszystkich zadań, odpowiednio. Coś, co przy dużym projekcie byłoby świetnym pomysłem. Nie było to jednak wymagane w moim przypadku. Co do has_many„bez sensu” Jeśli przeczytasz zaakceptowaną odpowiedź, zobaczysz, że w ogóle nie używaliśmy has_manydeklaracji. Zakładając, że Twoje wymagania odpowiadają potrzebom każdego innego gościa, jest to bezproduktywne, ta odpowiedź mogłaby być mniej osądzająca.
JonathanSimmons
Biorąc to pod uwagę, uważam, że jest to lepsza długoterminowa konfiguracja, którą powinniśmy promować dla osób z tym problemem. Jeśli zrewidowałbyś swoją odpowiedź, aby zawierała podstawowy przykład innych modeli i metodę użytkownika do pobierania połączonego zestawu zadań, poprawię ją jako wybraną odpowiedź. Dzięki!
JonathanSimmons
Tak, zgadzam się z wami. Użycie owned_tasksi assigned_tasksdo uzyskania wszystkich zadań będzie miało wskazówkę dotyczącą wydajności. W każdym razie zaktualizowałem moją odpowiedź i umieściłem metodę w Userklasie, aby uzyskać wszystkie powiązane tasks, pobierze wyniki w jednym zapytaniu i nie trzeba wykonywać scalania / łączenia.
Arslan Ali
2
Tylko FYI, klucze obce określone w Taskmodelu nie są w rzeczywistości potrzebne. Ponieważ masz belongs_torelacje o nazwach :owneri :assignee, Railsy domyślnie przyjmą, że klucze obce mają odpowiednio nazwy owner_idi assignee_id.
jeffdill2,
1
@ArslanAli nie ma problemu. :-)
jeffdill2
48

Rozszerzając powyższą odpowiedź @ dre-hh, która według mnie nie działa już zgodnie z oczekiwaniami w Railsach 5. Wygląda na to, że Rails 5 zawiera teraz domyślną klauzulę where dla efektu WHERE tasks.user_id = ?, która zawodzi, ponieważ user_idw tym scenariuszu nie ma kolumny.

Odkryłem, że nadal jest możliwe, aby działał z has_manyasocjacją, wystarczy usunąć tę dodatkową klauzulę where dodaną przez Railsy.

class User < ApplicationRecord
  has_many :tasks, ->(user) {
    unscope(:where).where(owner: user).or(where(assignee: user)
  }
end
Dwight
źródło
Dzięki @Dwight, nigdy bym o tym nie pomyślał!
Gabe Kopley
Nie można tego zmusić do działania dla belongs_to(gdzie rodzic nie ma identyfikatora, więc musi być oparty na wielokolumnowym PK). Po prostu mówi, że relacja jest zerowa (i patrząc na konsolę, nie widzę, aby zapytanie lambda kiedykolwiek zostało wykonane).
fgblomqvist
@fgblomqvist excluded_to ma opcję o nazwie: primary_key, która określa metodę zwracającą klucz podstawowy skojarzonego obiektu używanego do skojarzenia. Domyślnie jest to id. Myślę, że to mogłoby ci pomóc.
Ahmed Kamal
@AhmedKamal Nie widzę w ogóle, jak to jest przydatne?
fgblomqvist
24

Szyny 5:

musisz wyłączyć domyślną klauzulę where, zobacz odpowiedź @Dwight, jeśli nadal chcesz powiązać has_many.

Chociaż User.joins(:tasks)daje mi

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

Ponieważ nie jest to już możliwe, możesz również skorzystać z rozwiązania @Arslan Ali.

Szyny 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

Aktualizacja 1: Odnośnie komentarza @JonathanSimmons

Konieczność przekazania obiektu użytkownika do zakresu w modelu użytkownika wydaje się podejściem wstecznym

Nie musisz przekazywać modelu użytkownika do tego zakresu. Bieżąca instancja użytkownika jest przekazywana automatycznie do tej lambdy. Nazwij to tak:

user = User.find(9001)
user.tasks

Aktualizacja2:

jeśli to możliwe, czy mógłbyś rozszerzyć tę odpowiedź, aby wyjaśnić, co się dzieje? Chciałbym to lepiej zrozumieć, abym mógł zaimplementować coś podobnego. dzięki

Wywołanie has_many :tasksklasy ActiveRecord zapisze funkcję lambda w jakiejś zmiennej klasy i jest po prostu fantazyjnym sposobem na wygenerowanie tasksmetody na swoim obiekcie, która wywoła tę lambdę. Wygenerowana metoda wyglądałaby podobnie do następującego pseudokodu:

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
źródło
1
tak niesamowite, wszędzie i spojrzał ludzie cały czas próbowali sugerować przy użyciu tego przestarzałego gem dla kluczy kompozytowych
Firedragon
2
Jeśli to możliwe, czy mógłbyś rozszerzyć tę odpowiedź, aby wyjaśnić, co się dzieje? Chciałbym to lepiej zrozumieć, abym mógł zaimplementować coś podobnego. dzięki
Stephen Lead
1
chociaż to zachowuje związek, powoduje inne problemy. z dokumentacji: Uwaga: Dołączanie, gorliwe ładowanie i wstępne ładowanie tych skojarzeń nie jest w pełni możliwe. Operacje te mają miejsce przed utworzeniem instancji, a zakres zostanie wywołany z argumentem zerowym. Może to prowadzić do nieoczekiwanego zachowania i jest przestarzałe. api.rubyonrails.org/classes/ActiveRecord/Associations/…
s2t2
1
Wygląda to obiecująco, ale w Railsach 5 nadal używa się obcego klucza z wherezapytaniem zamiast po prostu używać lambdy powyżej
Mohamed El Mahallawy
1
Tak, wygląda na to, że to już nie działa w Railsach 5.
Dwight
13

Wypracowałem na to rozwiązanie. Jestem otwarty na wszelkie wskazówki, jak mogę to poprawić.

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

To zasadniczo przesłania asocjację has_many, ale nadal zwraca ActiveRecord::Relation obiekt, którego szukałem.

Więc teraz mogę zrobić coś takiego:

User.first.tasks.completed a wynikiem jest ukończone zadanie należące do pierwszego użytkownika lub przypisane do niego.

JonathanSimmons
źródło
czy nadal używasz tej metody, aby rozwiązać swoje pytanie? Jestem na tej samej łodzi i zastanawiam się, czy nauczyłeś się nowego sposobu, czy nadal jest to najlepsza opcja.
Dan,
Wciąż najlepsza opcja, jaką znalazłem.
JonathanSimmons,
To prawdopodobnie pozostałość po dawnych próbach. Edytowałem odpowiedź, aby ją usunąć.
JonathanSimmons
1
Czy to i poniższa odpowiedź dre-hh doprowadziłyby do tego samego?
dkniffin,
1
> Konieczność przekazania obiektu użytkownika do zakresu w modelu użytkownika wydaje się podejściem wstecznym. Nie musisz niczego przekazywać, to jest lambda i bieżąca instancja użytkownika jest do niej przekazywana automatycznie
dre-hh
2

Moja odpowiedź na asocjacje i (wiele) klucze obce w railsach (3.2): jak je opisać w modelu i zapisać migracje jest właśnie dla Ciebie!

Jeśli chodzi o twój kod, oto moje modyfikacje

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

Ostrzeżenie: Jeśli używasz RailsAdmin i chcesz utworzyć nowy rekord lub edytować istniejący, nie rób tego, co sugerowałem, ponieważ ten hack spowoduje problem, gdy zrobisz coś takiego:

current_user.tasks.build(params)

Powodem jest to, że railsy będą próbowały użyć current_user.id do wypełnienia zadania task.user_id, tylko po to, aby stwierdzić, że nie istnieje nic takiego jak user_id.

Więc potraktuj moją metodę hackowania jako wyjście poza schemat, ale nie rób tego.

sunsoft
źródło
Nie jestem pewien, czy ta odpowiedź zawiera wszystko, czego wcześniej nie zawierała zatwierdzona odpowiedź. Co więcej, wygląda to jak reklama na inne pytanie.
JonathanSimmons
@JonathanSimmons Dziękuję. Usunę tę odpowiedź, jeśli źle jest zachowywać się jak reklama.
sunsoft
2

Od Rails 5 możesz także zrobić to, co jest bezpieczniejszym sposobem ActiveRecord:

def tasks
  Task.where(owner: self).or(Task.where(assignee: self))
end
user2309631
źródło