activerecord-refined 0.3.3 → 0.5.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/sandbox.yml +158 -0
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +263 -17
- data/activerecord-refined.gemspec +3 -1
- 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 +320 -33
- data/lib/active_record/refined.rb +180 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +597 -20
- data/test/test_helper.rb +12 -0
- metadata +8 -1
data/test/test_block_syntax.rb
CHANGED
|
@@ -2,11 +2,11 @@ require_relative 'test_helper'
|
|
|
2
2
|
|
|
3
3
|
class TestBlockSyntax < Minitest::Test
|
|
4
4
|
def test_equal
|
|
5
|
-
assert_sql(/WHERE "users"."name" = '
|
|
5
|
+
assert_sql(/WHERE "users"."name" = 'alice'/, User.where { :name == 'alice' }.to_sql)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def test_not_equal
|
|
9
|
-
assert_sql(/WHERE "users"."name" != '
|
|
9
|
+
assert_sql(/WHERE "users"."name" != 'bob'/, User.where { :name != 'bob' }.to_sql)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def test_greater_than
|
|
@@ -34,23 +34,23 @@ class TestBlockSyntax < Minitest::Test
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def test_and
|
|
37
|
-
assert_sql(/WHERE "users"."name" = '
|
|
38
|
-
User.where { (:name == '
|
|
37
|
+
assert_sql(/WHERE "users"."name" = 'alice' AND "users"."age" > 18/,
|
|
38
|
+
User.where { (:name == 'alice') & (:age > 18) }.to_sql)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def test_or
|
|
42
|
-
assert_sql(/WHERE \(?\"users\".\"name\" = '
|
|
43
|
-
User.where { (:name == '
|
|
42
|
+
assert_sql(/WHERE \(?\"users\".\"name\" = 'alice' OR \"users\".\"name\" = 'bob'\)?/,
|
|
43
|
+
User.where { (:name == 'alice') | (:name == 'bob') }.to_sql)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def test_not
|
|
47
|
-
assert_sql(/WHERE NOT \(?\"users\".\"name\" = '
|
|
48
|
-
User.where { !(:name == '
|
|
47
|
+
assert_sql(/WHERE NOT \(?\"users\".\"name\" = 'alice'\)?/,
|
|
48
|
+
User.where { !(:name == 'alice') }.to_sql)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def test_complex_combination
|
|
52
|
-
sql = User.where { ((:name == '
|
|
53
|
-
assert_sql(/"users"."name" = '
|
|
52
|
+
sql = User.where { ((:name == 'alice') & (:age > 18)) | !(:name == 'bob') }.to_sql
|
|
53
|
+
assert_sql(/"users"."name" = 'alice'/, sql)
|
|
54
54
|
assert_sql(/"users"."age" > 18/, sql)
|
|
55
55
|
assert_sql(/NOT/, sql)
|
|
56
56
|
assert_sql(/OR/, sql)
|
|
@@ -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\('Alice'\)/,
|
|
74
|
+
User.where { :name.casecmp?('Alice') }.to_sql)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_casecmp_qualified
|
|
78
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Alice'\)/,
|
|
79
|
+
User.where { :users[:name].casecmp?('Alice') }.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: 'Alice')
|
|
90
|
+
User.create!(name: 'bob')
|
|
91
|
+
assert_equal(['Alice'], User.where { :name.casecmp?('aLiCe') }.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)
|
|
@@ -85,8 +115,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
85
115
|
# of literals; matching any one of them is enough.
|
|
86
116
|
def test_start_with_multiple
|
|
87
117
|
assert_sql(
|
|
88
|
-
/WHERE \("users"."name" LIKE '
|
|
89
|
-
User.where { :name.start_with?('
|
|
118
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\)/,
|
|
119
|
+
User.where { :name.start_with?('al', 'bo') }.to_sql)
|
|
90
120
|
end
|
|
91
121
|
|
|
92
122
|
def test_end_with_multiple
|
|
@@ -98,8 +128,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
98
128
|
# The OR arrives grouped, so a following & applies to the whole list.
|
|
99
129
|
def test_start_with_multiple_combined
|
|
100
130
|
assert_sql(
|
|
101
|
-
/WHERE \("users"."name" LIKE '
|
|
102
|
-
User.where { :name.start_with?('
|
|
131
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\) AND "users"."age" > 18/,
|
|
132
|
+
User.where { :name.start_with?('al', 'bo') & (:age > 18) }.to_sql)
|
|
103
133
|
end
|
|
104
134
|
|
|
105
135
|
def test_start_with_no_arguments
|
|
@@ -135,9 +165,52 @@ class TestBlockSyntax < Minitest::Test
|
|
|
135
165
|
User.where { !:tags.member?('ruby') }.to_sql)
|
|
136
166
|
end
|
|
137
167
|
|
|
138
|
-
|
|
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
|
|
139
181
|
assert_sql(/WHERE "users"."tags" @> '\{ruby,rails\}'/,
|
|
140
|
-
User.where { :tags.
|
|
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)
|
|
141
214
|
end
|
|
142
215
|
|
|
143
216
|
# MySQL additionally escapes the double quotes inside its string literal,
|
|
@@ -265,6 +338,56 @@ class TestBlockSyntax < Minitest::Test
|
|
|
265
338
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
266
339
|
end
|
|
267
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: 'alice')
|
|
354
|
+
User.create!(name: nil)
|
|
355
|
+
assert_equal(['alice'], User.where { :name.not_distinct_from?('alice') }.pluck(:name))
|
|
356
|
+
# Unlike !=, this keeps the NULL row.
|
|
357
|
+
assert_equal([nil], User.where { :name.distinct_from?('alice') }.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
|
+
|
|
268
391
|
def test_in_subquery
|
|
269
392
|
assert_sql(
|
|
270
393
|
/WHERE "authors"."id" IN \(SELECT "posts"."author_id" FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
@@ -298,8 +421,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
298
421
|
|
|
299
422
|
def test_exists_combined
|
|
300
423
|
assert_sql(
|
|
301
|
-
/WHERE "authors"."name" = '
|
|
302
|
-
Author.where { (:name == '
|
|
424
|
+
/WHERE "authors"."name" = 'alice' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
425
|
+
Author.where { (:name == 'alice') & exists?(Post.where(title: 'pub')) }.to_sql)
|
|
303
426
|
end
|
|
304
427
|
|
|
305
428
|
def test_exists_execution
|
|
@@ -354,8 +477,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
354
477
|
end
|
|
355
478
|
|
|
356
479
|
def test_qualified_column
|
|
357
|
-
assert_sql(/WHERE "users"."name" = '
|
|
358
|
-
User.where { :users[:name] == '
|
|
480
|
+
assert_sql(/WHERE "users"."name" = 'alice'/,
|
|
481
|
+
User.where { :users[:name] == 'alice' }.to_sql)
|
|
359
482
|
end
|
|
360
483
|
|
|
361
484
|
def test_column_to_column_comparison
|
|
@@ -373,6 +496,84 @@ class TestBlockSyntax < Minitest::Test
|
|
|
373
496
|
assert_sql(/LEFT OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id"/, sql)
|
|
374
497
|
end
|
|
375
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
|
+
|
|
376
577
|
def test_select_aggregate
|
|
377
578
|
assert_sql(/SELECT SUM\("users"."age"\)/,
|
|
378
579
|
User.select { :age.sum }.to_sql)
|
|
@@ -424,6 +625,33 @@ class TestBlockSyntax < Minitest::Test
|
|
|
424
625
|
User.select { count(:*).as(:cnt) }.to_sql)
|
|
425
626
|
end
|
|
426
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
|
+
|
|
427
655
|
def test_having_count_star
|
|
428
656
|
sql = Author.joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
429
657
|
group { :authors[:id] }.
|
|
@@ -482,6 +710,331 @@ class TestBlockSyntax < Minitest::Test
|
|
|
482
710
|
User.where { length(:name) > 3 }.to_sql)
|
|
483
711
|
end
|
|
484
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: 'alice')
|
|
759
|
+
User.create!(name: 'bob')
|
|
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: 'alice', age: 60)
|
|
797
|
+
assert_equal(['ALICE-x'], User.select { concat(upper(:name), '-x').as(:v) }.map(&:v))
|
|
798
|
+
assert_equal([5], 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: 'alice')
|
|
830
|
+
assert_equal(['alice!'], 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
|
+
# CURRENT_TIMESTAMP and its relatives are grammar rather than calls, so
|
|
849
|
+
# they come out without the parentheses PostgreSQL and SQLite reject.
|
|
850
|
+
def test_datetime_value_functions_are_emitted_bare
|
|
851
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP FROM/,
|
|
852
|
+
User.select { current_timestamp }.to_sql)
|
|
853
|
+
assert_sql(/SELECT CURRENT_DATE FROM/, User.select { current_date }.to_sql)
|
|
854
|
+
assert_sql(/SELECT CURRENT_TIME FROM/, User.select { current_time }.to_sql)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def test_datetime_value_function_in_comparison_and_alias
|
|
858
|
+
assert_sql(/WHERE "users"."name" < CURRENT_TIMESTAMP/,
|
|
859
|
+
User.where { :name < current_timestamp }.to_sql)
|
|
860
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP AS ts FROM/,
|
|
861
|
+
User.select { current_timestamp.as(:ts) }.to_sql)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def test_current_timestamp_runs
|
|
865
|
+
User.delete_all
|
|
866
|
+
User.create!(name: 'alice')
|
|
867
|
+
refute_nil(User.select { current_timestamp.as(:v) }.sole.v)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# The one thing that does go into the parentheses is a precision, which
|
|
871
|
+
# current_date never takes and SQLite never accepts.
|
|
872
|
+
def test_datetime_value_function_with_precision
|
|
873
|
+
if ADAPTER == 'sqlite3'
|
|
874
|
+
e = assert_raises(NotImplementedError) { User.select { current_timestamp(3) } }
|
|
875
|
+
assert_match(/precision/, e.message)
|
|
876
|
+
else
|
|
877
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP\(3\) FROM/,
|
|
878
|
+
User.select { current_timestamp(3) }.to_sql)
|
|
879
|
+
assert_sql(/SELECT CURRENT_TIME\(0\) FROM/,
|
|
880
|
+
User.select { current_time(0) }.to_sql)
|
|
881
|
+
User.delete_all
|
|
882
|
+
User.create!(name: 'alice')
|
|
883
|
+
refute_nil(User.select { current_timestamp(0).as(:v) }.sole.v)
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def test_current_date_takes_no_precision
|
|
888
|
+
assert_raises(ArgumentError) { User.select { current_date(0) } }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The precision is written into the SQL as given, so only an Integer is
|
|
892
|
+
# accepted there.
|
|
893
|
+
def test_precision_must_be_an_integer
|
|
894
|
+
assert_raises(ArgumentError) do
|
|
895
|
+
User.select { current_timestamp(:'3); DROP TABLE users --') }
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def test_localtime_is_unsupported_on_sqlite
|
|
900
|
+
if ADAPTER == 'sqlite3'
|
|
901
|
+
assert_raises(NotImplementedError) { User.select { localtime } }
|
|
902
|
+
assert_raises(NotImplementedError) { User.select { localtimestamp } }
|
|
903
|
+
else
|
|
904
|
+
assert_sql(/SELECT LOCALTIME FROM/, User.select { localtime }.to_sql)
|
|
905
|
+
assert_sql(/SELECT LOCALTIMESTAMP FROM/,
|
|
906
|
+
User.select { localtimestamp }.to_sql)
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def test_math_functions
|
|
911
|
+
assert_sql(/SELECT SIGN\("users"."age"\)/, User.select { sign(:age) }.to_sql)
|
|
912
|
+
assert_sql(/SELECT ATAN2\("users"."age", 2\)/,
|
|
913
|
+
User.select { atan2(:age, 2) }.to_sql)
|
|
914
|
+
assert_sql(/SELECT PI\(\)/, User.select { pi }.to_sql)
|
|
915
|
+
assert_sql(/SELECT DEGREES\(RADIANS\("users"."age"\)\)/,
|
|
916
|
+
User.select { degrees(radians(:age)) }.to_sql)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def test_math_functions_run
|
|
920
|
+
User.delete_all
|
|
921
|
+
User.create!(name: 'alice', age: 60)
|
|
922
|
+
assert_equal(1, User.select { sign(:age).as(:v) }.sole.v.to_i)
|
|
923
|
+
assert_equal(60,
|
|
924
|
+
User.select { round(degrees(radians(:age))).as(:v) }.sole.v.to_i)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# PostgreSQL spells log2(x) as log(2, x), which no renaming carries.
|
|
928
|
+
def test_log2_is_unsupported_on_postgresql
|
|
929
|
+
if ADAPTER == 'postgresql'
|
|
930
|
+
assert_raises(NotImplementedError) { User.select { log2(:age) } }
|
|
931
|
+
else
|
|
932
|
+
assert_sql(/SELECT LOG2\("users"."age"\)/, User.select { log2(:age) }.to_sql)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# MySQL spells trunc TRUNCATE, and insists on the second argument the
|
|
937
|
+
# others default to zero.
|
|
938
|
+
def test_trunc
|
|
939
|
+
expected = ADAPTER == 'mysql2' ? 'TRUNCATE' : 'TRUNC'
|
|
940
|
+
assert_sql(/SELECT #{expected}\("users"."age", 0\)/,
|
|
941
|
+
User.select { trunc(:age, 0) }.to_sql)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# EXTRACT(field FROM expr): the field is a keyword rather than a value.
|
|
945
|
+
# SQLite spells all of this as strftime formats, which no renaming
|
|
946
|
+
# carries.
|
|
947
|
+
def test_extract
|
|
948
|
+
if ADAPTER == 'sqlite3'
|
|
949
|
+
e = assert_raises(NotImplementedError) { User.select { extract(:year, :name) } }
|
|
950
|
+
assert_match(/extract/, e.message)
|
|
951
|
+
else
|
|
952
|
+
assert_sql(/SELECT EXTRACT\(YEAR FROM "users"."name"\)/,
|
|
953
|
+
User.select { extract(:year, :name) }.to_sql)
|
|
954
|
+
assert_sql(/WHERE EXTRACT\(YEAR FROM "users"."name"\) = 2026/,
|
|
955
|
+
User.where { extract(:year, :name) == 2026 }.to_sql)
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# A bad field is an ArgumentError on every adapter, before SQLite gets to
|
|
960
|
+
# say it has no extract at all.
|
|
961
|
+
def test_extract_rejects_an_injected_field
|
|
962
|
+
assert_raises(ArgumentError) { User.select { extract(INJECTION.to_sym, :name) } }
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def test_extract_runs
|
|
966
|
+
skip "#{ADAPTER} has no extract" if ADAPTER == 'sqlite3'
|
|
967
|
+
User.delete_all
|
|
968
|
+
User.create!(name: 'alice')
|
|
969
|
+
assert_equal(2026,
|
|
970
|
+
User.select { extract(:year, cast('2026-01-05', :date)).as(:v) }.sole.v.to_i)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def test_cast
|
|
974
|
+
assert_sql(/SELECT CAST\("users"."age" AS text\)/,
|
|
975
|
+
User.select { cast(:age, :text) }.to_sql)
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def test_cast_runs
|
|
979
|
+
User.delete_all
|
|
980
|
+
User.create!(name: 'alice')
|
|
981
|
+
assert_equal(12.5,
|
|
982
|
+
User.select { cast('12.5', 'decimal(10,2)').as(:v) }.sole.v.to_f)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# The type is written into the SQL as given, so it has to look like one:
|
|
986
|
+
# a plain name, at most parenthesized with lengths. The adapters' own
|
|
987
|
+
# spellings with a space in them pass too.
|
|
988
|
+
def test_cast_type_names
|
|
989
|
+
assert_sql(/AS double precision\)/,
|
|
990
|
+
User.select { cast(:age, 'double precision') }.to_sql)
|
|
991
|
+
assert_sql(/AS decimal\(10,2\)\)/,
|
|
992
|
+
User.select { cast(:age, 'decimal(10,2)') }.to_sql)
|
|
993
|
+
assert_raises(ArgumentError) { User.select { cast(:age, INJECTION.to_sym) } }
|
|
994
|
+
assert_raises(ArgumentError) do
|
|
995
|
+
User.select { cast(:age, 'integer); DROP TABLE users --') }
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# A name with no method of its own is still a NoMethodError, not a
|
|
1000
|
+
# function call the database has to reject.
|
|
1001
|
+
def test_unknown_function_is_a_no_method_error
|
|
1002
|
+
assert_raises(NoMethodError) { User.select { uppr(:name) } }
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def test_arithmetic_multiplication
|
|
1006
|
+
assert_sql(/SELECT "users"."age" \* 2 AS dbl/,
|
|
1007
|
+
User.select { (:age * 2).as(:dbl) }.to_sql)
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Ruby puts * above >, so the expression groups the way it reads.
|
|
1011
|
+
def test_arithmetic_in_where_without_parentheses
|
|
1012
|
+
assert_sql(/WHERE "users"."age" \* 2 > 100/,
|
|
1013
|
+
User.where { :age * 2 > 100 }.to_sql)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def test_arithmetic_between_columns
|
|
1017
|
+
assert_sql(/WHERE \("users"."age" \+ "users"."id"\) \/ 2 <= 30/,
|
|
1018
|
+
User.where { (:age + :id) / 2 <= 30 }.to_sql)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def test_arithmetic_inside_aggregate
|
|
1022
|
+
assert_sql(/SELECT SUM\("users"."age" \* 2\)/,
|
|
1023
|
+
User.select { sum(:age * 2) }.to_sql)
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
def test_aggregate_on_arithmetic
|
|
1027
|
+
assert_sql(/SELECT SUM\(\("users"."age" \+ 1\)\) AS s/,
|
|
1028
|
+
User.select { (:age + 1).sum.as(:s) }.to_sql)
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Arel groups + and - but not * and /, which is how SQL precedence works
|
|
1032
|
+
# out anyway.
|
|
1033
|
+
def test_arithmetic_on_qualified_column
|
|
1034
|
+
assert_sql(/SELECT \("users"."age" - 1\)/,
|
|
1035
|
+
User.select { :users[:age] - 1 }.to_sql)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
485
1038
|
def test_coalesce_function_with_literal
|
|
486
1039
|
assert_sql(/SELECT COALESCE\("users"."name", 'unknown'\)/,
|
|
487
1040
|
User.select { coalesce(:name, 'unknown') }.to_sql)
|
|
@@ -572,6 +1125,30 @@ class TestBlockSyntax < Minitest::Test
|
|
|
572
1125
|
User.order { [:age.desc, :name.asc] }.to_sql)
|
|
573
1126
|
end
|
|
574
1127
|
|
|
1128
|
+
def test_order_nulls_first
|
|
1129
|
+
skip_without_nulls_ordering_syntax
|
|
1130
|
+
assert_sql(/ORDER BY "users"."age" ASC NULLS FIRST/,
|
|
1131
|
+
User.order { :age.asc.nulls_first }.to_sql)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def test_order_nulls_last
|
|
1135
|
+
skip_without_nulls_ordering_syntax
|
|
1136
|
+
assert_sql(/ORDER BY "users"."age" DESC NULLS LAST, "users"."name" ASC/,
|
|
1137
|
+
User.order { [:age.desc.nulls_last, :name.asc] }.to_sql)
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# The order itself is portable even where the syntax is not.
|
|
1141
|
+
def test_order_nulls_execution
|
|
1142
|
+
User.delete_all
|
|
1143
|
+
User.create!(name: 'null_age', age: nil)
|
|
1144
|
+
User.create!(name: 'young', age: 20)
|
|
1145
|
+
User.create!(name: 'old', age: 60)
|
|
1146
|
+
assert_equal(%w[null_age young old],
|
|
1147
|
+
User.order { :age.asc.nulls_first }.pluck(:name))
|
|
1148
|
+
assert_equal(%w[young old null_age],
|
|
1149
|
+
User.order { :age.asc.nulls_last }.pluck(:name))
|
|
1150
|
+
end
|
|
1151
|
+
|
|
575
1152
|
def test_order_qualified_column
|
|
576
1153
|
assert_sql(/ORDER BY "users"."name" DESC/,
|
|
577
1154
|
User.order { :users[:name].desc }.to_sql)
|