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.
@@ -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
- create_table(:users) {|t| t.string :name; t.integer :age}
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