Union de requêtes ActiveRecord

90

J'ai écrit quelques requêtes complexes (du moins pour moi) avec l'interface de requête de 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})

Ces deux requêtes fonctionnent bien par elles-mêmes. Les deux renvoient des objets Post. Je voudrais combiner ces messages en une seule ActiveRelation. Puisqu'il pourrait y avoir des centaines de milliers de messages à un moment donné, cela doit être fait au niveau de la base de données. S'il s'agissait d'une requête MySQL, je pourrais simplement utiliser l' UNIONopérateur. Est-ce que quelqu'un sait si je peux faire quelque chose de similaire avec l'interface de requête de RoR?

LandonSchropp
la source
Vous devriez pouvoir utiliser la portée . Créez 2 étendues, puis appelez-les toutes les deux comme Post.watched_news_posts.watched_topic_posts. Vous devrez peut-être envoyer des paramètres aux portées pour des éléments tels que :user_idet :topic.
Zabba le
6
Merci pour la suggestion. Selon la documentation, "une portée représente un rétrécissement d'une requête de base de données". Dans mon cas, je ne recherche pas les messages qui se trouvent à la fois dans watch_news_posts et watch_topic_posts. Je recherche plutôt des messages qui sont dans watch_news_posts ou watch_topic_posts, sans doublons autorisés. Est-ce encore possible d'accomplir avec des portées?
LandonSchropp le
1
Pas vraiment possible hors de la boîte. Il y a un plugin sur github appelé union mais il utilise une syntaxe old-school (méthode de classe et paramètres de requête de style haché), si cela vous convient, je dirais d'aller avec ... sinon, écrivez-le dans le long chemin find_by_sql dans votre portée.
jenjenut233
1
Je suis d'accord avec jenjenut233, et je pense que vous pourriez faire quelque chose comme find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Je n'ai pas testé cela, alors dites-moi comment cela se passe si vous l'essayez. De plus, il existe probablement des fonctionnalités ARel qui fonctionneraient.
Wizard of Ogz
2
Eh bien, j'ai réécrit les requêtes en requêtes SQL. Ils fonctionnent maintenant, mais find_by_sqlne peuvent malheureusement pas être utilisés avec d'autres requêtes chaînables, ce qui signifie que je dois maintenant réécrire mes filtres et requêtes will_paginate également. Pourquoi ActiveRecord ne prend-il pas en charge une unionopération?
LandonSchropp

Réponses:

93

Voici un petit module rapide que j'ai écrit qui vous permet de UNION plusieurs portées. Il renvoie également les résultats sous forme d'instance d'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

Voici l'essentiel: https://gist.github.com/tlowrimore/5162327

Éditer:

Comme demandé, voici un exemple du fonctionnement d'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
la source
2
C'est vraiment une réponse beaucoup plus complète aux autres énumérées ci-dessus. Fonctionne très bien!
ghayes
Un exemple d'utilisation serait bien.
ciembor
Comme demandé, j'ai ajouté un exemple.
Tim Lowrimore
3
La solution est "presque" correcte et je lui ai donné un +1, mais j'ai rencontré un problème que j'ai corrigé ici: gist.github.com/lsiden/260167a4d3574a580d97
Lawrence I. Siden
7
Avertissement rapide: cette méthode est très problématique du point de vue des performances avec MySQL, car la sous-requête sera comptée comme dépendante et exécutée pour chaque enregistrement de la table (voir percona.com/blog/2010/10/25/mysql-limitations-part -3 sous-requêtes ).
shosti
70

J'ai également rencontré ce problème, et maintenant ma stratégie de choix consiste à générer du SQL (à la main ou à l'aide to_sqld'une portée existante), puis à le coller dans la fromclause. Je ne peux pas garantir que ce soit plus efficace que votre méthode acceptée, mais c'est relativement facile pour les yeux et vous donne un objet 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")

Vous pouvez également le faire avec deux modèles différents, mais vous devez vous assurer qu'ils "se ressemblent" tous les deux à l'intérieur de UNION - vous pouvez utiliser selectsur les deux requêtes pour vous assurer qu'ils produiront les mêmes colonnes.

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
la source
supposons que si nous avons deux modèles différents, veuillez me faire savoir quelle sera la requête pour unoin.
Chitra
Réponse très utile. Pour les futurs lecteurs, souvenez-vous de la dernière partie "AS comments" car activerecord construit la requête comme 'SELECT "comments". "*" FROM "... si vous ne spécifiez pas le nom de l'ensemble uni OU spécifiez un nom différent comme "AS foo", l'exécution finale de SQL échouera.
HeyZiko
1
C'était exactement ce que je cherchais. J'ai étendu ActiveRecord :: Relation à la prise #oren charge dans mon projet Rails 4. En supposant le même modèle:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt
11

Sur la base de la réponse d'Olives, j'ai trouvé une autre solution à ce problème. Cela ressemble un peu à un hack, mais il renvoie une instance de ActiveRelation, ce que je cherchais en premier lieu.

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)

J'apprécierais toujours si quelqu'un a des suggestions pour optimiser cela ou améliorer les performances, car il exécute essentiellement trois requêtes et se sent un peu redondant.

