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

คำค้นหาทั้งสองนี้ทำงานได้ดีด้วยตัวเอง ทั้งสองส่งคืนวัตถุโพสต์ ฉันต้องการรวมโพสต์เหล่านี้ไว้ใน ActiveRelation เดียว เนื่องจากอาจมีหลายแสนโพสต์ในบางจุดจึงจำเป็นต้องดำเนินการในระดับฐานข้อมูล หากเป็นแบบสอบถาม MySQL ฉันสามารถใช้ตัวUNIONดำเนินการได้ มีใครรู้บ้างว่าฉันสามารถทำสิ่งที่คล้ายกันกับอินเทอร์เฟซการสืบค้นของ RoR ได้หรือไม่?


คุณควรจะสามารถที่จะใช้ขอบเขต สร้าง 2 Post.watched_news_posts.watched_topic_postsขอบเขตแล้วเรียกพวกเขาทั้งสองเช่น คุณอาจจะต้องส่งใน params ขอบเขตสำหรับสิ่งที่ต้องการและ:user_id :topic
Zabba

6
ขอบคุณสำหรับคำแนะนำ ตามเอกสาร "ขอบเขตหมายถึงการ จำกัด คิวรีฐานข้อมูล" ในกรณีของฉันฉันไม่ได้มองหาโพสต์ที่อยู่ในทั้ง watch_news_posts และ watch_topic_posts แต่ฉันกำลังมองหาโพสต์ที่อยู่ใน watch_news_posts หรือ watch_topic_posts โดยไม่อนุญาตให้สร้างซ้ำ ยังสามารถทำได้ด้วยขอบเขต?
LandonSchropp

1
ออกนอกกรอบไม่ได้จริงๆ มีปลั๊กอินบน github ที่เรียกว่า union แต่ใช้ไวยากรณ์แบบ old-school (เมธอดคลาสและพารามิเตอร์การสืบค้นแบบแฮช) ถ้ามันเจ๋งกับคุณฉันจะบอกว่าไปกับมัน ... ไม่งั้นเขียนยาว ๆ ใน a 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ไม่สามารถใช้กับการสืบค้นแบบ chainable อื่น ๆ ได้ซึ่งหมายความว่าตอนนี้ฉันต้องเขียนตัวกรองและคำค้นหา will_paginate ของฉันใหม่ด้วย เหตุใด ActiveRecord จึงไม่รองรับการunionดำเนินการ
LandonSchropp

คำตอบ:


97

นี่คือโมดูลเล็ก ๆ น้อย ๆ ที่ฉันเขียนไว้ซึ่งช่วยให้คุณสามารถ UNION ได้หลายขอบเขต นอกจากนี้ยังส่งคืนผลลัพธ์เป็นอินสแตนซ์ของ 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

ตามที่ร้องขอฉันได้เพิ่มตัวอย่าง
Tim Lowrimore

3
วิธีแก้ปัญหานั้น "เกือบ" ถูกต้องและฉันให้ +1 แต่ฉันพบปัญหาที่ฉันแก้ไขที่นี่: gist.github.com/lsiden/260167a4d3574a580d97
Lawrence I. Siden

7
คำเตือนด่วน: วิธีนี้เป็นปัญหาอย่างมากจากมุมมองด้านประสิทธิภาพของ MySQL เนื่องจากการสืบค้นย่อยจะถูกนับว่าขึ้นอยู่กับและดำเนินการสำหรับแต่ละระเบียนในตาราง (ดูpercona.com/blog/2010/10/25/mysql-limitations-part -3- แบบสอบถามย่อย )
shosti

72

ฉันพบปัญหานี้เช่นกันและตอนนี้กลยุทธ์ go-to ของฉันคือการสร้าง 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
จิตรา

คำตอบที่เป็นประโยชน์มาก สำหรับผู้อ่านในอนาคตโปรดจำส่วน "AS comments" สุดท้ายเนื่องจาก 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}")
M. Wyatt

11

จากคำตอบของ Olives ฉันได้หาวิธีแก้ปัญหานี้อีกวิธีหนึ่ง มันให้ความรู้สึกเหมือนแฮ็คเล็กน้อย แต่มันส่งคืนตัวอย่าง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

คุณยังสามารถใช้อัญมณีactive_record_unionของBrian Hempelที่ขยายด้วยวิธีการสำหรับขอบเขตActiveRecordunion

คำถามของคุณจะเป็นดังนี้:

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
นี้เป็นทางออกที่ดีจริงๆ becouse มันไม่ได้กลับอาร์เรย์ดังนั้นแล้วคุณสามารถใช้.orderหรือ.paginateวิธีการ ... มันช่วยให้การเรียนการออม
mariowise

มีประโยชน์หากขอบเขตเป็นแบบจำลองเดียวกัน แต่จะทำให้เกิดการสืบค้นสองรายการเนื่องจากการดึงข้อมูล
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")

คุณได้รับโพสต์จากที่ไหน?
berto77

มีพารามิเตอร์ self.id สองพารามิเตอร์เนื่องจาก self.id ถูกอ้างถึงสองครั้งใน SQL - ดูเครื่องหมายคำถามสองข้อ
richardsun

นี่เป็นตัวอย่างที่มีประโยชน์ในการทำแบบสอบถาม UNION และรับ ActiveRecord :: Relation กลับคืนมา ขอบคุณ.
Fitter Man

คุณมีเครื่องมือในการสร้างคำค้นหา 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 = watch_news_posts & watch_topics_posts จริงๆแล้วอาจจะดีกว่าเนื่องจากเป็นทางแยกและจะหลีกเลี่ยงการซ้ำซ้อน
Jeffrey Alan Lee

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!
เช่น

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 บนรางของฉันเอง

คุณสามารถใช้ข้อมูลด้านล่างนี้เป็นแรงบันดาลใจในโค้ดของคุณเอง

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

ด้านล่างนี้คือ UNION ที่ฉันเข้าร่วมขอบเขตด้วยกัน

  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
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.