Unión de consultas ActiveRecord

90

He escrito un par de consultas complejas (al menos para mí) con la interfaz de consulta 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})

Ambas consultas funcionan bien por sí mismas. Ambos devuelven objetos Post. Me gustaría combinar estas publicaciones en una sola ActiveRelation. Dado que podría haber cientos de miles de publicaciones en algún momento, esto debe hacerse a nivel de la base de datos. Si fuera una consulta MySQL, simplemente podría usar el UNIONoperador. ¿Alguien sabe si puedo hacer algo similar con la interfaz de consulta de RoR?

LandonSchropp
fuente
Debería poder usar el alcance . Cree 2 ámbitos y luego llámelos a ambos como Post.watched_news_posts.watched_topic_posts. Es posible que deba enviar parámetros a los ámbitos para cosas como :user_idy :topic.
Zabba
6
Gracias por la sugerencia. Según los documentos, "un alcance representa una reducción de una consulta de base de datos". En mi caso, no busco publicaciones que estén tanto en las publicaciones de noticias_vistos como en las publicaciones de temas_visados. Más bien, estoy buscando publicaciones que estén en watch_news_posts o watch_topic_posts, sin que se permitan duplicados. ¿Todavía es posible lograr esto con los ámbitos?
LandonSchropp
1
Realmente no es posible de inmediato. Hay un complemento en github llamado union, pero usa la sintaxis de la vieja escuela (método de clase y parámetros de consulta de estilo hash), si le parece bien, le diría que lo haga ... de lo contrario, escríbalo en un find_by_sql en su alcance.
jenjenut233
1
Estoy de acuerdo con jenjenut233, y creo que podrías hacer algo como find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). No lo he probado, así que déjame saber cómo te va si lo intentas. Además, probablemente haya alguna funcionalidad ARel que funcione.
Mago de Ogz
2
Bueno, reescribí las consultas como consultas SQL. Funcionan ahora, pero desafortunadamente find_by_sqlno se pueden usar con otras consultas encadenables, lo que significa que ahora tengo que reescribir mis filtros y consultas will_paginate también. ¿Por qué ActiveRecord no admite una unionoperación?
LandonSchropp

Respuestas:

93

Aquí hay un pequeño módulo rápido que escribí que le permite UNIONAR múltiples ámbitos. También devuelve los resultados como una instancia 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

Aquí está la esencia: https://gist.github.com/tlowrimore/5162327

Editar:

Según lo solicitado, aquí hay un ejemplo de cómo funciona 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
fuente
2
Esta es realmente una respuesta mucho más completa que las otras enumeradas anteriormente. ¡Funciona genial!
ghayes
Un ejemplo de uso sería bueno.
ciembor
Según lo solicitado, agregué un ejemplo.
Tim Lowrimore
3
La solución es "casi" correcta y le di un +1, pero encontré un problema que solucioné aquí: gist.github.com/lsiden/260167a4d3574a580d97
Lawrence I. Siden
7
Advertencia rápida: este método es muy problemático desde la perspectiva del rendimiento con MySQL, ya que la subconsulta se contará como dependiente y se ejecutará para cada registro de la tabla (consulte percona.com/blog/2010/10/25/mysql-limitations-part -3-subconsultas ).
shosti
71

También me he encontrado con este problema, y ​​ahora mi estrategia de referencia es generar SQL (a mano o con to_sqlun alcance existente) y luego pegarlo en la fromcláusula. No puedo garantizar que sea más eficiente que su método aceptado, pero es relativamente agradable a la vista y le devuelve un objeto 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")

También puede hacer esto con dos modelos diferentes, pero debe asegurarse de que ambos "se vean iguales" dentro de UNION; puede usar selectambas consultas para asegurarse de que producirán las mismas columnas.

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
fuente
Supongamos que si tenemos dos modelos diferentes, hágamelo saber cuál será la consulta para unoin.
Chitra
Respuesta muy útil. Para los lectores futuros, recuerde la parte final "AS comentarios" porque activerecord construye la consulta como 'SELECT "comentarios". "*" FROM "... si no especifica el nombre del conjunto unido O especifica un nombre diferente como "AS foo", la ejecución final de sql fallará.
HeyZiko
1
Esto era exactamente lo que estaba buscando. Extendí ActiveRecord :: Relation para que sea compatible #orcon mi proyecto Rails 4. Suponiendo el mismo modelo:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt
11

Según la respuesta de Olives, se me ocurrió otra solución a este problema. Se siente un poco como un truco, pero devuelve una instancia de ActiveRelation, que es lo que buscaba en primer lugar.

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)

Todavía agradecería que alguien tenga alguna sugerencia para optimizar esto o mejorar el rendimiento, porque básicamente se trata de ejecutar tres consultas y se siente un poco redundante.

