ActiveRecord Query Union


90

Ho scritto un paio di query complesse (almeno a me) con Ruby sull'interfaccia di query di 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})

Entrambe queste query funzionano bene da sole. Entrambi restituiscono oggetti Post. Vorrei combinare questi post in un unico ActiveRelation. Poiché a un certo punto potrebbero esserci centinaia di migliaia di post, è necessario farlo a livello di database. Se fosse una query MySQL, potrei semplicemente utilizzare l' UNIONoperatore. Qualcuno sa se posso fare qualcosa di simile con l'interfaccia di query di RoR?


Dovresti essere in grado di utilizzare l' ambito . Crea 2 ambiti e poi chiamali entrambi come Post.watched_news_posts.watched_topic_posts. Potrebbe essere necessario inviare parametri agli ambiti per cose come :user_ide :topic.
Zabba

6
Grazie per il suggerimento. Secondo i documenti, "Un ambito rappresenta un restringimento di una query di database". Nel mio caso, non sto cercando post che siano sia inwatch_news_posts che in watch_topic_posts. Piuttosto, sto cercando post che si trovano inwatch_news_posts o watch_topic_posts, senza duplicati consentiti. È ancora possibile farlo con gli ambiti?
LandonSchropp

1
Non proprio possibile fuori dagli schemi. C'è un plugin su GitHub chiamato union ma usa la sintassi della vecchia scuola (metodo di classe e parametri di query in stile hash), se per te va bene ti direi di seguirlo ... altrimenti scrivilo nel lungo modo in un find_by_sql nel tuo ambito.
jenjenut233

1
Sono d'accordo con jenjenut233 e penso che potresti fare qualcosa di simile find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Non l'ho testato, quindi fammi sapere come va se lo provi. Inoltre, probabilmente ci sono alcune funzionalità ARel che funzionerebbero.
mago di Ogz

2
Bene, ho riscritto le query come query SQL. Funzionano ora, ma sfortunatamente find_by_sqlnon possono essere utilizzati con altre query concatenabili, il che significa che ora devo riscrivere anche i miei filtri e query will_paginate. Perché ActiveRecord non supporta unionun'operazione?
LandonSchropp

Risposte:


93

Ecco un piccolo modulo veloce che ho scritto che ti consente di UNION più ambiti. Restituisce inoltre i risultati come istanza di 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

Ecco il succo: https://gist.github.com/tlowrimore/5162327

Modificare:

Come richiesto, ecco un esempio di come funziona 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

2
Questa è davvero una risposta molto più completa delle altre sopra elencate. Funziona alla grande!
ghayes

Un esempio di utilizzo sarebbe carino.
ciembor

Come richiesto, ho aggiunto un esempio.
Tim Lowrimore

3
La soluzione è "quasi" corretta e ho dato un +1, ma mi sono imbattuto in un problema che ho risolto qui: gist.github.com/lsiden/260167a4d3574a580d97
Lawrence I. Siden

7
Avviso rapido: questo metodo è altamente problematico dal punto di vista delle prestazioni con MySQL, poiché la sottoquery verrà conteggiata come dipendente ed eseguita per ogni record nella tabella (vedere percona.com/blog/2010/10/25/mysql-limitations-part -3-sottoquery ).
shosti

70

Ho anche riscontrato questo problema e ora la mia strategia di riferimento è generare SQL (a mano o utilizzando to_sqlun ambito esistente) e quindi inserirlo nella fromclausola. Non posso garantire che sia più efficiente del tuo metodo accettato, ma è relativamente facile per gli occhi e ti restituisce un normale oggetto ARel.

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")

Puoi farlo anche con due modelli diversi, ma devi assicurarti che entrambi "abbiano lo stesso aspetto" all'interno di UNION - puoi usarli selectsu entrambe le query per assicurarti che producano le stesse colonne.

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")

supponiamo che se abbiamo due modelli diversi, fammi sapere quale sarà la query per unoin.
Chitra

Risposta molto utile. Per i lettori futuri, ricorda la parte finale "Commenti AS" perché activerecord costruisce la query come "SELEZIONA" commenti "." * "DA" ... se non specifichi il nome dell'insieme unito OPPURE specifica un nome diverso come "AS foo", l'esecuzione SQL finale fallirà.
HeyZiko

1
Questo era esattamente quello che stavo cercando. Ho esteso ActiveRecord :: Relation al supporto #ornel mio progetto Rails 4. Supponendo lo stesso modello:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt

11

Sulla base della risposta di Olives, ho trovato un'altra soluzione a questo problema. Sembra un po 'un hack, ma restituisce un'istanza di ActiveRelation, che è quello che stavo cercando in primo luogo.

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)

