União de consulta ActiveRecord

90

Eu escrevi algumas consultas complexas (pelo menos para mim) com a interface de consulta do Ruby on Rail:

watched_news_posts = Post.joins(:news => :watched).where(:watched => {:user_id => id})
watched_topic_posts = Post.joins(:post_topic_relationships => {:topic => :watched}).where(:watched => {:user_id => id})

Ambas as consultas funcionam bem sozinhas. Ambos retornam objetos Post. Eu gostaria de combinar essas postagens em um único ActiveRelation. Como pode haver centenas de milhares de postagens em algum ponto, isso precisa ser feito no nível do banco de dados. Se fosse uma consulta MySQL, eu poderia simplesmente usar o UNIONoperador. Alguém sabe se posso fazer algo semelhante com a interface de consulta do RoR?

LandonSchropp
fonte
Você deve ser capaz de usar o escopo . Crie 2 escopos e chame ambos de like Post.watched_news_posts.watched_topic_posts. Pode ser necessário enviar params para os escopos de coisas como :user_ide :topic.
Zabba
6
Obrigado pela sugestão. De acordo com os documentos, "Um escopo representa um estreitamento de uma consulta de banco de dados". No meu caso, não estou procurando por posts que estão em watch_news_posts e Watch_topic_posts. Em vez disso, estou procurando postagens que estão em Watch_news_posts ou Watch_topic_posts, sem duplicatas permitidas. Isso ainda é possível fazer com escopos?
LandonSchropp
1
Não é realmente possível fora da caixa. Existe um plug-in no github chamado union, mas ele usa a sintaxe da velha escola (método de classe e parâmetros de consulta no estilo hash), se estiver tudo bem para você, eu diria que vá com ele ... caso contrário, escreva-o por um longo caminho find_by_sql em seu escopo.
jenjenut233
1
Eu concordo com jenjenut233, e acho que você poderia fazer algo assim find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Eu não testei isso, então me diga como foi se você tentar. Além disso, provavelmente há alguma funcionalidade ARel que funcionaria.
Mago de Ogz
2
Bem, eu reescrevi as consultas como consultas SQL. Eles funcionam agora, mas infelizmente find_by_sqlnão podem ser usados ​​com outras consultas encadeadas, o que significa que agora tenho que reescrever meus filtros e consultas will_paginate também. Por que o ActiveRecord não oferece suporte a uma unionoperação?
LandonSchropp de

Respostas:

93

Aqui está um pequeno módulo rápido que escrevi que permite UNIONAR múltiplos escopos. Ele também retorna os resultados como uma instância de ActiveRecord :: Relation.

module ActiveRecord::UnionScope
  def self.included(base)
    base.send :extend, ClassMethods
  end

  module ClassMethods
    def union_scope(*scopes)
      id_column = "#{table_name}.id"
      sub_query = scopes.map { |s| s.select(id_column).to_sql }.join(" UNION ")
      where "#{id_column} IN (#{sub_query})"
    end
  end
end

Aqui está a essência: https://gist.github.com/tlowrimore/5162327

Editar:

Conforme solicitado, aqui está um exemplo de como funciona o UnionScope:

class Property < ActiveRecord::Base
  include ActiveRecord::UnionScope

  # some silly, contrived scopes
  scope :active_nearby,     -> { where(active: true).where('distance <= 25') }
  scope :inactive_distant,  -> { where(active: false).where('distance >= 200') }

  # A union of the aforementioned scopes
  scope :active_near_and_inactive_distant, -> { union_scope(active_nearby, inactive_distant) }
end
Tim Lowrimore
fonte
2
Esta é realmente uma forma de resposta mais completa das outras listadas acima. Funciona bem!
ghayes
Um exemplo de uso seria bom.
ciembor
Conforme solicitado, adicionei um exemplo.
Tim Lowrimore
3
A solução está "quase" correta e eu dei um +1, mas encontrei um problema que consertei
Lawrence I. Siden
7
Aviso rápido: este método é altamente problemático do ponto de vista de desempenho com o MySQL, uma vez que a subconsulta será contada como dependente e executada para cada registro na tabela (ver percona.com/blog/2010/10/25/mysql-limitations-part -3-subconsultas ).
shosti
70

Eu também encontrei esse problema e agora minha estratégia inicial é gerar SQL (manualmente ou usando to_sqlem um escopo existente) e, em seguida, inseri-lo na fromcláusula. Não posso garantir que seja mais eficiente do que o método aceito, mas é relativamente fácil para os olhos e devolve um objeto ARel normal.

