Skip to content

Commit

Permalink
Support where with comparison operators (>, >=, <, and <=)
Browse files Browse the repository at this point in the history
Revert "Revert "Merge pull request #39613 from kamipo/where_with_custom_operator""

This reverts commit da02291.

```ruby
posts = Post.order(:id)

posts.where("id >": 9).pluck(:id)  # => [10, 11]
posts.where("id >=": 9).pluck(:id) # => [9, 10, 11]
posts.where("id <": 3).pluck(:id)  # => [1, 2]
posts.where("id <=": 3).pluck(:id) # => [1, 2, 3]
```

From type casting and table/column name resolution's point of view,
`where("created_at >=": time)` is better alternative than `where("created_at >= ?", time)`.

```ruby
class Post < ActiveRecord::Base
  attribute :created_at, :datetime, precision: 3
end

time = Time.now.utc # => 2020-06-24 10:11:12.123456 UTC

Post.create!(created_at: time) # => #<Post id: 1, created_at: "2020-06-24 10:11:12.123000">

# SELECT `posts`.* FROM `posts` WHERE (created_at >= '2020-06-24 10:11:12.123456')
Post.where("created_at >= ?", time) # => []

# SELECT `posts`.* FROM `posts` WHERE `posts`.`created_at` >= '2020-06-24 10:11:12.123000'
Post.where("created_at >=": time) # => [#<Post id: 1, created_at: "2020-06-24 10:11:12.123000">]
```

As a main contributor of the predicate builder area, I'd recommend to
people use the hash syntax, the hash syntax also have other useful
effects (making boundable queries, unscopeable queries, hash-like
relation merging friendly, automatic other table references detection).

* Making boundable queries

While working on #23461, I realized that Active Record doesn't generate
boundable queries perfectly, so I've been improving generated queries to
be boundable for a long time.

e.g.

#26117
7d53993
#39219

Now, `where` with the hash syntax will generate boundable queries
perfectly.

I also want to generate boundable queries with a comparison operator in
a third party gem, but currently there is no other way than calling
`predicate_builder` directly.

kufu/activerecord-bitemporal#62

* Unscopeable queries, Hash-like relation merging friendly

Unscopeable, and Hash-like merging friendly queries are relying on where
clause is an array of attr with value, and attr name is normalized as a
string (i.e. using `User.arel_table[:name]` is not preferable for
`unscope` and `merge`).

Example:

```ruby
id = User.arel_table[:id]

users = User.where(id.gt(1).and(id.lteq(10)))

# no-op due to `id.gt(1).and(id.lteq(10))` is not an attr with value
users.unscope(:id)
```

* Automatic other table references detection

It works only for the hash syntax.

ee7f666
  • Loading branch information
kamipo committed Jan 14, 2021
1 parent 291a3d2 commit 0f5f596
Show file tree
Hide file tree
Showing 7 changed files with 73 additions and 10 deletions.
32 changes: 32 additions & 0 deletions activerecord/CHANGELOG.md
@@ -1,3 +1,35 @@
* Support `where` with comparison operators (`>`, `>=`, `<`, and `<=`).

```ruby
posts = Post.order(:id)

posts.where("id >": 9).pluck(:id) # => [10, 11]
posts.where("id >=": 9).pluck(:id) # => [9, 10, 11]
posts.where("id <": 3).pluck(:id) # => [1, 2]
posts.where("id <=": 3).pluck(:id) # => [1, 2, 3]
```

From type casting and table/column name resolution's point of view,
`where("created_at >=": time)` is better alternative than `where("created_at >= ?", time)`.

```ruby
class Post < ActiveRecord::Base
attribute :created_at, :datetime, precision: 3
end

time = Time.now.utc # => 2020-06-24 10:11:12.123456 UTC

Post.create!(created_at: time) # => #<Post id: 1, created_at: "2020-06-24 10:11:12.123000">

# SELECT `posts`.* FROM `posts` WHERE (created_at >= '2020-06-24 10:11:12.123456')
Post.where("created_at >= ?", time) # => []

# SELECT `posts`.* FROM `posts` WHERE `posts`.`created_at` >= '2020-06-24 10:11:12.123000'
Post.where("created_at >=": time) # => [#<Post id: 1, created_at: "2020-06-24 10:11:12.123000">]
```

*Ryuta Kamizono*

* Add `FinderMethods#sole` and `#find_sole_by` to find and assert the
presence of exactly one record.

Expand Down
4 changes: 4 additions & 0 deletions activerecord/lib/active_record/relation/predicate_builder.rb
Expand Up @@ -125,12 +125,16 @@ def expand_from_hash(attributes, &block)

grouping_queries(queries)
end
elsif key.end_with?(">", ">=", "<", "<=") && /\A(?<key>.+?)\s*(?<operator>>|>=|<|<=)\z/ =~ key
self[key, value, OPERATORS[operator]]
else
self[key, value]
end
end
end

OPERATORS = { ">" => :gt, ">=" => :gteq, "<" => :lt, "<=" => :lteq }.freeze

private
attr_reader :table