LandonSchropp
la source
Comment pourrais-je faire la même chose avec ceci: gist.github.com/2241307 Pour qu'il crée une classe AR :: Relation plutôt qu'une classe Array?
Marc
10

Vous pouvez également utiliser Brian Hempel de active_record_union joyau qui se prolonge ActiveRecordavec une unionméthode pour les portées.

Votre requête serait comme ceci:

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

Espérons que cela sera éventuellement fusionné dans ActiveRecordun jour.

dgilperez
la source
8

Que diriez-vous...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end
Richard Wan
la source
15
Soyez averti que cela exécutera trois requêtes plutôt qu'une, car chaque pluckappel est une requête en soi.
JacobEvelyn
3
C'est une très bonne solution, car elle ne renvoie pas de tableau, alors vous pouvez utiliser des méthodes .orderou .paginate... Il garde les classes orm
mariowise
Utile si les oscilloscopes sont du même modèle, mais cela générerait deux requêtes à cause des plucks.
jmjm
6

Pourriez-vous utiliser un OR au lieu d'un UNION?

Ensuite, vous pouvez faire quelque chose comme:

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

(Puisque vous rejoignez la table surveillée deux fois, je ne sais pas trop quels seront les noms des tables pour la requête)

Comme il y a beaucoup de jointures, cela peut également être assez lourd sur la base de données, mais il peut être optimisé.

Olives
la source
2
Désolé de vous répondre si tard, mais je suis en vacances depuis quelques jours. Le problème que j'ai eu lorsque j'ai essayé votre réponse était que la méthode des jointures provoquait la jointure des deux tables, plutôt que deux requêtes distinctes qui pouvaient ensuite être comparées. Cependant, votre idée était solide et m'a donné une autre idée. Merci pour l'aide.
LandonSchropp
sélectionner en utilisant OR est plus lent que UNION, se demandant une solution pour UNION à la place
Nich
5

On peut soutenir que cela améliore la lisibilité, mais pas nécessairement les performances:

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

Cette méthode retourne un ActiveRecord :: Relation, vous pouvez donc l'appeler comme ceci:

my_posts.order("watched_item_type, post.id DESC")
Richardsun
la source
d'où obtenez-vous posts.id?
berto77
Il existe deux paramètres self.id car self.id est référencé deux fois dans le SQL - voir les deux points d'interrogation.
richardsun
C'était un exemple utile de la façon de faire une requête UNION et de récupérer un ActiveRecord :: Relation. Merci.
Fitter Man
avez-vous un outil pour générer ces types de requêtes SDL - comment l'avez-vous fait sans fautes d'orthographe, etc.?
BKSpurgeon
2

Il existe une gemme active_record_union. Cela pourrait être utile

https://github.com/brianhempel/active_record_union

Avec ActiveRecordUnion, nous pouvons faire:

les articles (brouillon) de l'utilisateur actuel et tous les articles publiés de n'importe current_user.posts.union(Post.published) qui, ce qui équivaut au SQL suivant:

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
la source
1

Je voudrais simplement exécuter les deux requêtes dont vous avez besoin et combiner les tableaux d'enregistrements renvoyés:

@posts = watched_news_posts + watched_topics_posts

Ou, au moins, testez-le. Pensez-vous que la combinaison de tableaux en rubis sera beaucoup trop lente? En regardant les requêtes suggérées pour contourner le problème, je ne suis pas convaincu qu'il y aura une telle différence de performances.

Jeffrey Alan Lee
la source
En fait, faire @ posts = watch_news_posts & watch_topics_posts pourrait être mieux car c'est une intersection et évitera les dupes.
Jeffrey Alan Lee
1
J'avais l'impression qu'ActiveRelation charge ses dossiers paresseusement. Ne perdriez-vous pas cela si vous croisiez les tableaux dans Ruby?
LandonSchropp
Apparemment, une union qui renvoie une relation est sous dev in rails, mais je ne sais pas dans quelle version elle sera.
Jeffrey Alan Lee
1
ce tableau de retour à la place, ses deux résultats de requête différents fusionnent.
alexzg
1

Dans un cas similaire, j'ai additionné deux tableaux et utilisé Kaminari:paginate_array(). Solution très agréable et fonctionnelle. Je n'ai pas pu utiliser where(), car je dois additionner deux résultats différents order()sur la même table.

Sekrett
la source
1

Moins de problèmes et plus facile à suivre:

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

Donc à la fin:

union_scope(watched_news_posts, watched_topic_posts)
Dmitry Polushkin
la source
1
Je l'ai légèrement changé en: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }Thx!
eikes le
0

Elliot Nelson a bien répondu, sauf le cas où certaines relations sont vides. Je ferais quelque chose comme ça:

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)

fin

Ehud
la source
0

Voici comment j'ai rejoint des requêtes SQL en utilisant UNION sur ma propre application ruby ​​on rails.

Vous pouvez utiliser ce qui suit comme inspiration sur votre propre code.

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

Ci-dessous est le UNION où j'ai joint les portées ensemble.

  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
la source