Deseja encontrar registros sem registros associados no Rails

178

Considere uma associação simples ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Qual é a maneira mais limpa de obter todas as pessoas que NÃO têm amigos no ARel e / ou meta_where?

E então, que tal um has_many: através da versão

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

Eu realmente não quero usar counter_cache - e pelo que li, não funciona com has_many: através

Eu não quero puxar todos os registros person.friends e percorrê-los no Ruby - quero ter uma consulta / escopo que possa ser usada com a meta meta_search

Não me importo com o custo de desempenho das consultas

E quanto mais longe do SQL real, melhor ...

craic.com
fonte

Respostas:

110

Isso ainda está muito próximo do SQL, mas deve ter todos sem amigos no primeiro caso:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
fonte
6
Imagine que você tem 10000000 registros na tabela de amigos. E o desempenho nesse caso?
goodniceweb
@goodniceweb Dependendo da sua frequência duplicada, você provavelmente pode largar o DISTINCT. Caso contrário, acho que você deseja normalizar os dados e o índice nesse caso. Eu poderia fazer isso criando uma friend_idshstore ou coluna serializada. Então você poderia dizerPerson.where(friend_ids: nil)
Unixmonkey
Se você for usar o sql, provavelmente é melhor usar not exists (select person_id from friends where person_id = person.id)(ou talvez people.idou persons.id, dependendo da sua tabela). Não tenho certeza do que é mais rápido em uma situação específica, mas no passado isso funcionou bem para mim quando eu não estava tentando usar o ActiveRecord.
Nrose
442

Melhor:

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

Para o hmt, é basicamente a mesma coisa, você confia no fato de que uma pessoa sem amigos também não terá contatos:

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

Atualizar

Tenho uma pergunta sobre has_onenos comentários, então é só atualizar. O truque aqui é que includes()espera o nome da associação, mas whereespera o nome da tabela. Para um, has_onea associação será geralmente expressa no singular, de modo que muda, mas a where()parte permanece como está. Portanto, se Personapenas has_one :contact, sua declaração seria:

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

Atualização 2

Alguém perguntou sobre o inverso, amigos sem pessoas. Como eu comentei abaixo, isso realmente me fez perceber que o último campo (acima: the :person_id) não precisa estar relacionado ao modelo que você está retornando, apenas um campo na tabela de junção. Todos eles serão nilpara que possa ser qualquer um deles. Isso leva a uma solução mais simples para o acima:

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

E, em seguida, alternar para devolver os amigos sem pessoas fica ainda mais simples, você muda apenas a classe na frente:

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

Atualização 3 - Rails 5

Obrigado a @Anson pela excelente solução Rails 5 (dê alguns + 1s para a resposta abaixo), você pode usar left_outer_joinspara evitar o carregamento da associação:

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

Eu o incluí aqui para que as pessoas o encontrem, mas ele merece os + 1s por isso. Ótima adição!

Atualização 4 - Rails 6.1

Agradecemos a Tim Park por apontar que, nos próximos 6.1, você poderá fazer o seguinte:

Person.where.missing(:contacts)

Graças ao post que ele vinculou também.

smathy
fonte
4
Você pode incorporar isso em um escopo que seria muito mais limpo.
Oct12
3
Resposta muito melhor, sem saber por que o outro é classificado como aceito.
Tamik Soziev 22/02
5
Sim, apenas, assumindo que você tenha um nome singular para sua has_oneassociação, é necessário alterar o nome da associação na includeschamada. Assim, supondo que era has_one :contactpor dentro Person, em seguida, seu código seriaPerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy
3
Se você estiver usando um nome de tabela personalizado no seu modelo de amigo ( self.table_name = "custom_friends_table_name"), use Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
5
@ smathy Uma boa atualização no Rails 6.1 adiciona um missingmétodo para fazer exatamente isso !
Tim Park
172

smathy tem uma boa resposta do Rails 3.

