Skip to content

Commit 9e42cf0

Browse files
committed
Merge Pull Request #16052 Added #or to ActiveRecord::Relation
2 parents 96ac14a + ff45b9e commit 9e42cf0

File tree

8 files changed

+166
-1
lines changed

8 files changed

+166
-1
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Added the `#or` method on ActiveRecord::Relation, allowing use of the OR
2+
operator to combine WHERE or HAVING clauses.
3+
4+
Example:
5+
6+
Post.where('id = 1').or(Post.where('id = 2'))
7+
# => SELECT * FROM posts WHERE (id = 1) OR (id = 2)
8+
9+
*Sean Griffin*, *Matthew Draper*, *Gael Muller*, *Olivier El Mekki*
10+
111
* Don't define autosave association callbacks twice from
212
`accepts_nested_attributes_for`.
313

activerecord/lib/active_record/null_relation.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,9 @@ def calculate(operation, _column_name)
7575
def exists?(_id = false)
7676
false
7777
end
78+
79+
def or(other)
80+
other.spawn
81+
end
7882
end
7983
end

activerecord/lib/active_record/querying.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Querying
77
delegate :find_by, :find_by!, to: :all
88
delegate :destroy, :destroy_all, :delete, :delete_all, :update, :update_all, to: :all
99
delegate :find_each, :find_in_batches, to: :all
10-
delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins,
10+
delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :or,
1111
:where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly,
1212
:having, :create_with, :uniq, :distinct, :references, :none, :unscope, to: :all
1313
delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all

activerecord/lib/active_record/relation/query_methods.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,37 @@ def rewhere(conditions)
582582
unscope(where: conditions.keys).where(conditions)
583583
end
584584

585+
# Returns a new relation, which is the logical union of this relation and the one passed as an
586+
# argument.
587+
#
588+
# The two relations must be structurally compatible: they must be scoping the same model, and
589+
# they must differ only by +where+ (if no +group+ has been defined) or +having+ (if a +group+ is
590+
# present). Neither relation may have a +limit+, +offset+, or +uniq+ set.
591+
#
592+
# Post.where("id = 1").or(Post.where("id = 2"))
593+
# # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'id = 2'))
594+
#
595+
def or(other)
596+
spawn.or!(other)
597+
end
598+
599+
def or!(other) # :nodoc:
600+
unless structurally_compatible_for_or?(other)
601+
raise ArgumentError, 'Relation passed to #or must be structurally compatible'
602+
end
603+
604+
self.where_clause = self.where_clause.or(other.where_clause)
605+
self.having_clause = self.having_clause.or(other.having_clause)
606+
607+
self
608+
end
609+
610+
private def structurally_compatible_for_or?(other) # :nodoc:
611+
Relation::SINGLE_VALUE_METHODS.all? { |m| send("#{m}_value") == other.send("#{m}_value") } &&
612+
(Relation::MULTI_VALUE_METHODS - [:extending]).all? { |m| send("#{m}_values") == other.send("#{m}_values") } &&
613+
(Relation::CLAUSE_METHODS - [:having, :where]).all? { |m| send("#{m}_clause") != other.send("#{m}_clause") }
614+
end
615+
585616
# Allows to specify a HAVING clause. Note that you can't use HAVING
586617
# without also specifying a GROUP clause.
587618
#

activerecord/lib/active_record/relation/where_clause.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ def except(*columns)
3131
)
3232
end
3333

