ActiveRecord Query Union

89

Ich habe einige komplexe Abfragen (zumindest für mich) mit der Abfrageoberfläche von Ruby on Rail geschrieben:

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

Beide Abfragen funktionieren für sich. Beide geben Post-Objekte zurück. Ich möchte diese Beiträge in einer einzigen ActiveRelation kombinieren. Da es irgendwann Hunderttausende von Posts geben kann, muss dies auf Datenbankebene erfolgen. Wenn es eine MySQL-Abfrage wäre, könnte ich einfach den UNIONOperator verwenden. Weiß jemand, ob ich mit der Abfrageschnittstelle von RoR etwas Ähnliches tun kann?

LandonSchropp
quelle
Sie sollten in der Lage sein verwenden Umfang . Erstellen Sie 2 Bereiche und nennen Sie beide wie Post.watched_news_posts.watched_topic_posts. Möglicherweise müssen Sie Parameter für Dinge wie :user_idund an die Bereiche senden :topic.
Zabba
6
Danke für den Vorschlag. In den Dokumenten heißt es: "Ein Bereich stellt eine Verengung einer Datenbankabfrage dar". In meinem Fall suche ich nicht nach Posts, die sich sowohl in watched_news_posts als auch in watchedtopic_posts befinden. Ich suche vielmehr nach Posts, die sich in watch_news_posts oder watchedtopic_posts befinden, ohne dass Duplikate zulässig sind. Ist dies mit Scopes noch möglich?
LandonSchropp
1
Nicht wirklich sofort einsatzbereit. Es gibt ein Plugin auf Github namens Union, aber es verwendet die Syntax der alten Schule (Klassenmethode und Abfrageparameter im Hash-Stil) find_by_sql in Ihrem Bereich.
jenjenut233
1
Ich stimme jenjenut233 zu, und ich würde denken, Sie könnten so etwas tun find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Ich habe das noch nicht getestet. Lassen Sie mich wissen, wie es geht, wenn Sie es versuchen. Außerdem gibt es wahrscheinlich einige ARel-Funktionen, die funktionieren würden.
Zauberer von Ogz
2
Nun, ich habe die Abfragen als SQL-Abfragen umgeschrieben. Sie funktionieren jetzt, können aber leider find_by_sqlnicht mit anderen verkettbaren Abfragen verwendet werden, was bedeutet, dass ich jetzt auch meine will_paginate-Filter und Abfragen neu schreiben muss. Warum unterstützt ActiveRecord eine unionOperation nicht?
LandonSchropp

Antworten:

93

Hier ist ein kurzes kleines Modul, das ich geschrieben habe und mit dem Sie mehrere Bereiche mit UNION verbinden können. Die Ergebnisse werden auch als Instanz von ActiveRecord :: Relation zurückgegeben.

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

Hier ist das Wesentliche: https://gist.github.com/tlowrimore/5162327

Bearbeiten:

Hier ist ein Beispiel für die Funktionsweise von 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
quelle
2
Dies ist wirklich eine viel vollständigere Antwort auf die anderen oben aufgeführten. Funktioniert super!
Ghayes
Anwendungsbeispiel wäre schön.
Ciembor
Wie gewünscht habe ich ein Beispiel hinzugefügt.
Tim Lowrimore
3
Die Lösung ist "fast" korrekt und ich habe sie mit +1 bewertet
Lawrence I. Siden
7
Kurzwarnung: Diese Methode ist unter Leistungsgesichtspunkten mit MySQL äußerst problematisch, da die Unterabfrage für jeden Datensatz in der Tabelle als abhängig gezählt und ausgeführt wird (siehe percona.com/blog/2010/10/25/mysql-limitations-part -3-Unterabfragen ).
Shosti
69

