Объединение запросов ActiveRecord

Я написал пару сложных запросов (по крайней мере, для меня) с интерфейсом запросов 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})

Оба этих запроса прекрасно работают сами по себе. Оба возвращают объекты Post. Я хотел бы объединить эти сообщения в один ActiveRelation. Поскольку в какой-то момент могут быть сотни тысяч сообщений, это необходимо сделать на уровне базы данных. Если бы это был запрос MySQL, я мог бы просто использовать оператор UNION. Кто-нибудь знает, могу ли я сделать что-то подобное с интерфейсом запросов RoR?


person LandonSchropp    schedule 13.07.2011    source источник
comment
Вы должны иметь возможность использовать область. Создайте 2 области, а затем назовите их как Post.watched_news_posts.watched_topic_posts. Возможно, вам потребуется отправить параметры в области действия для таких вещей, как :user_id и :topic.   -  person Zabba    schedule 14.07.2011
comment
Спасибо за предложение. Согласно документам, область действия представляет собой сужение запроса к базе данных. В моем случае я не ищу посты, которые есть и в watch_news_posts, и в watch_topic_posts. Скорее, я ищу сообщения, которые находятся в наблюдаемых_новостных_сообщениях или наблюдаемых_тематических_сообщениях, без разрешенных дубликатов. Это все еще возможно выполнить с областями?   -  person LandonSchropp    schedule 14.07.2011
comment
На самом деле не возможно из коробки. На github есть плагин под названием union, но он использует синтаксис старой школы (метод класса и параметры запроса в стиле хэша), если вам это нравится, я бы сказал, продолжайте с ним... в противном случае напишите его длинным путем в find_by_sql в вашей области.   -  person jenjenut233    schedule 14.07.2011
comment
Я согласен с jenjenut233, и я думаю, что вы могли бы сделать что-то вроде find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Я не проверял это, так что дайте мне знать, как это работает, если вы попробуете. Кроме того, вероятно, есть некоторые функции ARel, которые будут работать.   -  person Wizard of Ogz    schedule 14.07.2011
comment
Ну, я переписал запросы как SQL-запросы. Теперь они работают, но, к сожалению, find_by_sql нельзя использовать с другими цепными запросами, что означает, что теперь мне также нужно переписать мои фильтры и запросы will_paginate. Почему ActiveRecord не поддерживает операцию union?   -  person LandonSchropp    schedule 15.07.2011
comment
Вы должны быть в состоянии выполнить это с помощью левого внешнего соединения   -  person mc.    schedule 15.09.2015


Ответы (13)


Вот небольшой модуль, который я написал, который позволяет вам ОБЪЕДИНЯТЬ несколько областей. Он также возвращает результаты как экземпляр 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

Вот суть: https://gist.github.com/tlowrimore/5162327

Редактировать:

В соответствии с просьбой, вот пример того, как работает 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
person Tim Lowrimore    schedule 14.03.2013
comment
Это действительно более полный ответ, чем другие, перечисленные выше. Работает отлично! - person ghayes; 15.08.2013
comment
Пример использования было бы неплохо. - person ciembor; 18.02.2014
comment
Как и просили, я добавил пример. - person Tim Lowrimore; 18.03.2014
comment
Я получаю исключение с SQLite: разрешен только один результат для SELECT, который является частью выражения. - person Sarah Vessels; 11.04.2014
comment
@SarahVessels Эта ошибка, по-видимому, означает, что вложенные операторы select запрашивают более одного столбца; однако определение union_scope явно выбирает столбец id и ничего больше. Мне было бы любопытно посмотреть, что получится в результате SQL для вашего сценария. Пробовали ли вы вызывать to_sql в вашей области видимости, чтобы посмотреть, как выглядит сгенерированный SQL? - person Tim Lowrimore; 14.04.2014
comment
Решение почти правильное, и я поставил ему +1, но столкнулся с проблемой, которую исправил здесь: gist.github.com/lsiden/260167a4d3574a580d97 - person Lawrence I. Siden; 21.07.2014
comment
Предупреждение: этот метод очень проблематичен с точки зрения производительности MySQL, поскольку подзапрос будет считаться зависимым и выполняться для каждой записи в таблице (см. percona.com/blog/2010/10/25/mysql-limitations-part-3-subqueries) . - person shosti; 17.09.2014

