Desea buscar registros sin registros asociados en Rails

178

Considere una asociación simple ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

¿Cuál es la forma más limpia de conseguir que todas las personas que NO tienen amigos en ARel y / o meta_where?

Y luego, ¿qué pasa con un has_many: a través de la versión

class Person
   has_many :contacts
   has_many :friends, :through => :contacts, :uniq => true
end

class Friend
   has_many :contacts
   has_many :people, :through => :contacts, :uniq => true
end

class Contact
   belongs_to :friend
   belongs_to :person
end

Realmente no quiero usar counter_cache, y por lo que he leído no funciona con has_many: a través de

No quiero extraer todos los registros de person.friends y recorrerlos en Ruby. Quiero tener una consulta / alcance que pueda usar con la gema meta_search

No me importa el costo de rendimiento de las consultas.

Y cuanto más lejos del SQL real, mejor ...

craic.com
fuente

Respuestas:

110

Esto todavía está bastante cerca de SQL, pero debería hacer que todos sin amigos en el primer caso:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
fuente
66
Solo imagine que tiene 10000000 registros en la tabla de amigos. ¿Qué pasa con el rendimiento en ese caso?
goodniceweb
@goodniceweb Dependiendo de su frecuencia duplicada, probablemente pueda soltar el DISTINCT. De lo contrario, creo que desearía normalizar los datos y el índice en ese caso. Podría hacerlo creando un friend_idshstore o una columna serializada. Entonces se podría decirPerson.where(friend_ids: nil)
Unixmonkey
Si va a usar sql, probablemente sea mejor usarlo not exists (select person_id from friends where person_id = person.id)(o tal vez people.ido persons.id, dependiendo de cuál sea su tabla). No estoy seguro de cuál es el más rápido en una situación particular, pero en el pasado esto me ha funcionado bien cuando no estaba tratando de usar ActiveRecord.
nroose
442

Mejor:

Person.includes(:friends).where( :friends => { :person_id => nil } )

Por el momento, es básicamente lo mismo, confías en el hecho de que una persona sin amigos tampoco tendrá contactos:

Person.includes(:contacts).where( :contacts => { :person_id => nil } )

Actualizar

Tengo una pregunta sobre has_oneen los comentarios, así que solo actualizando. El truco aquí es que includes()espera el nombre de la asociación pero whereespera el nombre de la tabla. Para a, has_onela asociación generalmente se expresará en singular, de modo que cambia, pero la where()parte permanece como está. Entonces, si Personsolo una has_one :contact, su declaración sería:

Person.includes(:contact).where( :contacts => { :person_id => nil } )

Actualización 2

Alguien preguntó por el inverso, amigos sin gente. Como comenté a continuación, esto realmente me hizo darme cuenta de que el último campo (arriba: el :person_id) en realidad no tiene que estar relacionado con el modelo que está devolviendo, solo tiene que ser un campo en la tabla de unión. Todos van a ser nilasí que puede ser cualquiera de ellos. Esto lleva a una solución más simple a lo anterior:

Person.includes(:contacts).where( :contacts => { :id => nil } )

Y luego cambiar esto para devolver a los amigos sin gente se vuelve aún más simple, solo cambias la clase en el frente:

Friend.includes(:contacts).where( :contacts => { :id => nil } )

Actualización 3 - Rails 5

Gracias a @Anson por la excelente solución Rails 5 (darle algunos +1 por su respuesta a continuación), puede usar left_outer_joinspara evitar cargar la asociación:

Person.left_outer_joins(:contacts).where( contacts: { id: nil } )

Lo he incluido aquí para que la gente lo encuentre, pero se merece los +1 por esto. Gran adición!

Actualización 4 - Rails 6.1

Gracias a Tim Park por señalar que en el próximo 6.1 puedes hacer esto:

Person.where.missing(:contacts)

Gracias a la publicación a la que también se vinculó.

pequeño
fuente
44
Puede incorporar esto en un ámbito que sería mucho más limpio.
Eytan
3
Mucho mejor respuesta, no estoy seguro de por qué el otro está calificado como aceptado.
Tamik Soziev
55
Sí, solo que, suponiendo que tenga un nombre singular para su has_oneasociación, debe cambiar el nombre de la asociación en la includesllamada. Entonces, suponiendo que estuviera has_one :contactdentro, Personentonces su código seríaPerson.includes(:contact).where( :contacts => { :person_id => nil } )
pequeño
3
Si está utilizando un nombre de tabla personalizado en su modelo de amigo ( self.table_name = "custom_friends_table_name"), use Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
55
@smathy ¡Una buena actualización en Rails 6.1 agrega un missingmétodo para hacer exactamente esto !
Tim Park
172

smathy tiene una buena respuesta de Rails 3.

Para Rails 5 , puede usar left_outer_joinspara evitar cargar la asociación.

Person.left_outer_joins(:contacts).where( contacts: { id: nil } )

Echa un vistazo a los documentos de la API . Fue introducido en la solicitud de extracción # 12071 .

