ActiveRecord Query Union


91

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


Вы должны уметь использовать область видимости . Создайте 2 области видимости и затем назовите их как Post.watched_news_posts.watched_topic_posts. Возможно, вам потребуется отправить параметры в области для таких вещей, как :user_idи :topic.
— Zabba

6
Спасибо за предложение. Согласно документам, «область действия представляет собой сужение запроса к базе данных». В моем случае я не ищу сообщения, которые есть как в watched_news_posts, так и в watched_topic_posts. Скорее я ищу сообщения, которые находятся в watched_news_posts или watched_topic_posts, без дублирования. Возможно ли это еще сделать с помощью прицелов?
— LandonSchropp

1
На самом деле это невозможно из коробки. В github есть плагин под названием union, но он использует синтаксис старой школы (метод класса и параметры запроса в стиле хеша), если вам это нравится, я бы сказал, идите с ним ... в противном случае напишите его долгим путем в find_by_sql в вашей области.
— jenjenut233

1
Я согласен с jenjenut233, и я думаю, вы могли бы сделать что-то подобное find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Я не проверял это, поэтому дайте мне знать, как это происходит, если вы попробуете. Кроме того, вероятно, есть некоторые функции ARel, которые будут работать.
— Wizard of Ogz

2
Я переписал запросы как SQL-запросы. Сейчас они работают, но, к сожалению, find_by_sqlне могут использоваться с другими цепочками запросов, а это означает, что теперь мне также нужно переписать мои фильтры и запросы will_paginate. Почему ActiveRecord не поддерживает unionоперацию?
— LandonSchropp

Ответы:


97

Вот небольшой модуль, который я написал, который позволяет объединить несколько областей. Он также возвращает результаты как экземпляр 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

2
Это действительно более полный ответ на другие, перечисленные выше. Работает отлично!
— ghayes

Пример использования был бы неплохим.
— ciembor

Как и просили, я добавил пример.
— Тим Лоуримор

3
Решение «почти» правильное, и я поставил ему +1, но я столкнулся с проблемой, которую исправил здесь: gist.github.com/lsiden/260167a4d3574a580d97
— Лоуренс И. Сиден

7
Краткое предупреждение: этот метод очень проблематичен с точки зрения производительности MySQL, поскольку подзапрос будет считаться зависимым и выполняться для каждой записи в таблице (см. Percona.com/blog/2010/10/25/mysql-limitations-part -3-подзапросы ).
— shosti

72

Я также столкнулся с этой проблемой, и теперь моя стратегия состоит в том, чтобы сгенерировать 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")

предположим, если у нас есть две разные модели, дайте мне знать, какой будет запрос для unoin.
— Chitra

Очень полезный ответ. Для будущих читателей запомните последнюю часть «Комментарии AS», потому что activerecord строит запрос как 'SELECT «comments». «*« FROM »... если вы не укажете имя объединенного набора ИЛИ укажите другое имя, например «AS foo», окончательное выполнение sql завершится ошибкой
— HeyZiko

1
Это было именно то, что я искал. Я расширил ActiveRecord :: Relation для поддержки #orв моем проекте Rails 4. Предполагая ту же модель:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
— М. Вятт

11

Основываясь на ответе Олив, я придумал другое решение этой проблемы. Это немного похоже на взлом, но он возвращает экземпляр 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)

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


Как я могу сделать то же самое с этим: gist.github.com/2241307 Чтобы он создавал класс AR :: Relation, а не класс Array?
— Marc

10

Вы можете также использовать Brian Hempel «s active_record_union драгоценный камень , который проходит 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.


7

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

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

15
Имейте в виду, что это будет выполнять три запроса, а не один, поскольку каждый pluckвызов сам по себе является запросом.
— JacobEvelyn

3
Это действительно хорошее решение, поскольку оно не возвращает массив, поэтому вы можете использовать методы .orderor .paginate... Оно сохраняет классы
— orm

Полезно, если области имеют одну и ту же модель, но это приведет к созданию двух запросов из-за рывков.
— jmjm

6

Могли бы вы использовать OR вместо UNION?

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

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

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

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


2
Извини, что вернулся к тебе так поздно, но я был в отпуске последние пару дней. Проблема, с которой я столкнулся, когда я попробовал ваш ответ, заключалась в том, что метод соединения вызывал объединение обеих таблиц, а не два отдельных запроса, которые затем можно было сравнить. Однако ваша идея была здравой и подала мне другую идею. Спасибо за помощь.
— LandonSchropp

выбор с использованием OR медленнее, чем UNION, вместо этого интересно какое-либо решение для UNION
— Nich

5

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

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

откуда вы получаете posts.id?
— berto77

Есть два параметра self.id, потому что на self.id дважды ссылаются в SQL - см. Два вопросительных знака.
— richardsun

Это был полезный пример того, как выполнить запрос UNION и получить обратно ActiveRecord :: Relation. Благодарю.
— Слесарь-

у вас есть инструмент для генерации таких типов запросов SDL - как вы это сделали без орфографических ошибок и т. д.?
— BKSpurgeon

2

Есть гем 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

1

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

@posts = watched_news_posts + watched_topics_posts

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


На самом деле, использование @ posts = watched_news_posts & watched_topics_posts может быть лучше, поскольку это перекресток и позволит избежать обманов.
— Джеффри Алан Ли,

1
У меня создалось впечатление, что ActiveRelation лениво загружает свои записи. Разве вы не потеряете это, если пересечете массивы в Ruby?
— LandonSchropp

Очевидно, объединение, которое возвращает отношение, находится в стадии разработки, но я не знаю, в какой версии он будет.
— Джеффри Алан Ли,

1
вместо этого возвращаемого массива два разных результата запроса объединяются.
— alexzg

1

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


1

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

    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)

1
Я немного изменил его на: scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }Thx!
— Eikes

0

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

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)

конец


0

Вот как я объединил 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
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.