watched_news_posts = Post.joins(:news => :watched).where(:watched => {:user_id => id})
watched_topic_posts = Post.joins(:post_topic_relationships => {:topic => :watched}).where(:watched => {:user_id => id})

Post.from("(#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}) AS posts")

Você também pode fazer isso com dois modelos diferentes, mas precisa ter certeza de que ambos "têm a mesma aparência" dentro do UNION - você pode usar selectem ambas as consultas para ter certeza de que produzirão as mesmas colunas.

topics = Topic.select('user_id AS author_id, description AS body, created_at')
comments = Comment.select('author_id, body, created_at')

Comment.from("(#{comments.to_sql} UNION #{topics.to_sql}) AS comments")
Elliot Nelson
fonte
suponha que se tivermos dois modelos diferentes, diga-me qual será a consulta para unoin.
Chitra
Resposta muito útil. Para leitores futuros, lembre-se da parte final dos "comentários AS" porque activerecord constrói a consulta como 'SELECT "comentários". "*" FROM "... se você não especificar o nome do conjunto unido OU especificar um nome diferente, como "AS foo", a execução final do sql falhará.
HeyZiko
1
Isso era exatamente o que eu estava procurando. Eu estendi ActiveRecord :: Relation para suportar #orem meu projeto Rails 4. Supondo o mesmo modelo:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt
11

Com base na resposta do Olives, descobri outra solução para esse problema. Parece um pouco como um hack, mas retorna uma instância de ActiveRelation, que é o que eu estava procurando em primeiro lugar.

