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?


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

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

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.


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.


8

Que diriez-vous...

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

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é.


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

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

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.


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.


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)

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


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
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.