web-dev-qa-db-fra.com

Union de requêtes ActiveRecord

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 toutes seules. Les deux renvoient des objets Post. Je voudrais combiner ces articles en un seul ActiveRelation. Comme 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'opérateur UNION. Est-ce que quelqu'un sait si je peux faire quelque chose de similaire avec l'interface de requête de RoR?

77
LandonSchropp

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

Voici le Gist: https://Gist.github.com/tlowrimore/5162327

Modifier:

Comme demandé, voici un exemple de la manière dont UnionScope fonctionne:

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
79
Tim Lowrimore

J'ai également rencontré ce problème et ma stratégie consiste maintenant à générer du code SQL (manuellement ou à l'aide de to_sql sur une portée existante), puis à le coller dans la clause from. Je ne peux pas garantir que ce soit plus efficace que votre méthode acceptée, mais il est relativement facile à regarder et vous rend 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" dans UNION. Vous pouvez utiliser select pour les deux requêtes afin de vous assurer qu'elles 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")
51
Elliot Nelson

Basé sur la réponse d'Olives, j'ai trouvé une autre solution à ce problème. Cela ressemble un peu à un bidouillage, mais il retourne une instance de ActiveRelation, ce que je recherchais au départ.

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 trois requêtes et se sent un peu redondant. 

11
LandonSchropp

Que diriez-vous...

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

Vous pouvez également utiliser Brian Hempel / s active_record_union gem qui étend ActiveRecord avec une méthode union 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 finira par être fusionné avec ActiveRecord un jour.

7
dgilperez

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

Ensuite, vous pourriez 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 suis pas trop sûr du nom des tables pour la requête)

Comme il y a beaucoup de jointures, la base de données peut également être lourde, mais elle peut être optimisée.

5
Olives

On peut dire 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")
5
richardsun

Il y a une gem active_record_union . Peut-être utile

https://github.com/brianhempel/active_record_union

Avec ActiveRecordUnion, nous pouvons faire:

les messages (brouillon) de l'utilisateur actuel et tous les messages publiés de qui que ce soit current_user.posts.union (Post.published) 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

2
Mike Lyubarskyy

Je voudrais juste exécuter les deux requêtes dont vous avez besoin et combiner les tableaux d'enregistrements qui sont retournés:

@posts = watched_news_posts + watched_topics_posts

Ou, au moins, testez-le. Pensez-vous que la combinaison de tableaux en Ruby sera beaucoup trop lente? En examinant les questions suggérées pour résoudre le problème, je ne suis pas convaincu qu'il y aura une différence de performance aussi significative.

1
Jeffrey Alan Lee

Dans un cas similaire, j'ai résumé deux tableaux et utilisé Kaminari:paginate_array(). Très belle et solution de travail. Je n'ai pas pu utiliser where(), car je dois additionner deux résultats avec order() différent sur la même table.

1
sekrett

Elliot Nelson a bien répondu, sauf dans 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
Ehud

Voici comment j'ai joint des requêtes SQL en utilisant UNION sur ma propre application Ruby sur Rails.

Vous pouvez utiliser le texte ci-dessous pour vous inspirer de votre propre code.

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

Ci-dessous l’UNION où j’ai joint les champs d’application.

  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
0
joeyk16