Lo apprezzerei comunque se qualcuno avesse qualche suggerimento per ottimizzare questo o migliorare le prestazioni, perché essenzialmente sta eseguendo tre query e sembra un po 'ridondante.


Come potrei fare la stessa cosa con questo: gist.github.com/2241307 in modo che crei una classe AR :: Relation piuttosto che una classe Array?
Marc

10

Puoi anche usare la gemma active_record_union di Brian Hempel che si estende con un metodo per gli ambiti.ActiveRecordunion

La tua domanda sarebbe come questa:

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

Si spera che questo possa essere fuso in ActiveRecordun giorno.


8

Che ne dite di...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end

15
Tieni presente che questo eseguirà tre query anziché una, poiché ogni pluckchiamata è una query in sé.
JacobEvelyn

3
Questa è davvero una buona soluzione, perché non restituisce un array, quindi puoi usare .ordero .paginatemetodi ... Mantiene le classi orm
mariowise

Utile se gli ambiti sono dello stesso modello, ma ciò genererebbe due query a causa delle pizze.
jmjm

6

Potresti usare un OR invece di UNION?

Quindi potresti fare qualcosa come:

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

(Dal momento che ti unisci alla tabella osservata due volte, non sono sicuro di quali saranno i nomi delle tabelle per la query)

Poiché ci sono molti join, potrebbe anche essere piuttosto pesante sul database, ma potrebbe essere ottimizzato.


2
Mi dispiace tornare da te così tardi, ma sono stato in vacanza negli ultimi due giorni. Il problema che ho avuto quando ho provato la tua risposta è stato che il metodo dei join causava l'unione di entrambe le tabelle, piuttosto che due query separate che potevano essere confrontate. Tuttavia, la tua idea era valida e mi ha dato un'altra idea. Grazie per l'aiuto.
LandonSchropp

selezionare usando OR è più lento di UNION, chiedendosi invece qualsiasi soluzione per UNION
Nich

5

Probabilmente, questo migliora la leggibilità, ma non necessariamente le prestazioni:

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

Questo metodo restituisce un ActiveRecord :: Relation, quindi puoi chiamarlo in questo modo:

my_posts.order("watched_item_type, post.id DESC")

da dove prendi posts.id?
berto77

Ci sono due parametri self.id perché self.id è referenziato due volte nell'SQL - vedere i due punti interrogativi.
richardsun

Questo è stato un utile esempio di come eseguire una query UNION e recuperare un ActiveRecord :: Relation. Grazie.
Fitter Man

disponi di uno strumento per generare questi tipi di query SDL - come hai fatto a farlo senza errori di ortografia, ecc.?
BKSpurgeon

2

C'è una gemma active_record_union. Potrebbe essere utile

https://github.com/brianhempel/active_record_union

Con ActiveRecordUnion possiamo fare:

i post (bozza) dell'utente corrente e tutti i post pubblicati da chiunque Il current_user.posts.union(Post.published) che è equivalente al seguente 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

1

Vorrei solo eseguire le due query di cui hai bisogno e combinare gli array di record che vengono restituiti:

@posts = watched_news_posts + watched_topics_posts

O almeno provalo. Pensi che la combinazione di array in ruby ​​sarà troppo lenta? Guardando le query suggerite per aggirare il problema, non sono convinto che ci sarà una differenza di prestazioni così significativa.


In realtà, fare @ posts =watch_news_posts e watch_topics_posts potrebbe essere migliore in quanto è un incrocio ed eviterà gli imbrogli.
Jeffrey Alan Lee

1
Avevo l'impressione che ActiveRelation carichi i suoi record pigramente. Non lo perderesti se intersecassi gli array in Ruby?
LandonSchropp

Apparentemente un'unione che restituisce una relazione è in fase di sviluppo in rotaie, ma non so in quale versione sarà.
Jeffrey Alan Lee

1
questo array di ritorno, invece, i suoi due diversi risultati di query si fondono.
alexzg

1

In un caso simile ho sommato due array e ho usato Kaminari:paginate_array(). Soluzione molto bella e funzionante. Non sono stato in grado di utilizzare where(), perché ho bisogno di sommare due risultati con diversi order()sulla stessa tabella.


1

Meno problemi e più facile da seguire:

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

Quindi alla fine:

union_scope(watched_news_posts, watched_topic_posts)

1
L'ho cambiato leggermente in: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }Thx!
come il

0

Elliot Nelson ha risposto bene, tranne il caso in cui alcune delle relazioni sono vuote. Farei qualcosa del genere:

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)

fine


0

Ecco come ho unito le query SQL usando UNION sulla mia applicazione ruby ​​on rails.

Puoi utilizzare quanto segue come ispirazione per il tuo codice.

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

Di seguito è riportata l'UNIONE in cui ho unito gli ambiti.

  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
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.