Союз запитів ActiveRecord


90

Я написав кілька складних запитів (принаймні для мене) за допомогою інтерфейсу запитів 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
Дякую за пропозицію. Згідно з документами, "Обсяг представляє звуження запиту до бази даних". У моєму випадку я не шукаю публікацій, які містяться як у поглядах_нових_постів, так і у поглядах_тег_постів. Швидше, я шукаю публікації, які містяться у поглядах_нових_постів або поглядах_теп_постів, без дублікатів. Чи можливо це ще зробити за допомогою сфери застосування?
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не можуть бути використані з іншими запрошуваними запитами, а це означає, що мені тепер доведеться також переписати свої фільтри та запити. Чому ActiveRecord не підтримує unionоперацію?
LandonSchropp

Відповіді:


93

Ось короткий маленький модуль, який я написав, що дозволяє об’єднати кілька областей. Він також повертає результати як екземпляр 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, але я зіткнувся з проблемою, яку я вирішив
Лоуренс І. Сіден

7
Швидке попередження: цей метод є дуже проблематичним з точки зору продуктивності з MySQL, оскільки підзапит буде зараховуватися як залежний і виконуватися для кожного запису в таблиці (див. Percona.com/blog/2010/10/25/mysql-limitations-part -3-підзапити ).
шості

71

Я також зіткнувся з цією проблемою, і зараз моєю стратегією є створення 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

Дуже корисна відповідь. Майбутнім читачам запам’ятайте останню частину «Як коментарі», оскільки activerecord конструює запит як «ВИБЕРІТЬ» коментарі ».« * «ВІД« ... якщо ви не вкажете ім'я унізованого набору АБО вкажете інше ім'я, наприклад "AS foo", остаточне виконання sql не вдасться.
HeyZiko,

1
Це було саме те, що я шукав. Я розширив ActiveRecord :: Relation для підтримки #orу своєму проекті Rails 4. Припускаючи ту саму модель:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt

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?
Марк

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якийсь день.


8

Як на рахунок...

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

15
Зауважте, що це буде виконувати три запити, а не один, оскільки кожен pluckвиклик сам по собі є запитом.
JacobEvelyn

3
Це дійсно хороше рішення, оскільки воно не повертає масив, тож ви можете використовувати .orderабо .paginateметоди ... Він зберігає класи
orm

Корисно, якщо сфери застосування однієї моделі, але це призведе до двох запитів через зриви.
jmjm

6

Чи не могли б ви використати АБО замість СОЮЗУ?

Тоді ви можете зробити щось на зразок:

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

(Оскільки ви приєднуєтеся до переглянутої таблиці двічі, я не надто впевнений, якими будуть назви таблиць для запиту)

Оскільки об’єднань багато, це може також бути досить важким для бази даних, але його можна буде оптимізувати.


2
Вибачте, що зв’язався з вами так пізно, але я був у відпустці останні пару днів. Проблема, з якою я зіткнувся, коли спробував отримати відповідь, полягала в тому, що метод об’єднання спричинив об’єднання обох таблиць, а не двох окремих запитів, які потім можна було порівняти. Однак ваша ідея була обґрунтованою і все-таки дала мені ще одну ідею. Дякую за допомогу.
LandonSchropp

вибрати, використовуючи АБО, повільніше, ніж UNION, цікавлячись будь-яким рішенням для UNION
Ніч

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 = спостерігали_нові_пості та спостерігали_теми_постів могли б бути краще, оскільки це перехрестя і уникне обману.
Джеффрі Алан Лі,

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.