Ich bin auch auf dieses Problem gestoßen, und jetzt besteht meine Strategie darin, SQL (von Hand oder to_sqlin einem vorhandenen Bereich) zu generieren und es dann in die fromKlausel einzufügen. Ich kann nicht garantieren, dass es effizienter ist als Ihre akzeptierte Methode, aber es ist relativ augenschonend und gibt Ihnen ein normales ARel-Objekt zurück.

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

Sie können dies auch mit zwei verschiedenen Modellen tun, aber Sie müssen sicherstellen, dass beide innerhalb der UNION "gleich aussehen". Sie können selectbeide Abfragen verwenden, um sicherzustellen, dass sie dieselben Spalten erzeugen.

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
quelle
Angenommen, wenn wir zwei verschiedene Modelle haben, lassen Sie mich bitte wissen, wie die Abfrage für unoin aussehen wird.
Chitra
Sehr hilfreiche Antwort. Denken Sie für zukünftige Leser an den letzten Abschnitt "AS-Kommentare", da activerecord die Abfrage als "SELECT" -Kommentare "erstellt." * "FROM" ... wenn Sie den Namen der geeinigten Menge nicht angeben ODER einen anderen Namen wie angeben "AS foo", die endgültige SQL-Ausführung wird fehlschlagen.
HeyZiko
1
Genau das habe ich gesucht. Ich habe ActiveRecord :: Relation erweitert, um es #orin meinem Rails 4-Projekt zu unterstützen. Angenommen, das gleiche Modell:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt
11

Basierend auf der Antwort von Olives habe ich eine andere Lösung für dieses Problem gefunden. Es fühlt sich ein bisschen wie ein Hack an, aber es gibt eine Instanz von zurück ActiveRelation, nach der ich überhaupt gesucht habe.

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)

Ich würde es immer noch begrüßen, wenn jemand Vorschläge hat, dies zu optimieren oder die Leistung zu verbessern, da es im Wesentlichen drei Abfragen ausführt und sich etwas überflüssig anfühlt.

LandonSchropp
quelle
Wie könnte ich dasselbe damit machen: gist.github.com/2241307 Damit wird eine AR :: Relation-Klasse anstelle einer Array-Klasse erstellt?
Marc
10

Sie können auch verwenden , Brian Hempel ‚s active_record_union Juwel , das sich ActiveRecordmit einem unionfür Tive Verfahren.

Ihre Anfrage würde so aussehen:

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

Hoffentlich wird dies irgendwann in ActiveRecordeinem Tag zusammengeführt.

dgilperez
quelle
8

Wie wäre es mit...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end
Richard Wan
quelle
15
Seien Sie gewarnt, dass hierdurch drei Abfragen statt einer ausgeführt werden, da jeder pluckAufruf eine Abfrage für sich ist.
JacobEvelyn
3
Dies ist eine wirklich gute Lösung, da es kein Array zurückgibt, also können Sie .orderoder .paginateMethoden verwenden ... Es behält die orm-Klassen
mariowise
Nützlich, wenn die Bereiche vom selben Modell sind, dies jedoch aufgrund des Zupfens zwei Abfragen erzeugen würde.
JMJM
6

Könnten Sie ein ODER anstelle einer UNION verwenden?

Dann könnten Sie so etwas tun wie:

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

(Da Sie zweimal der überwachten Tabelle beitreten, bin ich mir nicht sicher, wie die Namen der Tabellen für die Abfrage lauten werden.)

Da es viele Verknüpfungen gibt, ist die Datenbank möglicherweise sehr stark belastet, kann jedoch optimiert werden.

Oliven
quelle
2
Es tut mir leid, dass ich mich so spät bei Ihnen gemeldet habe, aber ich war in den letzten Tagen im Urlaub. Das Problem, das ich hatte, als ich Ihre Antwort versuchte, war, dass die Joins-Methode dazu führte, dass beide Tabellen verbunden wurden und nicht zwei separate Abfragen, die dann verglichen werden konnten. Ihre Idee war jedoch solide und gab mir eine andere Idee. Danke für die Hilfe.
LandonSchropp
Die Auswahl mit ODER ist langsamer als mit UNION und fragt sich stattdessen nach einer Lösung für UNION
Nich
5

