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
joeyk16
источник