activerecord-refined 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2343da1bdb5b8fdad1b6671f516c3a56173629d2ad433d93c4a22218deeef1fe
4
- data.tar.gz: f2f2cd301e6541409ffe472c0ec68b8551a5762aba89b64c266bb435df60182f
3
+ metadata.gz: f20876b4eb0b36ece84fc2f6a1c828461846b29bd829e279d31b22735cbf0c15
4
+ data.tar.gz: 4a717ffaf4991fa3a113ea4a6a619b16d431e8308e1869e22c988885022d450a
5
5
  SHA512:
6
- metadata.gz: 4fe091dac0872b9cfbffbdb5bb88d75dfb627e5cf26b29d6256e7bbc9845f5dad4fae4b41de66562ca4079160442f21849b04d0736df20340ba0c317b9ee5f3d
7
- data.tar.gz: fba70971662ad06f2383d51aa53e81a681f2716108a40a0f2f6ebda278526e10517be1159bd2b5715e2da963a33fdc5615ee5acfcd9253782b0526b9cc3cbacb
6
+ metadata.gz: 56734a22e7667270892abf1d6ab9fb0816a37043c5f1fe61a7367c40403f782af6dff7875b0f520ada5aef6ce89155f39919414a71d3abec279ccf3c538db578
7
+ data.tar.gz: f0aa350bbf6b80d1e36b156d2e997930195f2fcfe99c53ae449f3d0f44efe3870671c45bae52a7eac5f39c5f39adf129cbe5a1a1d3776b07eb8d29b1cfa470d2
data/README.md CHANGED
@@ -78,6 +78,27 @@ Author.where { :country.in?(%w[JP US]) } # IN
78
78
  Author.where { :country.null? } # IS NULL
79
79
  ```
80
80
 
81
+ `in?` also takes a relation as a subquery. Without an explicit select list the
82
+ subquery selects the relation's primary key, the same way ActiveRecord's own
83
+ `where(id: relation)` does:
84
+
85
+ ```ruby
86
+ Author.where { :id.in?(Post.published.select(:author_id)) }
87
+ # "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
88
+ ```
89
+
90
+ `exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
91
+ subquery with the outer table through qualified columns — its `where` block
92
+ goes through the DSL like any other:
93
+
94
+ ```ruby
95
+ Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
96
+ # EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
97
+
98
+ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
99
+ # NOT (EXISTS (...))
100
+ ```
101
+
81
102
  `like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
82
103
  Arel would otherwise reach for `ILIKE`.
83
104
 
@@ -91,6 +112,25 @@ Author.where { :name.end_with?('son') } # LIKE '%son'
91
112
  Author.where { :name.include?('test') } # LIKE '%test%'
92
113
  ```
93
114
 
115
+ Like their String namesakes, `start_with?` and `end_with?` take any number of
116
+ literals; matching any one of them is enough:
117
+
118
+ ```ruby
119
+ Author.where { :name.start_with?('A', 'B') }
120
+ # (name LIKE 'A%' OR name LIKE 'B%')
121
+ ```
122
+
123
+ `member?` tests containment in a PostgreSQL array column. The two flavors of
124
+ "does it contain this?" split by name the way Ruby's own classes do: `include?`
125
+ is String's substring match, `member?` is Enumerable's element test, which
126
+ String does not have. Pass an array to require every element:
127
+
128
+ ```ruby
129
+ Article.where { :tags.member?('ruby') } # "tags" @> '{ruby}'
130
+ Article.where { :tags.member?(%w[ruby rails]) } # "tags" @> '{ruby,rails}'
131
+ Article.where { :scores.member?(80) } # "scores" @> '{80}'
132
+ ```
133
+
94
134
  `=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
95
135
  `~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
96
136
  raises there.
@@ -115,6 +155,14 @@ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
115
155
  Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
116
156
  ```
117
157
 
158
+ For the same reason `== nil` raises `ArgumentError`: `= NULL` is never true in
159
+ SQL, so a NULL test has to be spelled as one. Use `null?`:
160
+
161
+ ```ruby
162
+ Author.where { :country.null? } # country IS NULL
163
+ Author.where { !:country.null? } # NOT (country IS NULL)
164
+ ```
165
+
118
166
  Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
119
167
  parentheses around each comparison necessary, though the `?` methods above need
120
168
  none:
@@ -202,21 +250,10 @@ and publishes it through RubyGems.org's trusted publishing, so no API key is
202
250
  stored anywhere.
203
251
 
204
252
  ```sh