LandonSchropp
fuente
¿Cómo podría hacer lo mismo con esto: gist.github.com/2241307 para que cree una clase AR :: Relation en lugar de una clase Array?
Marc
10

También puede usar la gema active_record_union de Brian Hempel que se extiende con un método para ámbitos.ActiveRecordunion

Tu consulta sería así:

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

Con suerte, esto finalmente se fusionará en ActiveRecordalgún día.

dgilperez
fuente
8

Qué tal si...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end
Richard Wan
fuente
15
Tenga en cuenta que esto realizará tres consultas en lugar de una, ya que cada pluckllamada es una consulta en sí misma.
JacobEvelyn
3
Esta es una solución realmente buena, porque no devuelve una matriz, por lo que puede usar los métodos .ordero .paginate... Mantiene las clases orm
mariowise
Útil si los alcances son del mismo modelo, pero esto generaría dos consultas debido a los plucks.
jmjm
6

¿Podrías usar un quirófano en lugar de un UNION?

Entonces podrías hacer algo como:

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

(Dado que se une a la tabla observada dos veces, no estoy muy seguro de cuáles serán los nombres de las tablas para la consulta)

Dado que hay muchas uniones, también puede ser bastante pesado en la base de datos, pero es posible que se pueda optimizar.

Olivos
fuente
2
Lamento comunicarme tan tarde, pero he estado de vacaciones los últimos días. El problema que tuve cuando probé su respuesta fue que el método joins estaba causando que ambas tablas se unieran, en lugar de dos consultas separadas que luego podrían compararse. Sin embargo, tu idea era sólida y me dio otra idea. Gracias por la ayuda.
LandonSchropp
seleccionar el uso de OR es más lento que UNION, preguntándose alguna solución para UNION en su lugar
Nich
5

Podría decirse que esto mejora la legibilidad, pero no necesariamente el rendimiento:

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

Este método devuelve un ActiveRecord :: Relation, por lo que podría llamarlo así:

my_posts.order("watched_item_type, post.id DESC")
richardsun
fuente
¿De dónde sacas posts.id?
berto77
Hay dos parámetros self.id porque se hace referencia a self.id dos veces en el SQL; consulte los dos signos de interrogación.
richardsun
Este fue un ejemplo útil de cómo hacer una consulta UNION y recuperar un ActiveRecord :: Relation. Gracias.
Fitter Man
¿Tiene una herramienta para generar este tipo de consultas SDL? ¿Cómo lo hizo sin errores ortográficos, etc.?
BKSpurgeon
2

Hay una gema active_record_union. Podría ser útil

https://github.com/brianhempel/active_record_union

Con ActiveRecordUnion, podemos hacer:

las publicaciones del usuario actual (borrador) y todas las publicaciones publicadas de cualquier persona current_user.posts.union(Post.published) que sea equivalente al siguiente 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
Mike Lyubarskyy
fuente
1

Simplemente ejecutaría las dos consultas que necesita y combinaría las matrices de registros que se devuelven:

@posts = watched_news_posts + watched_topics_posts

O, al menos, pruébalo. ¿Crees que la combinación de matrices en ruby ​​será demasiado lenta? Al analizar las consultas sugeridas para solucionar el problema, no estoy convencido de que haya una diferencia de rendimiento tan significativa.

Jeffrey Alan Lee
fuente
En realidad, hacer @ posts =watch_news_posts y watch_topics_posts podría ser mejor ya que es una intersección y evitará engaños.
Jeffrey Alan Lee
1
Tenía la impresión de que ActiveRelation carga sus registros con pereza. ¿No perderías eso si cruzaras las matrices en Ruby?
LandonSchropp
Aparentemente, una unión que devuelve una relación está bajo desarrollo en rieles, pero no sé en qué versión estará.
Jeffrey Alan Lee
1
esta matriz de retorno en su lugar, sus dos resultados de consulta diferentes se fusionan.
alexzg
1

En un caso similar, sumé dos matrices y usé Kaminari:paginate_array(). Solución muy bonita y funcional. No pude usar where(), porque necesito sumar dos resultados con diferentes order()en la misma tabla.

sekrett
fuente
1

Menos problemas y más fácil de seguir:

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

Así que al final:

union_scope(watched_news_posts, watched_topic_posts)
Dmitry Polushkin
fuente
1
Lo cambié ligeramente a: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }¡Gracias!
eikes
0

Elliot Nelson respondió bien, excepto en el caso de que algunas de las relaciones estén vacías. Yo haría algo como eso:

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

Así es como me uní a consultas SQL usando UNION en mi propia aplicación ruby ​​on rails.

Puede utilizar lo siguiente como inspiración en su propio código.

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

A continuación se muestra la UNIÓN donde uní los ámbitos.

  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
fuente