Dies verbessert wohl die Lesbarkeit, aber nicht unbedingt die Leistung:

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

Diese Methode gibt eine ActiveRecord :: Relation zurück, sodass Sie sie folgendermaßen aufrufen können:

my_posts.order("watched_item_type, post.id DESC")
richardsun
quelle
Woher bekommst du posts.id?
Berto77
Es gibt zwei self.id-Parameter, da self.id in SQL zweimal referenziert wird - siehe die beiden Fragezeichen.
Richardsun
Dies war ein nützliches Beispiel dafür, wie eine UNION-Abfrage durchgeführt und eine ActiveRecord :: Relation zurückgegeben wird. Vielen Dank.
Fitter Man
Haben Sie ein Tool zum Generieren dieser Art von SDL-Abfragen - wie haben Sie es gemacht, ohne Rechtschreibfehler usw.?
BKSpurgeon
2

Es gibt ein Juwel aus active_record_union. Könnte hilfreich sein

https://github.com/brianhempel/active_record_union

Mit ActiveRecordUnion können wir Folgendes tun:

die (Entwurfs-) Beiträge des aktuellen Benutzers und alle veröffentlichten Beiträge von jedermann, current_user.posts.union(Post.published) was der folgenden SQL entspricht:

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
quelle
1

Ich würde nur die zwei Abfragen ausführen, die Sie benötigen, und die Arrays der zurückgegebenen Datensätze kombinieren:

@posts = watched_news_posts + watched_topics_posts

Oder zumindest testen. Denken Sie, dass die Array-Kombination in Ruby viel zu langsam sein wird? Wenn ich mir die vorgeschlagenen Fragen ansehe, um das Problem zu umgehen, bin ich nicht davon überzeugt, dass es einen so signifikanten Leistungsunterschied geben wird.

Jeffrey Alan Lee
quelle
Tatsächlich ist es möglicherweise besser, @ posts = Watched_News_posts & Watched_Topics_posts auszuführen, da es sich um eine Kreuzung handelt und Dupes vermieden werden.
Jeffrey Alan Lee
1
Ich hatte den Eindruck, dass ActiveRelation seine Datensätze träge lädt. Würden Sie das nicht verlieren, wenn Sie die Arrays in Ruby schneiden würden?
LandonSchropp
Anscheinend ist eine Gewerkschaft, die eine Beziehung zurückgibt, unter Entwickler in Schienen, aber ich weiß nicht, in welcher Version es sein wird.
Jeffrey Alan Lee
1
Bei diesem Rückgabearray werden stattdessen die beiden unterschiedlichen Abfrageergebnisse zusammengeführt.
Alexzg
1

In einem ähnlichen Fall habe ich zwei Arrays summiert und verwendet Kaminari:paginate_array(). Sehr schöne und funktionierende Lösung. Ich konnte nicht verwenden where(), da ich zwei Ergebnisse mit unterschiedlichen Ergebnissen order()in derselben Tabelle summieren muss .

sekrett
quelle
1

Weniger Probleme und leichter zu folgen:

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

Also am Ende:

union_scope(watched_news_posts, watched_topic_posts)
Dmitry Polushkin
quelle
1
Ich habe es leicht geändert in: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }Thx!
Eikes
0

Elliot Nelson antwortete gut, außer in dem Fall, in dem einige der Beziehungen leer sind. Ich würde so etwas tun:

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)

Ende

Ehud
quelle
0

Hier ist, wie ich SQL-Abfragen mit UNION in meiner eigenen Ruby on Rails-Anwendung verbunden habe.

Sie können das Folgende als Inspiration für Ihren eigenen Code verwenden.

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

Unten ist die UNION, in der ich die Bereiche zusammengefügt habe.

  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
quelle