From 71ca4ec6785832c8691cb2777bfea0a60c05328e Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 23 Jun 2014 11:42:46 +0800 Subject: [PATCH] Added #or to ActiveRecord::Relation Post.where('id = 1').or(Post.where('id = 2')) # => SELECT * FROM posts WHERE (id = 1) OR (id = 2) [Matthew Draper & Gael Muller] --- activerecord/CHANGELOG.md | 10 +++ .../lib/active_record/null_relation.rb | 8 ++ activerecord/lib/active_record/querying.rb | 2 +- .../active_record/relation/query_methods.rb | 59 ++++++++++++++ activerecord/test/cases/relation/or_test.rb | 81 +++++++++++++++++++ activerecord/test/models/post.rb | 3 + 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 activerecord/test/cases/relation/or_test.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 7305c2c73834d..c421a94923621 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,13 @@ +* Added the `#or` method on ActiveRecord::Relation, allowing use of the OR + operator to combine WHERE or HAVING clauses. + + Example: + + Post.where('id = 1').or(Post.where('id = 2')) + # => SELECT * FROM posts WHERE (id = 1) OR (id = 2) + + *Matthew Draper*, *Gael Muller*, *Olivier El Mekki* + * `has_many :through` associations will no longer save the through record twice when added in an `after_create` callback defined before the associations. diff --git a/activerecord/lib/active_record/null_relation.rb b/activerecord/lib/active_record/null_relation.rb index 807c301596c89..1c1876ac786ab 100644 --- a/activerecord/lib/active_record/null_relation.rb +++ b/activerecord/lib/active_record/null_relation.rb @@ -77,5 +77,13 @@ def calculate(operation, _column_name, _options = {}) def exists?(_id = false) false end + + def or(other) + if other.is_a?(NullRelation) + super + else + other.or(self) + end + end end end diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 39817703cdfae..901cf4c81905b 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -7,7 +7,7 @@ module Querying delegate :find_by, :find_by!, to: :all delegate :destroy, :destroy_all, :delete, :delete_all, :update, :update_all, to: :all delegate :find_each, :find_in_batches, to: :all - delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, + delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :or, :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :having, :create_with, :uniq, :distinct, :references, :none, :unscope, to: :all delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 1262b2c291803..80a9c8f1e8a16 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -592,6 +592,65 @@ def rewhere(conditions) unscope(where: conditions.keys).where(conditions) end + # Returns a new relation, which is the logical union of this relation and the one passed as an + # argument. + # + # The two relations must be structurally compatible: they must be scoping the same model, and + # they must differ only by +where+ (if no +group+ has been defined) or +having+ (if a +group+ is + # present). Neither relation may have a +limit+, +offset+, or +uniq+ set. + # + # Post.where("id = 1").or(Post.where("id = 2")) + # # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'id = 2')) + # + def or(other) + spawn.or!(other) + end + + def or!(other) + combining = group_values.any? ? :having : :where + + unless structurally_compatible?(other, combining) + raise ArgumentError, 'Relation passed to #or must be structurally compatible' + end + + unless other.is_a?(NullRelation) + left_values = send("#{combining}_values") + right_values = other.send("#{combining}_values") + + common = left_values & right_values + mine = left_values - common + theirs = right_values - common + + if mine.any? && theirs.any? + mine = mine.map { |x| String === x ? Arel.sql(x) : x } + theirs = theirs.map { |x| String === x ? Arel.sql(x) : x } + + mine = [Arel::Nodes::And.new(mine)] if mine.size > 1 + theirs = [Arel::Nodes::And.new(theirs)] if theirs.size > 1 + + common << Arel::Nodes::Or.new(mine.first, theirs.first) + end + + send("#{combining}_values=", common) + end + + self + end + + def structurally_compatible?(other, allowed_to_vary) + Relation::SINGLE_VALUE_METHODS.all? do |name| + send("#{name}_value") == other.send("#{name}_value") + end && + (Relation::MULTI_VALUE_METHODS - [allowed_to_vary, :extending]).all? do |name| + send("#{name}_values") == other.send("#{name}_values") + end && + (extending_values - [NullRelation]) == (other.extending_values - [NullRelation]) && + !limit_value && + !offset_value && + !uniq_value + end + private :structurally_compatible? + # Allows to specify a HAVING clause. Note that you can't use HAVING # without also specifying a GROUP clause. # diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb new file mode 100644 index 0000000000000..f2115d8aa6580 --- /dev/null +++ b/activerecord/test/cases/relation/or_test.rb @@ -0,0 +1,81 @@ +require "cases/helper" +require 'models/post' + +module ActiveRecord + class OrTest < ActiveRecord::TestCase + fixtures :posts + + def test_or_with_relation + expected = Post.where('id = 1 or id = 2').to_a + assert_equal expected, Post.where('id = 1').or(Post.where('id = 2')).to_a + end + + def test_or_identity + expected = Post.where('id = 1').to_a + assert_equal expected, Post.where('id = 1').or(Post.where('id = 1')).to_a + end + + def test_or_with_null_left + expected = Post.where('id = 1').to_a + assert_equal expected, Post.none.or(Post.where('id = 1')).to_a + end + + def test_or_with_null_right + expected = Post.where('id = 1').to_a + assert_equal expected, Post.where('id = 1').or(Post.none).to_a + end + + def test_or_with_null_both + expected = Post.none.to_a + assert_equal expected, Post.none.or(Post.none).to_a + end + + def test_or_without_left_where + expected = Post.all.to_a + assert_equal expected, Post.or(Post.where('id = 1')).to_a + end + + def test_or_without_right_where + expected = Post.all.to_a + assert_equal expected, Post.where('id = 1').or(Post.all).to_a + end + + def test_or_preserves_other_querying_methods + expected = Post.where('id = 1 or id = 2 or id = 3').order('body asc').to_a + partial = Post.order('body asc') + assert_equal expected, partial.where('id = 1').or(partial.where(:id => [2, 3])).to_a + assert_equal expected, Post.order('body asc').where('id = 1').or(Post.order('body asc').where(:id => [2, 3])).to_a + end + + def test_or_with_incompatible_relations + assert_raises ArgumentError do + Post.order('body asc').where('id = 1').or(Post.order('id desc').where(:id => [2, 3])).to_a + end + end + + def test_or_when_grouping + groups = Post.where('id < 10').group('body').select('body, COUNT(*) AS c') + expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map {|o| [o.body, o.c] } + assert_equal expected, groups.having('COUNT(*) > 1').or(groups.having("body like 'Such%'")).to_a.map {|o| [o.body, o.c] } + end + + def test_or_with_named_scope + expected = Post.where("id = 1 or body LIKE '\%a\%'").to_a + assert_equal expected, Post.where('id = 1').or(Post.containing_the_letter_a) + end + + def test_or_inside_named_scope + expected = Post.where("body LIKE '\%a\%' OR title LIKE ?", "%'%").order('id DESC').to_a + assert_equal expected, Post.order(id: :desc).typographically_interesting + end + + def test_or_on_loaded_relation + expected = Post.where('id = 1 or id = 2').to_a + p = Post.where('id = 1') + p.load + assert_equal p.loaded?, true + assert_equal expected, p.or(Post.where('id = 2')).to_a + end + + end +end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 5f01ab0a82b4f..93acef4528f54 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -18,6 +18,7 @@ def greeting end scope :containing_the_letter_a, -> { where("body LIKE '%a%'") } + scope :titled_with_an_apostrophe, -> { where("title LIKE '%''%'") } scope :ranked_by_comments, -> { order("comments_count DESC") } scope :limit_by, lambda {|l| limit(l) } @@ -42,6 +43,8 @@ def first_comment scope :tagged_with, ->(id) { joins(:taggings).where(taggings: { tag_id: id }) } + scope :typographically_interesting, -> { containing_the_letter_a.or(titled_with_an_apostrophe) } + has_many :comments do def find_most_recent order("id DESC").first