Asociación de rieles con múltiples claves externas

84

Quiero poder usar dos columnas en una tabla para definir una relación. Entonces, usando una aplicación de tareas como ejemplo.

Intento 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

Por lo que entonces Task.create(owner_id:1, assignee_id: 2)

Esto me permite realizar Task.first.ownercuál devuelve el usuario uno y Task.first.assigneecuál devuelve el usuario dos pero User.first.taskno devuelve nada. Lo cual se debe a que la tarea no pertenece a un usuario, pertenece al propietario y al cesionario . Entonces,

Intento 2:

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

class Task < ActiveRecord::Base
  belongs_to :user
end

Eso simplemente falla por completo ya que dos claves externas no parecen ser compatibles.

Entonces, lo que quiero es poder decir User.tasksy obtener tanto las tareas asignadas como las propias de los usuarios.

Básicamente, construye de alguna manera una relación que equivaldría a una consulta de Task.where(owner_id || assignee_id == 1)

¿Es eso posible?

Actualizar

No estoy buscando usar finder_sql, pero la respuesta no aceptada de este problema parece estar cerca de lo que quiero: Rails - Asociación de claves de índice múltiple

Entonces, este método se vería así,

Intento 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

Aunque puedo hacer que funcione Rails 4, sigo recibiendo el siguiente error:

ActiveRecord::PreparedStatementInvalid: missing value for :owner_id in :donor_id => :person_id OR assignee_id => :person_id
Jonathan Simmons
fuente
2
¿Es esta joya lo que estás buscando? github.com/composite-primary-keys/composite_primary_keys
mus
Gracias por la información, pero esto no es lo que estoy buscando. Quiero una consulta para cualquiera de los dos o la columna es un valor dado. No es una clave primaria compuesta.
JonathanSimmons
sí, la actualización lo deja claro. Olvídate de la joya. Ambos pensamos que solo desea utilizar una clave primaria compuesta. Esto debería ser posible al menos definiendo un ámbito personalizado y una relación de ámbito. Escenario interesante. Lo echaré un vistazo más tarde
dre-hh
FWIW, mi objetivo aquí es obtener una tarea de usuario determinada y conservar el formato ActiveRecord :: Relation para poder seguir usando los ámbitos de tarea en el resultado para la búsqueda / filtrado.
JonathanSimmons

Respuestas:

80

TL; DR

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

Eliminar has_many :tasksen Userclase.


Usar has_many :tasksno tiene ningún sentido ya que no tenemos ninguna columna nombrada user_iden la tabla tasks.

Lo que hice para resolver el problema en mi caso es:

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

De esta forma, puede llamar User.first.assigned_taskstambién User.first.owned_tasks.

Ahora, puede definir un método llamado tasksque devuelva la combinación de assigned_tasksy owned_tasks.

Esa podría ser una buena solución en lo que respecta a la legibilidad, pero desde el punto de vista del rendimiento, no sería tan bueno como ahora, para obtener el tasks, se emitirán dos consultas en lugar de una, y luego, el resultado de esas dos consultas también deben unirse.

Entonces, para obtener las tareas que pertenecen a un usuario, definiríamos un tasksmétodo personalizado en Userclase de la siguiente manera:

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

De esta manera, obtendrá todos los resultados en una sola consulta y no tendremos que fusionar o combinar ningún resultado.

Arslan Ali
fuente
¡Esta solución es genial! Implica un requisito de acceso separado para asignar, poseer y todas las tareas respectivamente. Algo que en un gran proyecto sería un gran anticipo. Sin embargo, eso no era un requisito en mi caso. En cuanto a has_many"no tiene sentido" Si lee la respuesta aceptada, verá que no terminamos usando una has_manydeclaración en absoluto. Suponiendo que sus requisitos coincidan con las necesidades de todos los demás visitantes es improductivo, esta respuesta podría haber sido menos crítica.
JonathanSimmons
Dicho esto, creo que esta es la mejor configuración a largo plazo que deberíamos promover para las personas con este problema. Si revisara su respuesta para incluir un ejemplo básico de los otros modelos y el método de usuario para recuperar el conjunto combinado de tareas, lo revisaré como la respuesta seleccionada. ¡Gracias!
JonathanSimmons
Sí estoy de acuerdo con usted. El uso de owned_tasksy assigned_taskspara obtener todas las tareas tendrá una pista de rendimiento. De todos modos, he actualizado mi respuesta y he incluido un método en la Userclase para obtener todos los asociados tasks, obtendrá los resultados en una consulta y no es necesario fusionar / combinar.
Arslan Ali
2
Solo un FYI, las claves externas especificadas en el Taskmodelo en realidad no son necesarias. Dado que tiene las belongs_torelaciones denominadas :ownery :assignee, Rails asumirá de forma predeterminada que las claves externas se denominan owner_idy assignee_id, respectivamente.
jeffdill2
1
@ArslanAli no hay problema. :-)
jeffdill2
47

Ampliando la respuesta de @ dre-hh anterior, que encontré que ya no funciona como se esperaba en Rails 5. Parece que Rails 5 ahora incluye una cláusula where predeterminada para el efecto de WHERE tasks.user_id = ?, que falla porque no hay ninguna user_idcolumna en este escenario.

Descubrí que todavía es posible hacer que funcione con una has_manyasociación, solo necesita anular el alcance de esta cláusula adicional where agregada por Rails.