Anson
fuente
¿Hay alguna desventaja en esto? Lo comprobé y cargó 0.1 ms más rápido que .include
Qwertie
No cargar la asociación es una desventaja si realmente accede a ella más tarde, pero es un beneficio si no accede a ella. Para mis sitios, un golpe de 0.1 ms es bastante insignificante, por lo que .includesel costo adicional en tiempo de carga no sería algo de lo que me preocuparía mucho la optimización. Su caso de uso puede ser diferente.
Anson
1
Y si aún no tiene Rails 5, puede hacer esto: también Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')funciona bien como alcance. Hago esto todo el tiempo en mis proyectos de Rails.
Frank
3
La gran ventaja de este método es el ahorro de memoria. Cuando haces un includes, todos esos objetos AR se cargan en la memoria, lo que puede ser algo malo a medida que las tablas se hacen cada vez más grandes. Si no necesita acceso al registro de contacto, left_outer_joinsno carga el contacto en la memoria. La velocidad de solicitud de SQL es la misma, pero el beneficio general de la aplicación es mucho mayor.
chrismanderson
2
¡Esto es realmente bueno! ¡Gracias! Ahora bien, si los dioses de los rieles tal vez pudieran implementarlo de manera simple Person.where(contacts: nil)o Person.with(contact: contact)si usan where invade demasiado la 'propiedad', pero dado ese contacto: ya se analiza y se identifica como una asociación, parece lógico que arel pueda resolver fácilmente lo que se requiere ...
Justin Maxwell
14

Personas que no tienen amigos.

Person.includes(:friends).where("friends.person_id IS NULL")

O que tenga al menos un amigo

Person.includes(:friends).where("friends.person_id IS NOT NULL")

Puede hacer esto con Arel configurando ámbitos en Friend

class Friend
  belongs_to :person

  scope :to_somebody, ->{ where arel_table[:person_id].not_eq(nil) }
  scope :to_nobody,   ->{ where arel_table[:person_id].eq(nil) }
end

Y luego, las personas que tienen al menos un amigo:

Person.includes(:friends).merge(Friend.to_somebody)

El sin amigos:

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
fuente
2
Creo que también puedes hacer: Person.includes (: amigos) .where (amigos: {person: nil})
ReggieB
1
Nota: La estrategia de fusión a veces puede generar una advertencia comoDEPRECATION WARNING: It looks like you are eager loading table(s) Currently, Active Record recognizes the table in the string, and knows to JOIN the comments table to the query, rather than loading comments in a separate query. However, doing this without writing a full-blown SQL parser is inherently flawed. Since we don't want to write an SQL parser, we are removing this functionality. From now on, you must explicitly tell Active Record when you are referencing a table from a string
genkilabs
12

Las respuestas de dmarkow y Unixmonkey me dan lo que necesito. ¡Gracias!

Probé ambos en mi aplicación real y obtuve los tiempos para ellos: aquí están los dos ámbitos:

class Person
  has_many :contacts
  has_many :friends, :through => :contacts, :uniq => true
  scope :without_friends_v1, -> { where("(select count(*) from contacts where person_id=people.id) = 0") }
  scope :without_friends_v2, -> { where("id NOT IN (SELECT DISTINCT(person_id) FROM contacts)") }
end

Ejecuté esto con una aplicación real - pequeña mesa con ~ 700 registros de 'Persona' - promedio de 5 ejecuciones

Enfoque de Unixmonkey ( :without_friends_v1) 813 ms / consulta

Enfoque de dmarkow ( :without_friends_v2) 891ms / consulta (~ 10% más lento)

Pero luego se me ocurrió que no necesito la llamada para DISTINCT()...buscar Personregistros con NO Contacts, por lo que solo deben ser NOT INla lista de contactos person_ids. Así que probé este alcance:

  scope :without_friends_v3, -> { where("id NOT IN (SELECT person_id FROM contacts)") }

Eso obtiene el mismo resultado pero con un promedio de 425 ms / llamada, casi la mitad del tiempo ...

Ahora es posible que necesite DISTINCTotras consultas similares, pero en mi caso esto parece funcionar bien.

Gracias por tu ayuda

craic.com
fuente
5

Desafortunadamente, probablemente esté buscando una solución que involucre SQL, pero podría establecerla en un ámbito y luego simplemente usar ese ámbito:

class Person
  has_many :contacts
  has_many :friends, :through => :contacts, :uniq => true
  scope :without_friends, where("(select count(*) from contacts where person_id=people.id) = 0")
end

Luego, para obtenerlos, puede hacerlo Person.without_friends, y también puede encadenar esto con otros métodos de Arel:Person.without_friends.order("name").limit(10)

Dylan Markow
fuente
1

Una subconsulta correlacionada NO EXISTE debería ser rápida, particularmente a medida que aumenta el recuento de filas y la proporción de registros de padres a hijos.

scope :without_friends, where("NOT EXISTS (SELECT null FROM contacts where contacts.person_id = people.id)")
David Aldridge
fuente
1

Además, para filtrar por un amigo, por ejemplo:

Friend.where.not(id: other_friend.friends.pluck(:id))
dorio
fuente
3
Esto dará como resultado 2 consultas en lugar de una subconsulta.
grepsedawk 05 de