205
- # bump Activerecord::Refined::VERSION and commit it, then
206
- git tag v0.4.0
207
- git push origin v0.4.0
253
+ bump patch --tag # or bump {major,minor} etc.
254
+ git push --follow-tags
208
255
  ```
209
256
 
210
- This needs a trusted publisher registered once at
211
- <https://rubygems.org/gems/activerecord-refined/trusted_publishers>:
212
-
213
- | Field | Value |
214
- | --- | --- |
215
- | Repository owner | `shugo` |
216
- | Repository name | `activerecord-refined` |
217
- | Workflow filename | `push_gem.yml` |
218
- | Environment | `release` |
219
-
220
257
  ## Contributing
221
258
 
222
259
  1. Fork it
@@ -5,11 +5,20 @@ module ActiveRecord
5
5
  # expressions. Imported into the Symbol refinement with
6
6
  # Refinement#import_methods, so every method must be defined with def.
7
7
  module Predications
8
+ # == and != mean SQL = and <>, and = NULL is never true there, so nil
9
+ # is rejected rather than silently rewritten to IS NULL. null? builds
10
+ # its node directly and stays clear of this check.
8
11
  def ==(other)
12
+ if other.nil?
13
+ raise ArgumentError, "== does not take nil; use null? instead"
14
+ end
9
15
  Comparison.new(self, :==, other)
10
16
  end
11
17
 
12
18
  def !=(other)
19
+ if other.nil?
20
+ raise ArgumentError, "!= does not take nil; use !null? instead"
21
+ end
13
22
  Comparison.new(self, :!=, other)
14
23
  end
15
24
 
@@ -53,17 +62,27 @@ module ActiveRecord
53
62
  Like.new(self, pattern)
54
63
  end
55
64
 
56
- def start_with?(prefix)
57
- Like.new(self, "#{Like.escape(prefix)}%", Like::ESCAPE)
65
+ def start_with?(*prefixes)
66
+ if prefixes.empty?
67
+ raise ArgumentError, "start_with? needs at least one prefix"
68
+ end
69
+ Like.any(self, prefixes.map {|prefix| "#{Like.escape(prefix)}%" })
58
70
  end
59
71
 
60
- def end_with?(suffix)
61
- Like.new(self, "%#{Like.escape(suffix)}", Like::ESCAPE)
72
+ def end_with?(*suffixes)
73
+ if suffixes.empty?
74
+ raise ArgumentError, "end_with? needs at least one suffix"
75
+ end
76
+ Like.any(self, suffixes.map {|suffix| "%#{Like.escape(suffix)}" })
62
77
  end
63
78
 
64
79
  def include?(substring)
65
80
  Like.new(self, "%#{Like.escape(substring)}%", Like::ESCAPE)
66
81
  end
82
+
83
+ def member?(element)
84
+ Member.new(self, element)
85
+ end
67
86
  end
68
87
 
69
88
  class Node
@@ -215,7 +234,8 @@ module ActiveRecord
215
234
  end
216
235
  end
217
236
 
218
- # IN for a list of values, BETWEEN for a range.
237
+ # IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
238
+ # relation.
219
239
  class In < Predicate
220
240
  attr_reader :operand, :values
221
241
 
@@ -228,9 +248,49 @@ module ActiveRecord
228
248
  arel_operand = to_arel_operand(operand, table)
229
249
  case values
230
250
  when Range then arel_operand.between(values)
251
+ when ActiveRecord::Relation then arel_operand.in(subquery(values))
231
252
  else arel_operand.in(values)
232
253
  end
233
254
  end
255
+
256
+ private
257
+
258
+ # The same treatment ActiveRecord's own RelationHandler gives a
259
+ # relation used as a value: without an explicit select list the
260
+ # subquery selects the model's primary key.
261
+ def subquery(relation)
262
+ if relation.eager_loading?
263
+ relation = relation.send(:apply_join_dependency)
264
+ end
265
+ if relation.select_values.empty?
266
+ model = relation.model
267
+ if model.composite_primary_key?
268
+ raise ArgumentError,
269
+ "Cannot map composite primary key #{model.primary_key} to IN"
270
+ end
271
+ relation = relation.select(relation.table[model.primary_key])
272
+ end
273
+ relation.arel
274
+ end
275
+ end
276
+
277
+ # EXISTS (SELECT ...) for a relation. Correlate the subquery with the
278
+ # outer table through qualified columns. EXISTS only asks whether a row
279
+ # comes back, so unlike In there is no select list to fix up.
280
+ class Exists < Predicate
281
+ attr_reader :relation
282
+
283
+ def initialize(relation)
284
+ @relation = relation
285
+ end
286
+
287
+ def to_arel(_table)
288
+ subquery = relation
289
+ if subquery.eager_loading?
290
+ subquery = subquery.send(:apply_join_dependency)
291
+ end
292
+ subquery.arel.exists
293
+ end
234
294
  end
235
295
 
236
296
  class Like < Predicate
@@ -243,6 +303,13 @@ module ActiveRecord
243
303
  ActiveRecord::Base.sanitize_sql_like(string, ESCAPE)
244
304
  end
245
305
 
306
+ # ORs one LIKE per pattern, for the shortcuts that accept several
307
+ # literals the way String#start_with? does.
308
+ def self.any(operand, patterns)
309
+ patterns.map {|pattern| new(operand, pattern, ESCAPE) }.
310
+ inject {|left, right| Or.new(left, right) }
311
+ end
312
+
246
313
  attr_reader :operand, :pattern, :escape
247
314
 
248
315
  def initialize(operand, pattern, escape = nil)
@@ -258,6 +325,43 @@ module ActiveRecord
258
325
  end
259
326
  end
260
327
 
328
+ # Containment in a PostgreSQL array column. The two flavors of "does it
329
+ # contain this?" split by name the way Ruby's own classes do: include? is
330
+ # String's substring match (LIKE), member? is Enumerable's element test,
331
+ # which String does not have. The elements are rendered as an array
332
+ # literal, which PostgreSQL coerces to the column's element type, so any
333
+ # expression works as the operand and no schema lookup is needed.
334
+ class Member < Predicate
335
+ attr_reader :operand, :elements
336
+
337
+ def initialize(operand, element)
338
+ @operand = operand
339
+ @elements = element.is_a?(::Array) ? element : [element]
340
+ end
341
+
342
+ def to_arel(table)
343
+ arel_operand = to_arel_operand(operand, table)
344
+ arel_operand.contains(Arel::Nodes.build_quoted(array_literal))
345
+ end
346
+
347
+ private
348
+
349
+ # PostgreSQL array input syntax: elements joined by commas inside
350
+ # braces, and an element is double-quoted whenever it is empty, spells
351
+ # NULL, or contains a character the parser treats specially.
352
+ def array_literal
353
+ encoded = elements.map do |value|
354
+ s = value.to_s
355
+ if s.empty? || s.casecmp?("null") || s.match?(/[\s{},"\\]/)
356
+ "\"#{s.gsub(/["\\]/) {|c| "\\#{c}" }}\""
357
+ else
358
+ s
359
+ end
360
+ end
361
+ "{#{encoded.join(',')}}"
362
+ end
363
+ end
364
+
261
365
  # Regular expression match: REGEXP on MySQL, ~ on PostgreSQL. SQLite has
262
366
  # no regexp operator built in, so Arel raises NotImplementedError there.
263
367
  class Match < Predicate
@@ -40,6 +40,10 @@ module ActiveRecord
40
40
  SCALAR_FUNCTIONS.each do |name|
41
41
  define_method(name) {|*args| AST::Function.new(name.to_s.upcase, args) }
42
42
  end
43
+
44
+ def exists?(relation)
45
+ AST::Exists.new(relation)
46
+ end
43
47
  end
44
48
 
45
49
  module QueryMethods
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.3.2'
3
+ VERSION = '0.3.3'
4
4
  end
5
5
  end
@@ -81,6 +81,35 @@ class TestBlockSyntax < Minitest::Test
81
81
  User.where { :name.include?('der') }.to_sql)
82
82
  end
83
83
 
84
+ # Like their String namesakes, start_with? and end_with? take any number
85
+ # of literals; matching any one of them is enough.
86
+ def test_start_with_multiple
87
+ assert_sql(
88
+ /WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\)/,
89
+ User.where { :name.start_with?('ma', 'no') }.to_sql)
90
+ end
91
+
92
+ def test_end_with_multiple
93
+ assert_sql(
94
+ /WHERE \("users"."name" LIKE '%z' ESCAPE '\\' OR "users"."name" LIKE '%love' ESCAPE '\\'\)/,
95
+ User.where { :name.end_with?('z', 'love') }.to_sql)
96
+ end
97
+
98
+ # The OR arrives grouped, so a following & applies to the whole list.
99
+ def test_start_with_multiple_combined
100
+ assert_sql(
101
+ /WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\) AND "users"."age" > 18/,
102
+ User.where { :name.start_with?('ma', 'no') & (:age > 18) }.to_sql)
103
+ end
104
+
105
+ def test_start_with_no_arguments
106
+ assert_raises(ArgumentError) { User.where { :name.start_with? } }
107
+ end
108
+
109
+ def test_end_with_no_arguments
110
+ assert_raises(ArgumentError) { User.where { :name.end_with? } }
111
+ end
112
+
84
113
  def test_start_with_escapes_wildcards
85
114
  assert_sql(/WHERE "users"."name" LIKE '100\\%\\_%' ESCAPE '\\'/,
86
115
  User.where { :name.start_with?('100%_') }.to_sql)
@@ -91,6 +120,56 @@ class TestBlockSyntax < Minitest::Test
91
120
  User.where { :name.include?('100%') }.to_sql)
92
121
  end
93
122
 
123
+ def test_member
124
+ assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
125
+ User.where { :tags.member?('ruby') }.to_sql)
126
+ end
127
+
128
+ def test_member_qualified
129
+ assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
130
+ User.where { :users[:tags].member?('ruby') }.to_sql)
131
+ end
132
+
133
+ def test_member_negated
134
+ assert_sql(/WHERE NOT \("users"."tags" @> '\{ruby\}'\)/,
135
+ User.where { !:tags.member?('ruby') }.to_sql)
136
+ end
137
+
138
+ def test_member_multiple_elements
139
+ assert_sql(/WHERE "users"."tags" @> '\{ruby,rails\}'/,
140
+ User.where { :tags.member?(%w[ruby rails]) }.to_sql)
141
+ end
142
+
143
+ # MySQL additionally escapes the double quotes inside its string literal,
144
+ # so the exact spelling is only asserted where the operator is real.
145
+ def test_member_quotes_special_elements
146
+ skip_without_array_columns
147
+ assert_sql(/WHERE "users"."tags" @> '\{"with,comma"\}'/,
148
+ User.where { :tags.member?('with,comma') }.to_sql)
149
+ end
150
+
151
+ # include? is a substring match even on an array column; only member?
152
+ # means containment.
153
+ def test_include_is_like_even_on_array_columns
154
+ skip_without_array_columns
155
+ assert_sql(/WHERE "users"."tags" LIKE '%ruby%' ESCAPE '\\'/,
156
+ User.where { :tags.include?('ruby') }.to_sql)
157
+ end
158
+
159
+ # Elements survive the trip through the array literal: % is an ordinary
160
+ # character there, a comma stays inside its element, and quotes and
161
+ # backslashes are escaped.
162
+ def test_member_matches_elements_literally
163
+ skip_without_array_columns
164
+ User.delete_all
165
+ User.create!(name: 'literal', tags: ['100%', 'with,comma', 'q"uote', 'back\\slash'])
166
+ User.create!(name: 'lookalike', tags: ['100200', 'with', 'comma'])
167
+ assert_equal(['literal'], User.where { :tags.member?('100%') }.pluck(:name))
168
+ assert_equal(['literal'], User.where { :tags.member?('with,comma') }.pluck(:name))
169
+ assert_equal(['literal'], User.where { :tags.member?('q"uote') }.pluck(:name))
170
+ assert_equal(['literal'], User.where { :tags.member?('back\\slash') }.pluck(:name))
171
+ end
172
+
94
173
  def test_regexp
95
174
  skip_without_regexp_support
96
175
  assert_sql(/WHERE "users"."name" #{regexp_operator} '\^ma'/,
@@ -161,6 +240,16 @@ class TestBlockSyntax < Minitest::Test
161
240
  User.where { :users[:name].null? }.to_sql)
162
241
  end
163
242
 
243
+ def test_equal_nil_is_rejected
244
+ e = assert_raises(ArgumentError) { User.where { :name == nil } }
245
+ assert_match(/null\?/, e.message)
246
+ end
247
+
248
+ def test_not_equal_nil_is_rejected
249
+ e = assert_raises(ArgumentError) { User.where { :name != nil } }
250
+ assert_match(/null\?/, e.message)
251
+ end
252
+
164
253
  def test_in
165
254
  assert_sql(/WHERE "users"."age" IN \(1, 2, 3\)/,
166
255
  User.where { :age.in?([1, 2, 3]) }.to_sql)
@@ -176,6 +265,70 @@ class TestBlockSyntax < Minitest::Test
176
265
  User.where { !:age.in?([1, 2, 3]) }.to_sql)
177
266
  end
178
267
 
268
+ def test_in_subquery
269
+ assert_sql(
270
+ /WHERE "authors"."id" IN \(SELECT "posts"."author_id" FROM "posts" WHERE "posts"."title" = 'pub'\)/,
271
+ Author.where { :id.in?(Post.where(title: 'pub').select(:author_id)) }.to_sql)
272
+ end
273
+
274
+ # A relation without an explicit select list selects its primary key, the
275
+ # same way ActiveRecord's own where(id: relation) does.
276
+ def test_in_subquery_selects_primary_key_by_default
277
+ assert_sql(/WHERE "authors"."id" IN \(SELECT "posts"."id" FROM "posts"\)/,
278
+ Author.where { :id.in?(Post.all) }.to_sql)
279
+ end
280
+
281
+ def test_not_in_subquery
282
+ assert_sql(/WHERE NOT \("authors"."id" IN \(SELECT "posts"."author_id" FROM "posts"\)\)/,
283
+ Author.where { !:id.in?(Post.select(:author_id)) }.to_sql)
284
+ end
285
+
286
+ # The subquery correlates with the outer table through qualified columns,
287
+ # and its own where block goes through the DSL too.
288
+ def test_exists
289
+ assert_sql(
290
+ /WHERE EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."author_id" = "authors"."id"\)/,
291
+ Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.to_sql)
292
+ end
293
+
294
+ def test_not_exists
295
+ assert_sql(/WHERE NOT \(EXISTS \(SELECT "posts"\.\* FROM "posts"\)\)/,
296
+ Author.where { !exists?(Post.all) }.to_sql)
297
+ end
298
+
299
+ def test_exists_combined
300
+ assert_sql(
301
+ /WHERE "authors"."name" = 'matz' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
302
+ Author.where { (:name == 'matz') & exists?(Post.where(title: 'pub')) }.to_sql)
303
+ end
304
+
305
+ def test_exists_execution
306
+ Author.delete_all
307
+ Post.delete_all
308
+ with_post = Author.create!(name: 'with_post')
309
+ Author.create!(name: 'without')
310
+ Post.create!(title: 'pub', author_id: with_post.id)
311
+ correlated = -> { Post.where { :posts[:author_id] == :authors[:id] } }
312
+ assert_equal(['with_post'],
313
+ Author.where { exists?(correlated.call) }.pluck(:name))
314
+ assert_equal(['without'],
315
+ Author.where { !exists?(correlated.call) }.pluck(:name))
316
+ end
317
+
318
+ def test_in_subquery_execution
319
+ Author.delete_all
320
+ Post.delete_all
321
+ published = Author.create!(name: 'published')
322
+ drafting = Author.create!(name: 'drafting')
323
+ Post.create!(title: 'pub', author_id: published.id)
324
+ Post.create!(title: 'draft', author_id: drafting.id)
325
+ subquery = -> { Post.where(title: 'pub').select(:author_id) }
326
+ assert_equal(['published'],
327
+ Author.where { :id.in?(subquery.call) }.pluck(:name))
328
+ assert_equal(['drafting'],
329
+ Author.where { !:id.in?(subquery.call) }.pluck(:name))
330
+ end
331
+
179
332
  # == passes a Range or an Array through as a value rather than expanding it,
180
333
  # so that it compares against a PostgreSQL range or array column. The SQL
181
334
  # literal depends on the column type, so assert on the Arel node instead.
data/test/test_helper.rb CHANGED
@@ -62,6 +62,10 @@ module SqlAssertions
62
62
  skip "#{ADAPTER} has no regexp operator" unless REGEXP_OPERATORS.key?(ADAPTER)
63
63
  end
64
64
 
65
+ def skip_without_array_columns
66
+ skip "#{ADAPTER} has no array columns" unless ADAPTER == 'postgresql'
67
+ end
68
+
65
69
  def regexp_operator
66
70
  Regexp.escape(REGEXP_OPERATORS.fetch(ADAPTER).first)
67
71
  end
@@ -105,7 +109,11 @@ class CreateAllTables < ActiveRecord::Migration[8.1]
105
109
  drop_table(:users, if_exists: true)
106
110
  drop_table(:authors, if_exists: true)
107
111
  drop_table(:posts, if_exists: true)
108
- create_table(:users) {|t| t.string :name; t.integer :age}
112
+ create_table(:users) do |t|
113
+ t.string :name
114
+ t.integer :age
115
+ t.string :tags, array: true if ADAPTER == 'postgresql'
116
+ end
109
117
  create_table(:authors) {|t| t.string :name}
110
118
  create_table(:posts) {|t| t.string :title; t.integer :author_id}
111
119
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-refined
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugo Maeda