class User < ApplicationRecord
  has_many :tasks, ->(user) {
    unscope(:where).where(owner: user).or(where(assignee: user)
  }
end
Dwight
fuente
Gracias @Dwight, ¡nunca hubiera pensado en esto!
Gabe Kopley
No se puede hacer que esto funcione para un belongs_to(donde el padre no tiene identificación, por lo que debe basarse en el PK de varias columnas). Simplemente dice que la relación es nula (y mirando la consola, no puedo ver que la consulta lambda se ejecute).
fgblomqvist
@fgblomqvist pertenece_to tiene una opción llamada: clave_primaria, que especifica el método que devuelve la clave principal del objeto asociado utilizado para la asociación. Por defecto es id. Creo que esto podría ayudarte.
Ahmed Kamal
@AhmedKamal No veo cómo eso es útil en absoluto.
fgblomqvist
24

Carriles 5:

debe anular el alcance de la cláusula where predeterminada para ver la respuesta de @Dwight si aún desea una asociación has_many.

Aunque User.joins(:tasks)me da

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

Como ya no es posible, también puede usar la solución @Arslan Ali.

Carriles 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

Actualización 1: Con respecto al comentario de @JonathanSimmons

Tener que pasar el objeto de usuario al alcance en el modelo de usuario parece un enfoque al revés

No tiene que pasar el modelo de usuario a este alcance. La instancia de usuario actual se pasa automáticamente a esta lambda. Llámalo así:

user = User.find(9001)
user.tasks

Actualización2:

Si es posible, ¿podría ampliar esta respuesta para explicar lo que está sucediendo? Me gustaría entenderlo mejor para poder implementar algo similar. Gracias

Llamar has_many :tasksa la clase ActiveRecord almacenará una función lambda en alguna variable de clase y es solo una forma elegante de generar un tasksmétodo en su objeto, que llamará a esta lambda. El método generado sería similar al siguiente pseudocódigo:

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
fuente
1
tan impresionante, en todos los demás lugares donde miraba, la gente seguía tratando de sugerir el uso de esta joya obsoleta para claves compuestas
FireDragon
2
Si es posible, ¿podría ampliar esta respuesta para explicar lo que está sucediendo? Me gustaría entenderlo mejor para poder implementar algo similar. gracias
Stephen Lead
1
aunque esto conserva la asociación, provoca otros problemas. de los documentos: Nota: No es posible unirse, cargar con entusiasmo y precargar estas asociaciones. Estas operaciones ocurren antes de la creación de la instancia y el alcance se llamará con un argumento nulo. Esto puede provocar un comportamiento inesperado y está obsoleto. api.rubyonrails.org/classes/ActiveRecord/Associations/…
s2t2
1
Esto parece prometedor, pero con Rails 5 termina usando Foreign_key con una whereconsulta en lugar de simplemente usar el lambda anterior
Mohamed El Mahallawy
1
Sí, parece que esto ya no funciona en Rails 5.
Dwight
13

Resolví una solución para esto. Estoy abierto a cualquier sugerencia sobre cómo puedo mejorar esto.

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

Esto básicamente anula la asociación has_many pero aún devuelve el ActiveRecord::Relationobjeto que estaba buscando.

Entonces ahora puedo hacer algo como esto:

User.first.tasks.completed y el resultado es que todas las tareas completadas son propiedad o están asignadas al primer usuario.

Jonathan Simmons
fuente
¿Sigues usando este método para resolver tu pregunta? Estoy en el mismo barco y me pregunto si ha aprendido una nueva forma o si esta sigue siendo la mejor opción.
Dan
Sigue siendo la mejor opción que he encontrado.
JonathanSimmons
Es probable que sea un remanente de viejos intentos. Editó la respuesta para eliminarla.
JonathanSimmons
1
¿Esto y la respuesta de dre-hh a continuación lograrían lo mismo?
dkniffin
1
> Tener que pasar el objeto de usuario al ámbito del modelo de usuario parece un enfoque al revés. No tiene que pasar nada, esta es una lambda y la instancia de usuario actual se le pasa automáticamente
dre-hh
2

Mi respuesta a Asociaciones y (múltiples) claves externas en rieles (3.2): ¡cómo describirlas en el modelo y escribir migraciones es solo para usted!

En cuanto a su código, aquí están mis modificaciones.

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

Advertencia: si está utilizando RailsAdmin y necesita crear un nuevo registro o editar el registro existente, no haga lo que le he sugerido, porque este truco causará problemas cuando haga algo como esto:

current_user.tasks.build(params)

La razón es que rails intentará usar current_user.id para completar task.user_id, solo para encontrar que no hay nada como user_id.

Por lo tanto, considere mi método de pirateo como una forma fuera de la caja, pero no haga eso.

sunsoft
fuente
No estoy seguro de que esta respuesta proporcione algo que la respuesta aprobada aún no proporciona. Además, parece un anuncio para otra pregunta.
JonathanSimmons
@JonathanSimmons Gracias. Eliminaré esta respuesta si es malo actuar como un anuncio.
sunsoft
2

Desde Rails 5 también puede hacer lo que es la forma más segura de ActiveRecord:

def tasks
  Task.where(owner: self).or(Task.where(assignee: self))
end
usuario2309631
fuente