34+
def or(other)
35+
if empty?
36+
other
37+
elsif other.empty?
38+
self
39+
else
40+
WhereClause.new(
41+
[ast.or(other.ast)],
42+
binds + other.binds
43+
)
44+
end
45+
end
46+
3447
def to_h(table_name = nil)
3548
equalities = predicates.grep(Arel::Nodes::Equality)
3649
if table_name
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
require "cases/helper"
2+
require 'models/post'
3+
4+
module ActiveRecord
5+
class OrTest < ActiveRecord::TestCase
6+
fixtures :posts
7+
8+
def test_or_with_relation
9+
expected = Post.where('id = 1 or id = 2').to_a
10+
assert_equal expected, Post.where('id = 1').or(Post.where('id = 2')).to_a
11+
end
12+
13+
def test_or_identity
14+
expected = Post.where('id = 1').to_a
15+
assert_equal expected, Post.where('id = 1').or(Post.where('id = 1')).to_a
16+
end
17+
18+
def test_or_with_null_left
19+
expected = Post.where('id = 1').to_a
20+
assert_equal expected, Post.none.or(Post.where('id = 1')).to_a
21+
end
22+
23+
def test_or_with_null_right
24+
expected = Post.where('id = 1').to_a
25+
assert_equal expected, Post.where('id = 1').or(Post.none).to_a
26+
end
27+
28+
def test_or_with_bind_params
29+
assert_equal Post.find([1, 2]), Post.where(id: 1).or(Post.where(id: 2)).to_a
30+
end
31+
32+
def test_or_with_null_both
33+
expected = Post.none.to_a
34+
assert_equal expected, Post.none.or(Post.none).to_a
35+
end
36+
37+
def test_or_without_left_where
38+
expected = Post.where('id = 1')
39+
assert_equal expected, Post.or(Post.where('id = 1')).to_a
40+
end
41+
42+
def test_or_without_right_where
43+
expected = Post.where('id = 1')
44+
assert_equal expected, Post.where('id = 1').or(Post.all).to_a
45+
end
46+
47+
def test_or_preserves_other_querying_methods
48+
expected = Post.where('id = 1 or id = 2 or id = 3').order('body asc').to_a
49+
partial = Post.order('body asc')
50+
assert_equal expected, partial.where('id = 1').or(partial.where(:id => [2, 3])).to_a
51+
assert_equal expected, Post.order('body asc').where('id = 1').or(Post.order('body asc').where(:id => [2, 3])).to_a
52+
end
53+
54+
def test_or_with_incompatible_relations
55+
assert_raises ArgumentError do
56+
Post.order('body asc').where('id = 1').or(Post.order('id desc').where(:id => [2, 3])).to_a
57+
end
58+
end
59+
60+
def test_or_when_grouping
61+
groups = Post.where('id < 10').group('body').select('body, COUNT(*) AS c')
62+
expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map {|o| [o.body, o.c] }
63+
assert_equal expected, groups.having('COUNT(*) > 1').or(groups.having("body like 'Such%'")).to_a.map {|o| [o.body, o.c] }
64+
end
65+
66+
def test_or_with_named_scope
67+
expected = Post.where("id = 1 or body LIKE '\%a\%'").to_a
68+
assert_equal expected, Post.where('id = 1').or(Post.containing_the_letter_a)
69+
end
70+
71+
def test_or_inside_named_scope
72+
expected = Post.where("body LIKE '\%a\%' OR title LIKE ?", "%'%").order('id DESC').to_a
73+
assert_equal expected, Post.order(id: :desc).typographically_interesting
74+
end
75+
76+
def test_or_on_loaded_relation
77+
expected = Post.where('id = 1 or id = 2').to_a
78+
p = Post.where('id = 1')
79+
p.load
80+
assert_equal p.loaded?, true
81+
assert_equal expected, p.or(Post.where('id = 2')).to_a
82+
end
83+
end
84+
end

activerecord/test/cases/relation/where_clause_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,26 @@ class WhereClauseTest < ActiveRecord::TestCase
145145
assert_equal where_clause.ast, where_clause_with_empty.ast
146146
end
147147

148+
test "or joins the two clauses using OR" do
149+
where_clause = WhereClause.new([table["id"].eq(bind_param)], [attribute("id", 1)])
150+
other_clause = WhereClause.new([table["name"].eq(bind_param)], [attribute("name", "Sean")])
151+
expected_ast =
152+
Arel::Nodes::Grouping.new(
153+
Arel::Nodes::Or.new(table["id"].eq(bind_param), table["name"].eq(bind_param))
154+
)
155+
expected_binds = where_clause.binds + other_clause.binds
156+
157+
assert_equal expected_ast.to_sql, where_clause.or(other_clause).ast.to_sql
158+
assert_equal expected_binds, where_clause.or(other_clause).binds
159+
end
160+
161+
test "or does nothing with an empty where clause" do
162+
where_clause = WhereClause.new([table["id"].eq(bind_param)], [attribute("id", 1)])
163+
164+
assert_equal where_clause, where_clause.or(WhereClause.empty)
165+
assert_equal where_clause, WhereClause.empty.or(where_clause)
166+
end
167+
148168
private
149169

150170
def table

activerecord/test/models/post.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def greeting
1818
end
1919

2020
scope :containing_the_letter_a, -> { where("body LIKE '%a%'") }
21+
scope :titled_with_an_apostrophe, -> { where("title LIKE '%''%'") }
2122
scope :ranked_by_comments, -> { order("comments_count DESC") }
2223

2324
scope :limit_by, lambda {|l| limit(l) }
@@ -43,6 +44,8 @@ def first_comment
4344
scope :tagged_with, ->(id) { joins(:taggings).where(taggings: { tag_id: id }) }
4445
scope :tagged_with_comment, ->(comment) { joins(:taggings).where(taggings: { comment: comment }) }
4546

47+
scope :typographically_interesting, -> { containing_the_letter_a.or(titled_with_an_apostrophe) }
48+
4649
has_many :comments do
4750
def find_most_recent
4851
order("id DESC").first

0 commit comments

Comments
 (0)