Я также столкнулся с этой проблемой, и теперь моя стратегия перехода состоит в том, чтобы сгенерировать SQL (вручную или с использованием to_sql в существующей области), а затем вставить его в предложение from. Я не могу гарантировать, что он более эффективен, чем принятый вами метод, но он относительно удобен для глаз и возвращает вам обычный объект ARel.

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

Вы также можете сделать это с двумя разными моделями, но вам нужно убедиться, что они обе "выглядят одинаково" внутри UNION - вы можете использовать select в обоих запросах, чтобы убедиться, что они будут создавать одни и те же столбцы.

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")
person Elliot Nelson    schedule 01.06.2013
comment
предположим, если у нас есть две разные модели, пожалуйста, дайте мне знать, какой будет запрос для unoin. - person Chitra; 14.01.2016
comment
Очень полезный ответ. Для будущих читателей запомните заключительную часть комментариев AS, потому что activerecord строит запрос как 'SELECT comments.* FROM... если вы не укажете имя объединенного набора ИЛИ укажете другое имя, например AS foo, окончательное выполнение sql не удастся. - person HeyZiko; 26.10.2016
comment
Это было именно то, что я искал. Я расширил ActiveRecord::Relation для поддержки #or в моем проекте Rails 4. Предполагая ту же модель: klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}") - person M. Wyatt; 28.09.2018

Основываясь на ответе Оливки, я придумал другое решение этой проблемы. Это немного похоже на взлом, но он возвращает экземпляр ActiveRelation, что мне и было нужно в первую очередь.

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)

Я все равно был бы признателен, если бы у кого-нибудь были какие-либо предложения по оптимизации или повышению производительности, потому что он по существу выполняет три запроса и кажется немного избыточным.

person LandonSchropp    schedule 18.07.2011
comment
Как я мог сделать то же самое с этим: gist.github.com/2241307, чтобы он создавал Класс AR::Relation, а не класс Array? - person Marc; 29.03.2012

Вы также можете использовать Brian Hempel active_record_union gem, расширяющий ActiveRecord методом union для областей.

Ваш запрос будет таким:

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

Надеюсь, когда-нибудь это будет объединено в ActiveRecord.

person dgilperez    schedule 01.03.2015

Как насчет...

def union(scope1, scope2)
  ids = scope1.pluck(:id) + scope2.pluck(:id)
  where(id: ids.uniq)
end
person Richard Wan    schedule 12.03.2014
comment
Имейте в виду, что это будет выполнять три запроса, а не один, поскольку каждый вызов pluck сам по себе является запросом. - person JacobEvelyn; 25.04.2014
comment
Это действительно хорошее решение, потому что оно не возвращает массив, поэтому вы можете использовать методы .order или .paginate... Он сохраняет классы orm - person mariowise; 11.04.2015
comment
Полезно, если области имеют одну и ту же модель, но это приведет к созданию двух запросов из-за извлечения. - person jmjm; 10.03.2016
comment
Точно так же это отлично сработало для меня, намного проще, чем другие подходы, и я могу связать больше областей! - person Eric Pugh; 16.03.2021

Не могли бы вы использовать ИЛИ вместо UNION?

Тогда вы можете сделать что-то вроде:

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

(Поскольку вы дважды присоединяетесь к отслеживаемой таблице, я не слишком уверен, какие имена таблиц будут для запроса)

Поскольку существует много соединений, это также может быть довольно тяжелым для базы данных, но его можно оптимизировать.

