activerecord-refined 0.3.2 → 0.4.0
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 +4 -4
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +260 -21
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +335 -20
- data/lib/active_record/refined.rb +119 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +579 -0
- data/test/test_helper.rb +21 -1
- metadata +7 -1
data/test/test_block_syntax.rb
CHANGED
|
@@ -61,6 +61,36 @@ class TestBlockSyntax < Minitest::Test
|
|
|
61
61
|
User.where { :users[:name].like?('tender%') }.to_sql)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# ILIKE is PostgreSQL's; elsewhere Arel emits LIKE, which those adapters
|
|
65
|
+
# already match case-insensitively by default.
|
|
66
|
+
def test_ilike
|
|
67
|
+
expected = ADAPTER == 'postgresql' ? 'ILIKE' : 'LIKE'
|
|
68
|
+
assert_sql(/WHERE "users"."name" #{expected} 'ma%'/,
|
|
69
|
+
User.where { :name.ilike?('ma%') }.to_sql)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_casecmp
|
|
73
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Matz'\)/,
|
|
74
|
+
User.where { :name.casecmp?('Matz') }.to_sql)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_casecmp_qualified
|
|
78
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Matz'\)/,
|
|
79
|
+
User.where { :users[:name].casecmp?('Matz') }.to_sql)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_casecmp_nil_is_rejected
|
|
83
|
+
e = assert_raises(ArgumentError) { User.where { :name.casecmp?(nil) } }
|
|
84
|
+
assert_match(/null\?/, e.message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_casecmp_execution
|
|
88
|
+
User.delete_all
|
|
89
|
+
User.create!(name: 'Matz')
|
|
90
|
+
User.create!(name: 'nobu')
|
|
91
|
+
assert_equal(['Matz'], User.where { :name.casecmp?('mAtZ') }.pluck(:name))
|
|
92
|
+
end
|
|
93
|
+
|
|
64
94
|
def test_not_like
|
|
65
95
|
assert_sql(/WHERE NOT \("users"."name" LIKE 'tender%'\)/,
|
|
66
96
|
User.where { !:name.like?('tender%') }.to_sql)
|
|
@@ -81,6 +111,35 @@ class TestBlockSyntax < Minitest::Test
|
|
|
81
111
|
User.where { :name.include?('der') }.to_sql)
|
|
82
112
|
end
|
|
83
113
|
|
|
114
|
+
# Like their String namesakes, start_with? and end_with? take any number
|
|
115
|
+
# of literals; matching any one of them is enough.
|
|
116
|
+
def test_start_with_multiple
|
|
117
|
+
assert_sql(
|
|
118
|
+
/WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\)/,
|
|
119
|
+
User.where { :name.start_with?('ma', 'no') }.to_sql)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_end_with_multiple
|
|
123
|
+
assert_sql(
|
|
124
|
+
/WHERE \("users"."name" LIKE '%z' ESCAPE '\\' OR "users"."name" LIKE '%love' ESCAPE '\\'\)/,
|
|
125
|
+
User.where { :name.end_with?('z', 'love') }.to_sql)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The OR arrives grouped, so a following & applies to the whole list.
|
|
129
|
+
def test_start_with_multiple_combined
|
|
130
|
+
assert_sql(
|
|
131
|
+
/WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\) AND "users"."age" > 18/,
|
|
132
|
+
User.where { :name.start_with?('ma', 'no') & (:age > 18) }.to_sql)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_start_with_no_arguments
|
|
136
|
+
assert_raises(ArgumentError) { User.where { :name.start_with? } }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_end_with_no_arguments
|
|
140
|
+
assert_raises(ArgumentError) { User.where { :name.end_with? } }
|
|
141
|
+
end
|
|
142
|
+
|
|
84
143
|
def test_start_with_escapes_wildcards
|
|
85
144
|
assert_sql(/WHERE "users"."name" LIKE '100\\%\\_%' ESCAPE '\\'/,
|
|
86
145
|
User.where { :name.start_with?('100%_') }.to_sql)
|
|
@@ -91,6 +150,99 @@ class TestBlockSyntax < Minitest::Test
|
|
|
91
150
|
User.where { :name.include?('100%') }.to_sql)
|
|
92
151
|
end
|
|
93
152
|
|
|
153
|
+
def test_member
|
|
154
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
|
|
155
|
+
User.where { :tags.member?('ruby') }.to_sql)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_member_qualified
|
|
159
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
|
|
160
|
+
User.where { :users[:tags].member?('ruby') }.to_sql)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_member_negated
|
|
164
|
+
assert_sql(/WHERE NOT \("users"."tags" @> '\{ruby\}'\)/,
|
|
165
|
+
User.where { !:tags.member?('ruby') }.to_sql)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Ruby's [1, 2].member?([1]) is false: member? tests one element, and an
|
|
169
|
+
# Array argument would have to mean something the namesake does not.
|
|
170
|
+
def test_member_array_is_rejected
|
|
171
|
+
e = assert_raises(ArgumentError) { User.where { :tags.member?(%w[ruby rails]) } }
|
|
172
|
+
assert_match(/superset\?/, e.message)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def test_superset
|
|
176
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby,rails\}'/,
|
|
177
|
+
User.where { :tags.superset?(%w[ruby rails]) }.to_sql)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def test_superset_takes_a_set
|
|
181
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby,rails\}'/,
|
|
182
|
+
User.where { :tags.superset?(Set['ruby', 'rails']) }.to_sql)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def test_superset_rejects_a_scalar
|
|
186
|
+
assert_raises(ArgumentError) { User.where { :tags.superset?('ruby') } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_subset
|
|
190
|
+
assert_sql(/WHERE "users"."tags" <@ '\{ruby,rails,go\}'/,
|
|
191
|
+
User.where { :tags.subset?(%w[ruby rails go]) }.to_sql)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def test_intersect
|
|
195
|
+
assert_sql(/WHERE "users"."tags" && '\{ruby,go\}'/,
|
|
196
|
+
User.where { :tags.intersect?(%w[ruby go]) }.to_sql)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_intersect_negated
|
|
200
|
+
assert_sql(/WHERE NOT \("users"."tags" && '\{ruby,go\}'\)/,
|
|
201
|
+
User.where { !:tags.intersect?(%w[ruby go]) }.to_sql)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_array_comparisons_execution
|
|
205
|
+
skip_without_array_columns
|
|
206
|
+
User.delete_all
|
|
207
|
+
User.create!(name: 'both', tags: %w[ruby rails])
|
|
208
|
+
User.create!(name: 'one', tags: %w[ruby go])
|
|
209
|
+
User.create!(name: 'neither', tags: %w[python])
|
|
210
|
+
assert_equal(['both'], User.where { :tags.superset?(%w[ruby rails]) }.pluck(:name))
|
|
211
|
+
assert_equal(['neither'], User.where { :tags.subset?(%w[python js]) }.pluck(:name))
|
|
212
|
+
assert_equal(%w[both one],
|
|
213
|
+
User.where { :tags.intersect?(%w[ruby js]) }.pluck(:name).sort)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# MySQL additionally escapes the double quotes inside its string literal,
|
|
217
|
+
# so the exact spelling is only asserted where the operator is real.
|
|
218
|
+
def test_member_quotes_special_elements
|
|
219
|
+
skip_without_array_columns
|
|
220
|
+
assert_sql(/WHERE "users"."tags" @> '\{"with,comma"\}'/,
|
|
221
|
+
User.where { :tags.member?('with,comma') }.to_sql)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# include? is a substring match even on an array column; only member?
|
|
225
|
+
# means containment.
|
|
226
|
+
def test_include_is_like_even_on_array_columns
|
|
227
|
+
skip_without_array_columns
|
|
228
|
+
assert_sql(/WHERE "users"."tags" LIKE '%ruby%' ESCAPE '\\'/,
|
|
229
|
+
User.where { :tags.include?('ruby') }.to_sql)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Elements survive the trip through the array literal: % is an ordinary
|
|
233
|
+
# character there, a comma stays inside its element, and quotes and
|
|
234
|
+
# backslashes are escaped.
|
|
235
|
+
def test_member_matches_elements_literally
|
|
236
|
+
skip_without_array_columns
|
|
237
|
+
User.delete_all
|
|
238
|
+
User.create!(name: 'literal', tags: ['100%', 'with,comma', 'q"uote', 'back\\slash'])
|
|
239
|
+
User.create!(name: 'lookalike', tags: ['100200', 'with', 'comma'])
|
|
240
|
+
assert_equal(['literal'], User.where { :tags.member?('100%') }.pluck(:name))
|
|
241
|
+
assert_equal(['literal'], User.where { :tags.member?('with,comma') }.pluck(:name))
|
|
242
|
+
assert_equal(['literal'], User.where { :tags.member?('q"uote') }.pluck(:name))
|
|
243
|
+
assert_equal(['literal'], User.where { :tags.member?('back\\slash') }.pluck(:name))
|
|
244
|
+
end
|
|
245
|
+
|
|
94
246
|
def test_regexp
|
|
95
247
|
skip_without_regexp_support
|
|
96
248
|
assert_sql(/WHERE "users"."name" #{regexp_operator} '\^ma'/,
|
|
@@ -161,6 +313,16 @@ class TestBlockSyntax < Minitest::Test
|
|
|
161
313
|
User.where { :users[:name].null? }.to_sql)
|
|
162
314
|
end
|
|
163
315
|
|
|
316
|
+
def test_equal_nil_is_rejected
|
|
317
|
+
e = assert_raises(ArgumentError) { User.where { :name == nil } }
|
|
318
|
+
assert_match(/null\?/, e.message)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def test_not_equal_nil_is_rejected
|
|
322
|
+
e = assert_raises(ArgumentError) { User.where { :name != nil } }
|
|
323
|
+
assert_match(/null\?/, e.message)
|
|
324
|
+
end
|
|
325
|
+
|
|
164
326
|
def test_in
|
|
165
327
|
assert_sql(/WHERE "users"."age" IN \(1, 2, 3\)/,
|
|
166
328
|
User.where { :age.in?([1, 2, 3]) }.to_sql)
|
|
@@ -176,6 +338,120 @@ class TestBlockSyntax < Minitest::Test
|
|
|
176
338
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
177
339
|
end
|
|
178
340
|
|
|
341
|
+
# Spelled IS [NOT] DISTINCT FROM on PostgreSQL, IS / IS NOT on SQLite and
|
|
342
|
+
# <=> on MySQL, so only the resulting rows are portable.
|
|
343
|
+
def test_not_distinct_from_execution
|
|
344
|
+
User.delete_all
|
|
345
|
+
User.create!(name: 'named')
|
|
346
|
+
User.create!(name: nil)
|
|
347
|
+
assert_equal([nil], User.where { :name.not_distinct_from?(nil) }.pluck(:name))
|
|
348
|
+
assert_equal(['named'], User.where { :name.distinct_from?(nil) }.pluck(:name))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def test_not_distinct_from_a_value_execution
|
|
352
|
+
User.delete_all
|
|
353
|
+
User.create!(name: 'matz')
|
|
354
|
+
User.create!(name: nil)
|
|
355
|
+
assert_equal(['matz'], User.where { :name.not_distinct_from?('matz') }.pluck(:name))
|
|
356
|
+
# Unlike !=, this keeps the NULL row.
|
|
357
|
+
assert_equal([nil], User.where { :name.distinct_from?('matz') }.pluck(:name))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def test_distinct_from_postgresql_syntax
|
|
361
|
+
skip "#{ADAPTER} spells it differently" unless ADAPTER == 'postgresql'
|
|
362
|
+
assert_sql(/WHERE "users"."name" IS NOT DISTINCT FROM 'x'/,
|
|
363
|
+
User.where { :name.not_distinct_from?('x') }.to_sql)
|
|
364
|
+
assert_sql(/WHERE "users"."name" IS DISTINCT FROM 'x'/,
|
|
365
|
+
User.where { :name.distinct_from?('x') }.to_sql)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def test_comparison_with_scalar_subquery
|
|
369
|
+
assert_sql(/WHERE "users"."age" >= \(SELECT AVG\("users"."age"\) FROM "users"\)/,
|
|
370
|
+
User.where { :age >= User.select { avg(:age) } }.to_sql)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def test_equality_with_scalar_subquery
|
|
374
|
+
assert_sql(/WHERE "users"."age" = \(SELECT MAX\("users"."age"\) FROM "users"\)/,
|
|
375
|
+
User.where { :age == User.select { max(:age) } }.to_sql)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# A scalar comparison has no sensible default select list, unlike in?.
|
|
379
|
+
def test_scalar_subquery_without_select_is_rejected
|
|
380
|
+
e = assert_raises(ArgumentError) { User.where { :age >= User.all } }
|
|
381
|
+
assert_match(/select/, e.message)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def test_scalar_subquery_execution
|
|
385
|
+
User.delete_all
|
|
386
|
+
User.create!(name: 'young', age: 20)
|
|
387
|
+
User.create!(name: 'old', age: 60)
|
|
388
|
+
assert_equal(['old'], User.where { :age >= User.select { avg(:age) } }.pluck(:name))
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def test_in_subquery
|
|
392
|
+
assert_sql(
|
|
393
|
+
/WHERE "authors"."id" IN \(SELECT "posts"."author_id" FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
394
|
+
Author.where { :id.in?(Post.where(title: 'pub').select(:author_id)) }.to_sql)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# A relation without an explicit select list selects its primary key, the
|
|
398
|
+
# same way ActiveRecord's own where(id: relation) does.
|
|
399
|
+
def test_in_subquery_selects_primary_key_by_default
|
|
400
|
+
assert_sql(/WHERE "authors"."id" IN \(SELECT "posts"."id" FROM "posts"\)/,
|
|
401
|
+
Author.where { :id.in?(Post.all) }.to_sql)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def test_not_in_subquery
|
|
405
|
+
assert_sql(/WHERE NOT \("authors"."id" IN \(SELECT "posts"."author_id" FROM "posts"\)\)/,
|
|
406
|
+
Author.where { !:id.in?(Post.select(:author_id)) }.to_sql)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The subquery correlates with the outer table through qualified columns,
|
|
410
|
+
# and its own where block goes through the DSL too.
|
|
411
|
+
def test_exists
|
|
412
|
+
assert_sql(
|
|
413
|
+
/WHERE EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."author_id" = "authors"."id"\)/,
|
|
414
|
+
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.to_sql)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def test_not_exists
|
|
418
|
+
assert_sql(/WHERE NOT \(EXISTS \(SELECT "posts"\.\* FROM "posts"\)\)/,
|
|
419
|
+
Author.where { !exists?(Post.all) }.to_sql)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def test_exists_combined
|
|
423
|
+
assert_sql(
|
|
424
|
+
/WHERE "authors"."name" = 'matz' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
425
|
+
Author.where { (:name == 'matz') & exists?(Post.where(title: 'pub')) }.to_sql)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def test_exists_execution
|
|
429
|
+
Author.delete_all
|
|
430
|
+
Post.delete_all
|
|
431
|
+
with_post = Author.create!(name: 'with_post')
|
|
432
|
+
Author.create!(name: 'without')
|
|
433
|
+
Post.create!(title: 'pub', author_id: with_post.id)
|
|
434
|
+
correlated = -> { Post.where { :posts[:author_id] == :authors[:id] } }
|
|
435
|
+
assert_equal(['with_post'],
|
|
436
|
+
Author.where { exists?(correlated.call) }.pluck(:name))
|
|
437
|
+
assert_equal(['without'],
|
|
438
|
+
Author.where { !exists?(correlated.call) }.pluck(:name))
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def test_in_subquery_execution
|
|
442
|
+
Author.delete_all
|
|
443
|
+
Post.delete_all
|
|
444
|
+
published = Author.create!(name: 'published')
|
|
445
|
+
drafting = Author.create!(name: 'drafting')
|
|
446
|
+
Post.create!(title: 'pub', author_id: published.id)
|
|
447
|
+
Post.create!(title: 'draft', author_id: drafting.id)
|
|
448
|
+
subquery = -> { Post.where(title: 'pub').select(:author_id) }
|
|
449
|
+
assert_equal(['published'],
|
|
450
|
+
Author.where { :id.in?(subquery.call) }.pluck(:name))
|
|
451
|
+
assert_equal(['drafting'],
|
|
452
|
+
Author.where { !:id.in?(subquery.call) }.pluck(:name))
|
|
453
|
+
end
|
|
454
|
+
|
|
179
455
|
# == passes a Range or an Array through as a value rather than expanding it,
|
|
180
456
|
# so that it compares against a PostgreSQL range or array column. The SQL
|
|
181
457
|
# literal depends on the column type, so assert on the Arel node instead.
|
|
@@ -220,6 +496,84 @@ class TestBlockSyntax < Minitest::Test
|
|
|
220
496
|
assert_sql(/LEFT OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id"/, sql)
|
|
221
497
|
end
|
|
222
498
|
|
|
499
|
+
# The alias is what the block's qualified columns name, which is what makes
|
|
500
|
+
# a self join expressible at all. Adapters differ on writing the AS
|
|
501
|
+
# keyword, so the assertions allow either.
|
|
502
|
+
def test_joins_with_alias
|
|
503
|
+
assert_sql(
|
|
504
|
+
/INNER JOIN "authors" (?:AS )?"mentors" ON "mentors"."id" = "authors"."id"/,
|
|
505
|
+
Author.joins(:authors, as: :mentors) { :mentors[:id] == :authors[:id] }.to_sql)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def test_left_outer_joins_with_alias
|
|
509
|
+
assert_sql(
|
|
510
|
+
/LEFT OUTER JOIN "authors" (?:AS )?"mentors" ON "mentors"."id" = "authors"."id"/,
|
|
511
|
+
Author.left_outer_joins(:authors, as: :mentors) { :mentors[:id] == :authors[:id] }.to_sql)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def test_joins_alias_needs_a_block
|
|
515
|
+
assert_raises(ArgumentError) { Author.joins(:posts, as: :p) }
|
|
516
|
+
assert_raises(ArgumentError) { Author.left_outer_joins(:posts, as: :p) }
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def test_joins_without_alias_still_delegates
|
|
520
|
+
assert_sql(/INNER JOIN "posts" ON "posts"."author_id" = "authors"."id"/,
|
|
521
|
+
Author.joins(:posts).to_sql)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def test_self_join_execution
|
|
525
|
+
Author.delete_all
|
|
526
|
+
Author.create!(name: 'shared')
|
|
527
|
+
Author.create!(name: 'other')
|
|
528
|
+
assert_equal(%w[other shared],
|
|
529
|
+
Author.joins(:authors, as: :mentors) { :mentors[:name] == :authors[:name] }.
|
|
530
|
+
pluck(:name).sort)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# ActiveRecord's from only takes a table name as a string.
|
|
534
|
+
def test_from_symbol
|
|
535
|
+
assert_sql(/FROM "tree"/, Node.from(:tree).to_sql)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def test_from_symbol_with_alias
|
|
539
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/, Node.from(:tree, as: :nodes).to_sql)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def test_from_string_still_delegates
|
|
543
|
+
assert_sql(/FROM subq/, Node.from('subq').to_sql)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def test_from_alias_needs_a_symbol
|
|
547
|
+
assert_raises(ArgumentError) { Node.from('tree', as: :nodes) }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# A CTE is joined by name like any other table, so the recursive member's
|
|
551
|
+
# ON clause is a block rather than the string join Rails' own docs use.
|
|
552
|
+
def test_recursive_cte
|
|
553
|
+
Node.delete_all
|
|
554
|
+
root = Node.create!(name: 'root')
|
|
555
|
+
child = Node.create!(name: 'child', parent_id: root.id)
|
|
556
|
+
Node.create!(name: 'grandchild', parent_id: child.id)
|
|
557
|
+
Node.create!(name: 'unrelated', parent_id: nil)
|
|
558
|
+
descendants = Node.with_recursive(
|
|
559
|
+
tree: [
|
|
560
|
+
Node.where { :id == root.id },
|
|
561
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
|
|
562
|
+
]
|
|
563
|
+
).from(:tree, as: :nodes)
|
|
564
|
+
assert_equal(%w[child grandchild root], descendants.pluck(:name).sort)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def test_cte_joined_by_name
|
|
568
|
+
Node.delete_all
|
|
569
|
+
root = Node.create!(name: 'root')
|
|
570
|
+
Node.create!(name: 'child', parent_id: root.id)
|
|
571
|
+
Node.create!(name: 'orphan', parent_id: nil)
|
|
572
|
+
q = Node.with(roots: Node.where { :parent_id.null? }).
|
|
573
|
+
joins(:roots) { :roots[:id] == :nodes[:parent_id] }
|
|
574
|
+
assert_equal(['child'], q.pluck(:name))
|
|
575
|
+
end
|
|
576
|
+
|
|
223
577
|
def test_select_aggregate
|
|
224
578
|
assert_sql(/SELECT SUM\("users"."age"\)/,
|
|
225
579
|
User.select { :age.sum }.to_sql)
|
|
@@ -271,6 +625,33 @@ class TestBlockSyntax < Minitest::Test
|
|
|
271
625
|
User.select { count(:*).as(:cnt) }.to_sql)
|
|
272
626
|
end
|
|
273
627
|
|
|
628
|
+
def test_count_distinct
|
|
629
|
+
assert_sql(/SELECT COUNT\(DISTINCT "users"."name"\)/,
|
|
630
|
+
User.select { count(:name, distinct: true) }.to_sql)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def test_count_distinct_as_method
|
|
634
|
+
assert_sql(/SELECT COUNT\(DISTINCT "users"."name"\) AS n/,
|
|
635
|
+
User.select { :name.count(distinct: true).as(:n) }.to_sql)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def test_count_distinct_in_having
|
|
639
|
+
assert_sql(/HAVING COUNT\(DISTINCT "users"."name"\) > 1/,
|
|
640
|
+
User.group(:age).having { count(:name, distinct: true) > 1 }.to_sql)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# DISTINCT is Arel's only aggregate modifier, and COUNT(DISTINCT *) is not
|
|
644
|
+
# valid SQL.
|
|
645
|
+
def test_distinct_is_rejected_for_other_aggregates
|
|
646
|
+
assert_raises(ArgumentError) do
|
|
647
|
+
ActiveRecord::Refined::AST::Aggregate.new(:age, :sum, distinct: true)
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def test_count_star_distinct_is_rejected
|
|
652
|
+
assert_raises(ArgumentError) { User.select { count(:*, distinct: true) } }
|
|
653
|
+
end
|
|
654
|
+
|
|
274
655
|
def test_having_count_star
|
|
275
656
|
sql = Author.joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
276
657
|
group { :authors[:id] }.
|
|
@@ -329,6 +710,180 @@ class TestBlockSyntax < Minitest::Test
|
|
|
329
710
|
User.where { length(:name) > 3 }.to_sql)
|
|
330
711
|
end
|
|
331
712
|
|
|
713
|
+
# fn emits the name as written, so a case-sensitive one can be spelled
|
|
714
|
+
# exactly.
|
|
715
|
+
def test_fn
|
|
716
|
+
assert_sql(/SELECT date_trunc\('day', "users"."name"\)/,
|
|
717
|
+
User.select { fn(:date_trunc, 'day', :name) }.to_sql)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def test_fn_is_comparable
|
|
721
|
+
assert_sql(/WHERE char_length\("users"."name"\) > 3/,
|
|
722
|
+
User.where { fn(:char_length, :name) > 3 }.to_sql)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def test_fn_alias
|
|
726
|
+
assert_sql(/SELECT date_trunc\('day', "users"."name"\) AS d/,
|
|
727
|
+
User.select { fn(:date_trunc, 'day', :name).as(:d) }.to_sql)
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# Aliases and function names are written into the SQL where a value would
|
|
731
|
+
# have been quoted, so a name that is not plain is refused rather than
|
|
732
|
+
# given the chance to close the identifier and carry on.
|
|
733
|
+
INJECTION = %q{a" AS x, (SELECT 1) AS "y}
|
|
734
|
+
|
|
735
|
+
def test_alias_rejects_an_injected_name
|
|
736
|
+
assert_raises(ArgumentError) { User.select { :name.as(INJECTION.to_sym) } }
|
|
737
|
+
assert_raises(ArgumentError) { User.select { count(:*).as(INJECTION.to_sym) } }
|
|
738
|
+
assert_raises(ArgumentError) { User.select { :name.as(:'a; DROP TABLE users') } }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def test_fn_rejects_an_injected_name
|
|
742
|
+
assert_raises(ArgumentError) { User.select { fn(INJECTION.to_sym, :name) } }
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def test_plain_names_are_still_accepted
|
|
746
|
+
assert_sql(/AS post_count/, User.select { :name.as(:post_count) }.to_sql)
|
|
747
|
+
assert_sql(/AS 名前/, User.select { :name.as(:名前) }.to_sql)
|
|
748
|
+
assert_sql(/SELECT myFunc\(/, User.select { fn(:myFunc, :name) }.to_sql)
|
|
749
|
+
assert_sql(/SELECT pg_catalog.upper\(/,
|
|
750
|
+
User.select { fn(:'pg_catalog.upper', :name) }.to_sql)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Values go through the adapter's quoting, which each spells its own way,
|
|
754
|
+
# so what is asserted is that the payload stays a value: it matches no row
|
|
755
|
+
# rather than opening the condition up.
|
|
756
|
+
def test_values_are_quoted
|
|
757
|
+
User.delete_all
|
|
758
|
+
User.create!(name: 'matz')
|
|
759
|
+
User.create!(name: 'nobu')
|
|
760
|
+
payload = "x' OR 1=1 --"
|
|
761
|
+
assert_empty(User.where { :name == payload }.pluck(:name))
|
|
762
|
+
assert_empty(User.where { :name.like?(payload) }.pluck(:name))
|
|
763
|
+
assert_empty(User.where { :name.in?([payload]) }.pluck(:name))
|
|
764
|
+
assert_empty(User.where { :name.include?(payload) }.pluck(:name))
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Likewise for column names: the payload becomes one identifier, so the
|
|
768
|
+
# database rejects it as an unknown column instead of running it.
|
|
769
|
+
def test_column_names_are_quoted
|
|
770
|
+
assert_raises(ActiveRecord::StatementInvalid) do
|
|
771
|
+
User.where { :users[INJECTION.to_sym] == 1 }.to_a
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def test_scalar_functions_shared_by_every_adapter
|
|
776
|
+
assert_sql(/SELECT CONCAT\(UPPER\("users"."name"\), 'x'\)/,
|
|
777
|
+
User.select { concat(upper(:name), 'x') }.to_sql)
|
|
778
|
+
assert_sql(/WHERE MOD\("users"."age", 7\) = 0/,
|
|
779
|
+
User.where { mod(:age, 7) == 0 }.to_sql)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# SQLite has no CHAR_LENGTH, GREATEST or LEAST, but LENGTH, MAX and MIN
|
|
783
|
+
# mean the same thing there.
|
|
784
|
+
def test_scalar_functions_spelled_differently_on_sqlite
|
|
785
|
+
expected = ADAPTER == 'sqlite3' ? %w[LENGTH MAX MIN] : %w[CHAR_LENGTH GREATEST LEAST]
|
|
786
|
+
assert_sql(/SELECT #{expected[0]}\("users"."name"\)/,
|
|
787
|
+
User.select { char_length(:name) }.to_sql)
|
|
788
|
+
assert_sql(/SELECT #{expected[1]}\("users"."age", 18\)/,
|
|
789
|
+
User.select { greatest(:age, 18) }.to_sql)
|
|
790
|
+
assert_sql(/SELECT #{expected[2]}\("users"."age", 99\)/,
|
|
791
|
+
User.select { least(:age, 99) }.to_sql)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def test_scalar_functions_run
|
|
795
|
+
User.delete_all
|
|
796
|
+
User.create!(name: 'matz', age: 60)
|
|
797
|
+
assert_equal(['MATZ-x'], User.select { concat(upper(:name), '-x').as(:v) }.map(&:v))
|
|
798
|
+
assert_equal([4], User.select { char_length(:name).as(:v) }.map(&:v))
|
|
799
|
+
assert_equal([60], User.select { greatest(:age, 18).as(:v) }.map(&:v))
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# rand takes the name back from Kernel#rand, which would otherwise answer
|
|
803
|
+
# inside the block and never reach the database.
|
|
804
|
+
def test_rand
|
|
805
|
+
expected = ADAPTER == 'mysql2' ? 'RAND' : 'RANDOM'
|
|
806
|
+
assert_sql(/ORDER BY #{expected}\(\)/, User.order { rand }.to_sql)
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Where an adapter has no equivalent, the block raises instead of leaving
|
|
810
|
+
# the database to reject the SQL.
|
|
811
|
+
def test_unsupported_function_raises
|
|
812
|
+
if ADAPTER == 'postgresql'
|
|
813
|
+
assert_sql(/SELECT DATE_TRUNC\('day', "users"."name"\)/,
|
|
814
|
+
User.select { date_trunc('day', :name) }.to_sql)
|
|
815
|
+
else
|
|
816
|
+
e = assert_raises(NotImplementedError) { User.select { date_trunc('day', :name) } }
|
|
817
|
+
assert_match(/date_trunc/, e.message)
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# MySQL's FORMAT is a different function that happens to share the name,
|
|
822
|
+
# and reads a printf template as the number zero rather than complaining,
|
|
823
|
+
# so the name carries the printf one and MySQL raises.
|
|
824
|
+
def test_format_is_printf_and_unsupported_on_mysql
|
|
825
|
+
if ADAPTER == 'mysql2'
|
|
826
|
+
assert_raises(NotImplementedError) { User.select { format('%s!', :name) } }
|
|
827
|
+
else
|
|
828
|
+
User.delete_all
|
|
829
|
+
User.create!(name: 'matz')
|
|
830
|
+
assert_equal(['matz!'], User.select { format('%s!', :name).as(:v) }.map(&:v))
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# MySQL's own is still reachable, spelled as the different thing it is.
|
|
835
|
+
def test_mysql_format_through_fn
|
|
836
|
+
assert_sql(/SELECT format\(1234.5678, 2\)/,
|
|
837
|
+
User.select { fn(:format, 1234.5678, 2) }.to_sql)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def test_now_is_unsupported_on_sqlite
|
|
841
|
+
if ADAPTER == 'sqlite3'
|
|
842
|
+
assert_raises(NotImplementedError) { User.select { now } }
|
|
843
|
+
else
|
|
844
|
+
assert_sql(/SELECT NOW\(\)/, User.select { now }.to_sql)
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# A name with no method of its own is still a NoMethodError, not a
|
|
849
|
+
# function call the database has to reject.
|
|
850
|
+
def test_unknown_function_is_a_no_method_error
|
|
851
|
+
assert_raises(NoMethodError) { User.select { uppr(:name) } }
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def test_arithmetic_multiplication
|
|
855
|
+
assert_sql(/SELECT "users"."age" \* 2 AS dbl/,
|
|
856
|
+
User.select { (:age * 2).as(:dbl) }.to_sql)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Ruby puts * above >, so the expression groups the way it reads.
|
|
860
|
+
def test_arithmetic_in_where_without_parentheses
|
|
861
|
+
assert_sql(/WHERE "users"."age" \* 2 > 100/,
|
|
862
|
+
User.where { :age * 2 > 100 }.to_sql)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def test_arithmetic_between_columns
|
|
866
|
+
assert_sql(/WHERE \("users"."age" \+ "users"."id"\) \/ 2 <= 30/,
|
|
867
|
+
User.where { (:age + :id) / 2 <= 30 }.to_sql)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def test_arithmetic_inside_aggregate
|
|
871
|
+
assert_sql(/SELECT SUM\("users"."age" \* 2\)/,
|
|
872
|
+
User.select { sum(:age * 2) }.to_sql)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def test_aggregate_on_arithmetic
|
|
876
|
+
assert_sql(/SELECT SUM\(\("users"."age" \+ 1\)\) AS s/,
|
|
877
|
+
User.select { (:age + 1).sum.as(:s) }.to_sql)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Arel groups + and - but not * and /, which is how SQL precedence works
|
|
881
|
+
# out anyway.
|
|
882
|
+
def test_arithmetic_on_qualified_column
|
|
883
|
+
assert_sql(/SELECT \("users"."age" - 1\)/,
|
|
884
|
+
User.select { :users[:age] - 1 }.to_sql)
|
|
885
|
+
end
|
|
886
|
+
|
|
332
887
|
def test_coalesce_function_with_literal
|
|
333
888
|
assert_sql(/SELECT COALESCE\("users"."name", 'unknown'\)/,
|
|
334
889
|
User.select { coalesce(:name, 'unknown') }.to_sql)
|
|
@@ -419,6 +974,30 @@ class TestBlockSyntax < Minitest::Test
|
|
|
419
974
|
User.order { [:age.desc, :name.asc] }.to_sql)
|
|
420
975
|
end
|
|
421
976
|
|
|
977
|
+
def test_order_nulls_first
|
|
978
|
+
skip_without_nulls_ordering_syntax
|
|
979
|
+
assert_sql(/ORDER BY "users"."age" ASC NULLS FIRST/,
|
|
980
|
+
User.order { :age.asc.nulls_first }.to_sql)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def test_order_nulls_last
|
|
984
|
+
skip_without_nulls_ordering_syntax
|
|
985
|
+
assert_sql(/ORDER BY "users"."age" DESC NULLS LAST, "users"."name" ASC/,
|
|
986
|
+
User.order { [:age.desc.nulls_last, :name.asc] }.to_sql)
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# The order itself is portable even where the syntax is not.
|
|
990
|
+
def test_order_nulls_execution
|
|
991
|
+
User.delete_all
|
|
992
|
+
User.create!(name: 'null_age', age: nil)
|
|
993
|
+
User.create!(name: 'young', age: 20)
|
|
994
|
+
User.create!(name: 'old', age: 60)
|
|
995
|
+
assert_equal(%w[null_age young old],
|
|
996
|
+
User.order { :age.asc.nulls_first }.pluck(:name))
|
|
997
|
+
assert_equal(%w[young old null_age],
|
|
998
|
+
User.order { :age.asc.nulls_last }.pluck(:name))
|
|
999
|
+
end
|
|
1000
|
+
|
|
422
1001
|
def test_order_qualified_column
|
|
423
1002
|
assert_sql(/ORDER BY "users"."name" DESC/,
|
|
424
1003
|
User.order { :users[:name].desc }.to_sql)
|
data/test/test_helper.rb
CHANGED
|
@@ -62,6 +62,16 @@ 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
|
+
|
|
69
|
+
# MySQL has no NULLS FIRST/LAST; Arel emulates it with a leading IS NULL
|
|
70
|
+
# ordering, so only the resulting order is portable, not the SQL.
|
|
71
|
+
def skip_without_nulls_ordering_syntax
|
|
72
|
+
skip "#{ADAPTER} emulates NULLS FIRST/LAST" if ADAPTER == 'mysql2'
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
def regexp_operator
|
|
66
76
|
Regexp.escape(REGEXP_OPERATORS.fetch(ADAPTER).first)
|
|
67
77
|
end
|
|
@@ -100,14 +110,24 @@ class Post < ActiveRecord::Base
|
|
|
100
110
|
belongs_to :author
|
|
101
111
|
end
|
|
102
112
|
|
|
113
|
+
# Self-referencing, for the recursive CTE tests.
|
|
114
|
+
class Node < ActiveRecord::Base
|
|
115
|
+
end
|
|
116
|
+
|
|
103
117
|
class CreateAllTables < ActiveRecord::Migration[8.1]
|
|
104
118
|
def up
|
|
105
119
|
drop_table(:users, if_exists: true)
|
|
106
120
|
drop_table(:authors, if_exists: true)
|
|
107
121
|
drop_table(:posts, if_exists: true)
|
|
108
|
-
|
|
122
|
+
drop_table(:nodes, if_exists: true)
|
|
123
|
+
create_table(:users) do |t|
|
|
124
|
+
t.string :name
|
|
125
|
+
t.integer :age
|
|
126
|
+
t.string :tags, array: true if ADAPTER == 'postgresql'
|
|
127
|
+
end
|
|
109
128
|
create_table(:authors) {|t| t.string :name}
|
|
110
129
|
create_table(:posts) {|t| t.string :title; t.integer :author_id}
|
|
130
|
+
create_table(:nodes) {|t| t.string :name; t.integer :parent_id}
|
|
111
131
|
end
|
|
112
132
|
end
|
|
113
133
|
ActiveRecord::Migration.verbose = false
|