Expand Down
8 changes: 6 additions & 2 deletions activerecord/test/cases/date_time_precision_test.rb
Expand Up @@ -98,8 +98,12 @@ def test_formatting_datetime_according_to_precision
date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999)
Foo.create!(created_at: date, updated_at: date)

assert foo = Foo.find_by(created_at: date)
assert_equal 1, Foo.where(updated_at: date).count
assert_nil Foo.find_by("created_at >= ?", date)
assert_equal 0, Foo.where("updated_at >= ?", date).count

assert foo = Foo.find_by("created_at >=": date)
assert_equal 1, Foo.where("updated_at >=": date).count

assert_equal date.to_s, foo.created_at.to_s
assert_equal date.to_s, foo.updated_at.to_s
assert_equal 000000, foo.created_at.usec
Expand Down
25 changes: 22 additions & 3 deletions activerecord/test/cases/relations_test.rb
Expand Up @@ -1004,7 +1004,7 @@ def test_count
assert_equal 11, posts.count(:all)
assert_equal 11, posts.count(:id)

assert_equal 3, posts.where("legacy_comments_count > 1").count
assert_equal 3, posts.where("comments_count >": 1).count
assert_equal 6, posts.where(comments_count: 0).count
end

Expand Down Expand Up @@ -1124,7 +1124,7 @@ def test_empty_with_zero_limit
end

def test_count_complex_chained_relations
posts = Post.select("comments_count").where("id is not null").group("author_id").where("legacy_comments_count > 0")
posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count >": 0)

expected = { 1 => 4, 2 => 1 }
assert_equal expected, posts.count
Expand All @@ -1146,7 +1146,7 @@ def test_empty
end

def test_empty_complex_chained_relations
posts = Post.select("comments_count").where("id is not null").group("author_id").where("legacy_comments_count > 0")
posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count >": 0)

assert_queries(1) { assert_equal false, posts.empty? }
assert_not_predicate posts, :loaded?
Expand Down Expand Up @@ -2169,6 +2169,25 @@ def test_find_by_with_take_memoization
assert_not_same first_post, third_post
end

def test_where_with_comparison_operator
posts = Post.order(:id)

assert_equal [10, 11], posts.where("id >": 9).pluck(:id)
assert_equal [9, 10, 11], posts.where("id >=": 9).pluck(:id)
assert_equal [1, 2], posts.where("id <": 3).pluck(:id)
assert_equal [1, 2, 3], posts.where("id <=": 3).pluck(:id)
end

def test_where_with_table_name_resolution
posts = Post.joins(:comments).order(:id)

assert_equal [1, 1, 2], posts.where("id <": 3).pluck(:id)

assert_raise(ActiveRecord::StatementInvalid) do
posts.where("id < ?", 3).pluck(:id) # ambiguous column name: id
end
end

test "#skip_query_cache!" do
Post.cache do
assert_queries(1) do
Expand Down
4 changes: 2 additions & 2 deletions activerecord/test/cases/scoping/default_scoping_test.rb
Expand Up @@ -268,10 +268,10 @@ def test_unscope_multiple_where_clauses
end

def test_unscope_string_where_clauses_involved
dev_relation = Developer.order("salary DESC").where("legacy_created_at > ?", 1.year.ago)
dev_relation = Developer.order("salary DESC").where("created_at >": 1.year.ago)
expected = dev_relation.collect(&:name)

dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("legacy_created_at > ?", 1.year.ago)
dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("created_at >": 1.year.ago)
received = dev_ordered_relation.unscope(where: [:name]).collect(&:name)

assert_equal expected.sort, received.sort
Expand Down
8 changes: 6 additions & 2 deletions activerecord/test/cases/time_precision_test.rb
Expand Up @@ -92,8 +92,12 @@ def test_formatting_time_according_to_precision
time = ::Time.utc(2000, 1, 1, 12, 30, 0, 999999)
Foo.create!(start: time, finish: time)

assert foo = Foo.find_by(start: time)
assert_equal 1, Foo.where(finish: time).count
assert_nil Foo.find_by("start >= ?", time)
assert_equal 0, Foo.where("finish >= ?", time).count

assert foo = Foo.find_by("start >=": time)
assert_equal 1, Foo.where("finish >=": time).count

assert_equal time.to_s, foo.start.to_s
assert_equal time.to_s, foo.finish.to_s
assert_equal 000000, foo.start.usec
Expand Down
2 changes: 1 addition & 1 deletion activerecord/test/models/author.rb
Expand Up @@ -38,7 +38,7 @@ def ratings
-> { where(title: "Welcome to the weblog").where(comments_count: 1) },
class_name: "Post"
has_many :welcome_posts_with_comments,
-> { where(title: "Welcome to the weblog").where("legacy_comments_count > 0") },
-> { where(title: "Welcome to the weblog").where("comments_count >": 0) },
class_name: "Post"

has_many :comments_desc, -> { order("comments.id DESC") }, through: :posts_sorted_by_id, source: :comments
Expand Down

0 comments on commit 0f5f596

Please sign in to comment.