person Kyle d'Oliveira    schedule 15.07.2011
comment
Извините, что так поздно возвращаюсь к вам, но последние пару дней я был в отпуске. Проблема, с которой я столкнулся, когда я попробовал ваш ответ, заключалась в том, что метод соединений вызывал объединение обеих таблиц, а не два отдельных запроса, которые затем можно было сравнить. Тем не менее, ваша идея была здравой и натолкнула меня на другую идею. Спасибо за помощь. - person LandonSchropp; 19.07.2011
comment
выберите с помощью OR медленнее, чем UNION, вместо этого задайтесь вопросом, есть ли какое-либо решение для UNION - person Nich; 21.01.2016

Возможно, это улучшает читаемость, но не обязательно производительность:

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

Этот метод возвращает ActiveRecord::Relation, поэтому вы можете вызвать его следующим образом:

my_posts.order("watched_item_type, post.id DESC")
person richardsun    schedule 10.08.2011
comment
откуда вы берете posts.id? - person berto77; 29.06.2012
comment
Имеется два параметра self.id, потому что self.id дважды упоминается в SQL — см. два вопросительных знака. - person richardsun; 29.06.2012
comment
Это был полезный пример того, как выполнить запрос UNION и получить обратно ActiveRecord::Relation. Спасибо. - person Fitter Man; 22.04.2014
comment
есть ли у вас инструмент для генерации SDL-запросов такого типа - как вы это сделали без орфографических ошибок и т. д.? - person BKSpurgeon; 06.02.2020

Есть гем active_record_union. Может быть полезно

https://github.com/brianhempel/active_record_union

С ActiveRecordUnion мы можем:

сообщения текущего пользователя (черновик) и все опубликованные сообщения от кого-либо current_user.posts.union(Post.published) Что эквивалентно следующему 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
person Mike Lyubarskyy    schedule 28.01.2016

В аналогичном случае я суммировал два массива и использовал Kaminari:paginate_array(). Очень красивое и рабочее решение. Я не смог использовать where(), потому что мне нужно суммировать два результата с разными order() в одной таблице.

person sekrett    schedule 11.01.2013

Вот как я присоединялся к SQL-запросам с помощью UNION в своем собственном приложении ruby ​​on rails.

Вы можете использовать приведенное ниже в качестве вдохновения для своего собственного кода.

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

Ниже находится СОЮЗ, где я соединил прицелы вместе.

  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
person joeyk16    schedule 29.05.2019

Меньше проблем и легче следовать:

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

Итак, в конце:

union_scope(watched_news_posts, watched_topic_posts)
person Dmitry Polushkin    schedule 17.12.2019
comment
Я немного изменил его на: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) } Спасибо! - person eikes; 12.02.2020

Я бы просто запустил два необходимых вам запроса и объединил массивы возвращаемых записей:

@posts = watched_news_posts + watched_topics_posts

Или хотя бы протестировать. Как вы думаете, комбинация массивов в ruby ​​будет слишком медленной? Глядя на предлагаемые запросы, чтобы обойти проблему, я не уверен, что будет такая значительная разница в производительности.

person Jeffrey Alan Lee    schedule 09.08.2012
comment
На самом деле, использование @posts=watched_news_posts и watch_topics_posts может быть лучше, так как это пересечение и позволит избежать обмана. - person Jeffrey Alan Lee; 09.08.2012
comment
У меня сложилось впечатление, что ActiveRelation лениво загружает свои записи. Разве вы не потеряли бы это, если бы вы пересекали массивы в Ruby? - person LandonSchropp; 10.08.2012
comment
По-видимому, объединение, которое возвращает отношение, находится в стадии разработки в рельсах, но я не знаю, в какой версии оно будет. - person Jeffrey Alan Lee; 11.08.2012
comment
этот возвращаемый массив вместо этого объединяет два разных результата запроса. - person alexzg; 28.02.2014

Эллиот Нельсон ответил хорошо, за исключением случая, когда некоторые отношения пусты. Я бы сделал что-то вроде этого:

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)

конец

person Ehud    schedule 21.02.2018