Unia zapytań ActiveRecord


90

Napisałem kilka złożonych zapytań (przynajmniej do mnie) za pomocą interfejsu zapytań 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})

Oba te zapytania działają dobrze samodzielnie. Oba zwracają obiekty Post. Chciałbym połączyć te posty w jedną ActiveRelation. Ponieważ w pewnym momencie mogą pojawić się setki tysięcy postów, należy to zrobić na poziomie bazy danych. Gdyby to było zapytanie MySQL, mógłbym po prostu użyć UNIONoperatora. Czy ktoś wie, czy mogę zrobić coś podobnego z interfejsem zapytań RoR?


Powinieneś móc używać zakresu . Utwórz 2 zakresy, a następnie nazwij je jak Post.watched_news_posts.watched_topic_posts. Może być konieczne wysłanie parametrów do zakresów dla rzeczy takich jak :user_idi :topic.
Zabba,

6
Dzieki za sugestie. Zgodnie z dokumentacją „Zakres reprezentuje zawężenie zapytania do bazy danych”. W moim przypadku nie szukam postów, które znajdują się zarówno w watched_news_posts, jak i watched_topic_posts. Raczej szukam postów, które są w watched_news_posts lub watched_topic_posts, bez dozwolonych duplikatów. Czy nadal można to osiągnąć za pomocą lunet?
LandonSchropp,

1
Nie jest to możliwe po wyjęciu z pudełka. Na githubie jest wtyczka o nazwie union, ale używa ona starej składni (metoda klasowa i parametry zapytania w stylu hash), jeśli nie masz nic przeciwko, powiedziałbym, że idź z nią ... w przeciwnym razie napisz to długą drogę w find_by_sql w Twoim zakresie.
jenjenut233

1
Zgadzam się z jenjenut233 i myślę, że możesz zrobić coś takiego find_by_sql("#{watched_news_posts.to_sql} UNION #{watched_topic_posts.to_sql}"). Nie testowałem tego, więc daj mi znać, jeśli spróbujesz. Prawdopodobnie jest też funkcjonalność ARel, która by działała.
Czarnoksiężnik Ogz

2
Cóż, przepisałem zapytania jako zapytania SQL. Teraz działają, ale niestety find_by_sqlnie można ich używać z innymi zapytaniami, które można łączyć w łańcuch, co oznacza, że ​​muszę teraz przepisać moje filtry i zapytania will_paginate. Dlaczego ActiveRecord nie obsługuje unionoperacji?
LandonSchropp,

Odpowiedzi:


93

Oto krótki mały moduł, który napisałem, który umożliwia UNION wiele zakresów. Zwraca również wyniki jako instancję 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

Oto sedno: https://gist.github.com/tlowrimore/5162327

Edytować:

Zgodnie z prośbą, oto przykład działania 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
To naprawdę bardziej kompletna odpowiedź na inne wymienione powyżej. Działa świetnie!
Ghayes

Przykład użycia byłby fajny.
ciembor

Zgodnie z żądaniem dodałem przykład.
Tim Lowrimore

3
Rozwiązanie jest „prawie” poprawne i dałem mu +1, ale napotkałem problem, który naprawiłem tutaj: gist.github.com/lsiden/260167a4d3574a580d97
Lawrence I. Siden

7
Szybkie ostrzeżenie: ta metoda jest wysoce problematyczna z punktu widzenia wydajności w przypadku MySQL, ponieważ podzapytanie będzie liczone jako zależne i wykonywane dla każdego rekordu w tabeli (patrz percona.com/blog/2010/10/25/mysql-limitations-part -3-podzapytania ).
shosti

71

Również napotkałem ten problem, a teraz moją strategią jest generowanie kodu SQL (ręcznie lub przy użyciu to_sqlistniejącego zakresu), a następnie umieszczanie go w fromklauzuli. Nie mogę zagwarantować, że jest bardziej wydajna niż przyjęta metoda, ale jest stosunkowo łagodna dla oczu i przywraca normalny obiekt 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")

Możesz to zrobić również z dwoma różnymi modelami, ale musisz upewnić się, że oba "wyglądają tak samo" wewnątrz UNION - możesz użyć selectw obu zapytaniach, aby upewnić się, że utworzą te same kolumny.

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

załóżmy, że jeśli mamy dwa różne modele, daj mi znać, jakie będzie zapytanie dotyczące unoin.
Chitra,

Bardzo pomocna odpowiedź. Dla przyszłych czytelników zapamiętaj ostatnią część „Komentarze AS”, ponieważ activerecord konstruuje zapytanie jako „SELECT” komentarze ”.„ * „FROM” ... jeśli nie określisz nazwy połączonego zestawu LUB określisz inną nazwę, na przykład „AS foo”, ostateczne wykonanie sql nie powiedzie się
HeyZiko

1
To było dokładnie to, czego szukałem. Rozszerzyłem ActiveRecord :: Relation o wsparcie #orw moim projekcie Rails 4. Przyjmując ten sam model:klass.from("(#{to_sql} union #{other_relation.to_sql}) as #{table_name}")
M. Wyatt

11

Na podstawie odpowiedzi Olives wymyśliłem inne rozwiązanie tego problemu. To trochę przypomina włamanie, ale zwraca przykład ActiveRelation, o co mi chodziło.

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)