Post.where('posts.id IN 
      (
        SELECT post_topic_relationships.post_id FROM post_topic_relationships
          INNER JOIN "watched" ON "watched"."watched_item_id" = "post_topic_relationships"."topic_id" AND "watched"."watched_item_type" = "Topic" WHERE "watched"."user_id" = ?
      )
      OR posts.id IN
      (
        SELECT "posts"."id" FROM "posts" INNER JOIN "news" ON "news"."id" = "posts"."news_id" 
        INNER JOIN "watched" ON "watched"."watched_item_id" = "news"."id" AND "watched"."watched_item_type" = "News" WHERE "watched"."user_id" = ?
      )', id, id)

Ainda agradeceria se alguém tivesse alguma sugestão para otimizar isso ou melhorar o desempenho, porque essencialmente está executando três consultas e parece um pouco redundante.

LandonSchropp
fonte
Como eu poderia fazer a mesma coisa com isso: gist.github.com/2241307 Para que ele crie uma classe AR :: Relation em vez de uma classe Array?
Marc
10

Você também pode usar Brian Hempel 's active_record_union gem que se estende ActiveRecordcom um unionmétodo para escopos.

Sua consulta seria assim:

Post.joins(:news => :watched).
  where(:watched => {:user_id => id}).
  union(Post.joins(:post_topic_relationships => {:topic => :watched}
    .where(:watched => {:user_id => id}))

Esperançosamente isso será eventualmente mesclado em ActiveRecordalgum dia.

dgilperez
fonte
8

E se...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end
Richard Wan
fonte
15
Esteja avisado que isso executará três consultas em vez de uma, pois cada pluckchamada é uma consulta em si.
JacobEvelyn
3
Esta é uma solução muito boa, porque ela não retorna um array, então você pode usar os métodos .orderou .paginate... Ele mantém as classes orm
mariowise
Útil se os escopos forem do mesmo modelo, mas isso geraria duas consultas por causa dos plucks.
jmjm
6

Você poderia usar um OR em vez de um UNION?

Então você poderia fazer algo como:

Post.joins(:news => :watched, :post_topic_relationships => {:topic => :watched})
.where("watched.user_id = :id OR topic_watched.user_id = :id", :id => id)

(Como você se junta à tabela observada duas vezes, não tenho certeza de quais serão os nomes das tabelas para a consulta)

Como há muitas junções, também pode ser muito pesado no banco de dados, mas pode ser otimizado.

Azeitonas
fonte
2
Desculpe por entrar em contato com você tão tarde, mas estive de férias nos últimos dias. O problema que tive quando tentei sua resposta foi que o método joins estava fazendo com que as duas tabelas fossem unidas, em vez de duas consultas separadas que poderiam ser comparadas. No entanto, sua ideia era boa e me deu outra ideia. Obrigado pela ajuda.
LandonSchropp
selecionar usando OR é mais lento do que UNION, imaginando qualquer solução para UNION em vez
Nich
5

Indiscutivelmente, isso melhora a legibilidade, mas não necessariamente o desempenho:

def my_posts
  Post.where <<-SQL, self.id, self.id
    posts.id IN 
    (SELECT post_topic_relationships.post_id FROM post_topic_relationships
    INNER JOIN watched ON watched.watched_item_id = post_topic_relationships.topic_id 
    AND watched.watched_item_type = "Topic" 
    AND watched.user_id = ?
    UNION
    SELECT posts.id FROM posts 
    INNER JOIN news ON news.id = posts.news_id 
    INNER JOIN watched ON watched.watched_item_id = news.id 
    AND watched.watched_item_type = "News" 
    AND watched.user_id = ?)
  SQL
end

Este método retorna um ActiveRecord :: Relation, então você pode chamá-lo assim:

my_posts.order("watched_item_type, post.id DESC")
Richardsun
fonte
de onde você está obtendo posts.id?
berto77
Existem dois parâmetros self.id porque self.id é referenciado duas vezes no SQL - veja os dois pontos de interrogação.
richardsun
Este foi um exemplo útil de como fazer uma consulta UNION e obter um ActiveRecord :: Relation. Obrigado.
Fitter Man
você tem uma ferramenta para gerar esses tipos de consultas SDL - como você fez isso sem erros de ortografia etc.?
BKSpurgeon
2

Existe uma gem active_record_union. Pode ser útil

https://github.com/brianhempel/active_record_union

Com ActiveRecordUnion, podemos fazer:

as postagens (rascunho) do usuário atual e todas as postagens publicadas de qualquer pessoa, o current_user.posts.union(Post.published) que é equivalente ao seguinte SQL:

SELECT "posts".* FROM (
  SELECT "posts".* FROM "posts"  WHERE "posts"."user_id" = 1
  UNION
  SELECT "posts".* FROM "posts"  WHERE (published_at < '2014-07-19 16:04:21.918366')
) posts
Mike Lyubarskyy
fonte
1

Gostaria apenas de executar as duas consultas de que você precisa e combinar as matrizes de registros que são retornados:

@posts = watched_news_posts + watched_topics_posts

Ou, pelo menos, faça um teste. Você acha que a combinação do array em ruby ​​será muito lenta? Olhando para as consultas sugeridas para contornar o problema, não estou convencido de que haverá uma diferença significativa de desempenho.

Jeffrey Alan Lee
fonte
Na verdade, fazer @ posts = Watch_news_posts & Watch_topics_posts pode ser melhor, pois é um cruzamento e evitará enganos.
Jeffrey Alan Lee
1
Tive a impressão de que o ActiveRelation carrega seus registros preguiçosamente. Você não perderia isso se cruzasse as matrizes em Ruby?
LandonSchropp de
Aparentemente, uma união que retorna uma relação está sob dev in rails, mas não sei em que versão estará.
Jeffrey Alan Lee
1
em vez disso, essa matriz de retorno, seus dois resultados de consulta diferentes se fundem.
alexzg
1

Em um caso semelhante, somei dois arrays e usei Kaminari:paginate_array(). Solução muito agradável e funcional. Não consegui usar where(), pois preciso somar dois resultados com diferentes order()na mesma tabela.

sekrett
fonte
1

Menos problemas e mais fácil de seguir:

    def union_scope(*scopes)
      scopes[1..-1].inject(where(id: scopes.first)) { |all, scope| all.or(where(id: scope)) }
    end

Então, no final:

union_scope(watched_news_posts, watched_topic_posts)
Dmitry Polushkin
fonte
1
Mudei um pouco para: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }Thx!
eikes
0

Elliot Nelson respondeu bem, exceto no caso em que algumas das relações estão vazias. Eu faria algo assim:

def union_2_relations(relation1,relation2)
sql = ""
if relation1.any? && relation2.any?
  sql = "(#{relation1.to_sql}) UNION (#{relation2.to_sql}) as #{relation1.klass.table_name}"
elsif relation1.any?
  sql = relation1.to_sql
elsif relation2.any?
  sql = relation2.to_sql
end
relation1.klass.from(sql)

fim

Ehud
fonte
0

Veja como juntei consultas SQL usando UNION em meu próprio aplicativo Ruby on Rails.

Você pode usar o seguinte como inspiração em seu próprio código.

class Preference < ApplicationRecord
  scope :for, ->(object) { where(preferenceable: object) }
end

Abaixo está a UNION onde juntei os escopos.

  def zone_preferences
    zone = Zone.find params[:zone_id]
    zone_sql = Preference.for(zone).to_sql
    region_sql = Preference.for(zone.region).to_sql
    operator_sql = Preference.for(Operator.current).to_sql

    Preference.from("(#{zone_sql} UNION #{region_sql} UNION #{operator_sql}) AS preferences")
  end
joeyk16
fonte