Para o Rails 5 , você pode left_outer_joinsevitar o carregamento da associação.

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

Confira os documentos da API . Foi introduzido na solicitação de recebimento nº 12071 .

Anson
fonte
Há alguma desvantagem nisso? Eu verifiquei e carregado de 0,1 ms .Inclui mais rápido, em seguida,
Qwertie
Não carregar a associação é uma desvantagem se você realmente acessá-la mais tarde, mas um benefício se você não acessá-la. Para meus sites, um acerto de 0,1ms é bastante insignificante, portanto, .includeso custo extra no tempo de carregamento não seria algo que eu me preocuparia muito em otimizar. Seu caso de uso pode ser diferente.
Anson
1
E se você ainda não possui o Rails 5, pode fazer o seguinte: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')Ele também funciona bem como um escopo. Eu faço isso o tempo todo em meus projetos Rails.
Frank
3
A grande vantagem desse método é a economia de memória. Quando você faz um includes, todos esses objetos AR são carregados na memória, o que pode ser uma coisa ruim à medida que as tabelas ficam cada vez maiores. Se você não precisar acessar o registro de contato, left_outer_joinsele não carregará o contato na memória. A velocidade da solicitação SQL é a mesma, mas o benefício geral do aplicativo é muito maior.
Chrismanderson 23/03
2
Isto é muito bom! Obrigado! Agora, se os deuses dos trilhos talvez pudessem implementá-lo como um simples, Person.where(contacts: nil)ou Person.with(contact: contact)se o uso for muito invasivo da 'propensão' - mas, considerando esse contato: já está sendo analisado e identificado como uma associação, parece lógico que o arel possa facilmente descobrir o que é necessário ...
Justin Maxwell
14

Pessoas que não têm amigos

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

Ou que tenham pelo menos um amigo

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

Você pode fazer isso com a Arel configurando escopos em 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

E então, pessoas que têm pelo menos um amigo:

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

Os sem amigos:

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
fonte
2
Eu acho que você também pode fazer: Person.includes (: amigos) .onde (amigos: {pessoa: nil})
ReggieB
1
Nota: A estratégia de mesclagem às vezes pode gerar um aviso 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

As respostas de dmarkow e Unixmonkey me dão o que eu preciso - obrigado!

Tentei os dois no meu aplicativo real e obtive horários para eles - Aqui estão os dois escopos:

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

Executei isso com um aplicativo real - mesa pequena com ~ 700 registros 'Pessoa' - média de 5 execuções

Abordagem de Unixmonkey ( :without_friends_v1) 813ms / consulta

abordagem de dmarkow ( :without_friends_v2) 891ms / consulta (~ 10% mais lenta)

Mas então me ocorreu que eu não preciso da ligação para DISTINCT()...procurar Personregistros com NO Contacts- então eles só precisam ser NOT INa lista de contatos person_ids. Então, eu tentei este escopo:

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

O resultado é o mesmo, mas com uma média de 425 ms / chamada - quase metade do tempo ...

Agora você pode precisar de DISTINCToutras consultas semelhantes - mas, no meu caso, isso parece funcionar bem.

Obrigado pela ajuda

craic.com
fonte
5

Infelizmente, você provavelmente está procurando uma solução envolvendo SQL, mas pode defini-la em um escopo e depois usá-lo:

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

Em seguida, para obtê-los, você pode simplesmente fazer isso Person.without_friendse também pode encadear isso com outros métodos da Arel:Person.without_friends.order("name").limit(10)

Dylan Markow
fonte
1

Uma subconsulta correlacionada NOT EXISTS deve ser rápida, principalmente à medida que a contagem de linhas e a proporção de registros filho / pai aumentam.

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

Além disso, para filtrar por um amigo, por exemplo:

Friend.where.not(id: other_friend.friends.pluck(:id))
Dorian
fonte
3
Isso resultará em 2 consultas, em vez de uma subconsulta.
grepsedawk