Nadal byłbym wdzięczny, gdyby ktoś miał jakieś sugestie, aby to zoptymalizować lub poprawić wydajność, ponieważ zasadniczo wykonuje trzy zapytania i wydaje się trochę zbędne.


Jak mogłem zrobić to samo z tym: gist.github.com/2241307, aby tworzyło klasę AR :: Relation zamiast klasy Array?
Marc

10

Możesz także użyć klejnotu active_record_union Briana Hempela , który rozszerza się oActiveRecordunion metodę dla lunet.

Twoje zapytanie wyglądałoby tak:

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

Miejmy nadzieję, że ActiveRecordpewnego dnia zostanie to połączone .


8

Co powiesz na...

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

15
Ostrzegamy, że spowoduje to wykonanie trzech zapytań zamiast jednego, ponieważ każde pluckwywołanie jest samo w sobie zapytaniem.
JacobEvelyn

3
Jest to bardzo dobre rozwiązanie, poniewaz nie zwraca tablicę, więc można użyć .orderalbo .paginatemetody ... Utrzymuje klas ORM
mariowise

Przydatne, jeśli zakresy są tego samego modelu, ale spowodowałoby to wygenerowanie dwóch zapytań z powodu podciągnięć.
jmjm

6

Czy możesz użyć OR zamiast UNION?

Wtedy możesz zrobić coś takiego:

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

(Ponieważ dwukrotnie dołączasz do obserwowanej tabeli, nie jestem pewien, jakie będą nazwy tabel dla zapytania)

Ponieważ jest wiele sprzężeń, może to być również dość ciężkie dla bazy danych, ale może być zoptymalizowane.


2
Przepraszam, że wróciłem tak późno, ale od kilku dni jestem na wakacjach. Problem, który miałem, kiedy próbowałem twojej odpowiedzi, polegał na tym, że metoda łączenia powodowała łączenie obu tabel, a nie dwóch oddzielnych zapytań, które można było następnie porównać. Jednak twój pomysł był rozsądny i dał mi inny pomysł. Dzięki za pomoc.
LandonSchropp

wybierz użycie OR jest wolniejsze niż UNION, zastanawiając się nad rozwiązaniem dla UNION
Nich

5

Prawdopodobnie poprawia to czytelność, ale niekoniecznie wydajność:

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

Ta metoda zwraca ActiveRecord :: Relation, więc możesz to nazwać w ten sposób:

my_posts.order("watched_item_type, post.id DESC")

skąd otrzymujesz posts.id?
berto77

Istnieją dwa parametry self.id, ponieważ odwołanie do self.id występuje dwukrotnie w SQL - zobacz dwa znaki zapytania.
richardsun

To był przydatny przykład tego, jak wykonać zapytanie aa UNION i odzyskać ActiveRecord :: Relation. Dzięki.
Fitter Man

czy masz narzędzie do generowania tego typu zapytań SDL - jak to zrobiłeś bez błędów ortograficznych itp.?
BKSpurgeon

2

Istnieje klejnot active_record_union. Może być pomocna

https://github.com/brianhempel/active_record_union

Dzięki ActiveRecordUnion możemy:

aktualny użytkownik (wersja robocza) posty i wszystkie opublikowane posty od kogokolwiek, current_user.posts.union(Post.published) co jest równoważne z następującym 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

Po prostu uruchomiłbym dwa zapytania, których potrzebujesz i połączyłby tablice zwracanych rekordów:

@posts = watched_news_posts + watched_topics_posts

Albo przynajmniej przetestuj to. Czy uważasz, że kombinacja tablic w ruby ​​będzie zbyt wolna? Patrząc na sugerowane zapytania w celu obejścia problemu, nie jestem przekonany, że wystąpi tak znacząca różnica w wydajności.


Właściwie zrobienie @ posts = watched_news_posts & watched_topics_posts może być lepsze, ponieważ jest to skrzyżowanie i pozwoli uniknąć oszustw.
Jeffrey Alan Lee

1
Miałem wrażenie, że ActiveRelation leniwie ładuje swoje płyty. Czy nie straciłbyś tego, gdybyś przeciął tablice w Rubim?
LandonSchropp

Najwyraźniej związek, który zwraca relację, jest pod dev w railsach, ale nie wiem, w jakiej wersji to będzie.
Jeffrey Alan Lee

1
zamiast tej tablicy zwracanej, jej dwa różne wyniki zapytania łączą się.
alexzg

1

W podobnym przypadku zsumowałem dwie tablice i użyłem Kaminari:paginate_array(). Bardzo ładne i działające rozwiązanie. Nie mogłem użyć where(), ponieważ muszę zsumować dwa wyniki z różnymi order()na tej samej tabeli.


1

Mniej problemów i łatwiejsze do naśladowania:

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

Więc na koniec:

union_scope(watched_news_posts, watched_topic_posts)

1
Zmieniłem to nieznacznie na: Dzięki scopes.drop(1).reduce(where(id: scopes.first)) { |query, scope| query.or(where(id: scope)) }!
eike

0

Elliot Nelson odpowiedział dobrze, z wyjątkiem przypadku, gdy niektóre relacje są puste. Zrobiłbym coś takiego:

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)

koniec


0

Oto jak dołączyłem do zapytań SQL używając UNION na mojej własnej aplikacji Ruby on Rails.

Możesz użyć poniższego jako inspiracji dla własnego kodu.

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

Poniżej znajduje się UNIA, w której połączyłem razem zakresy.

  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
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.