activerecord-refined 0.5.0 → 0.6.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 +162 -26
- data/.github/workflows/test.yml +15 -2
- data/README.md +486 -52
- data/activerecord-refined.gemspec +3 -2
- data/examples/ctes.rb +41 -10
- data/examples/expressions.rb +71 -5
- data/examples/json.rb +94 -0
- data/examples/postgresql.rb +89 -6
- data/examples/predicates.rb +32 -5
- data/examples/windows.rb +96 -0
- data/examples/writes.rb +84 -0
- data/lib/active_record/refined/ast.rb +831 -94
- data/lib/active_record/refined.rb +351 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +7 -0
- data/test/test_block_syntax.rb +1100 -51
- data/test/test_helper.rb +82 -0
- metadata +4 -1
data/test/test_block_syntax.rb
CHANGED
|
@@ -91,11 +91,22 @@ class TestBlockSyntax < Minitest::Test
|
|
|
91
91
|
assert_equal(['Alice'], User.where { :name.casecmp?('aLiCe') }.pluck(:name))
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def
|
|
94
|
+
def test_bang_negates_like
|
|
95
95
|
assert_sql(/WHERE NOT \("users"."name" LIKE 'tender%'\)/,
|
|
96
96
|
User.where { !:name.like?('tender%') }.to_sql)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
def test_not_like
|
|
100
|
+
assert_sql(/WHERE "users"."name" NOT LIKE 'tender%'/,
|
|
101
|
+
User.where { :name.not_like?('tender%') }.to_sql)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def test_not_ilike
|
|
105
|
+
expected = ADAPTER == 'postgresql' ? 'ILIKE' : 'LIKE'
|
|
106
|
+
assert_sql(/WHERE "users"."name" NOT #{expected} 'tender%'/,
|
|
107
|
+
User.where { :name.not_ilike?('tender%') }.to_sql)
|
|
108
|
+
end
|
|
109
|
+
|
|
99
110
|
def test_start_with
|
|
100
111
|
assert_sql(/WHERE "users"."name" LIKE 'tender%' ESCAPE '\\'/,
|
|
101
112
|
User.where { :name.start_with?('tender') }.to_sql)
|
|
@@ -298,11 +309,23 @@ class TestBlockSyntax < Minitest::Test
|
|
|
298
309
|
User.where { :age.in?(18...65) }.to_sql)
|
|
299
310
|
end
|
|
300
311
|
|
|
301
|
-
def
|
|
312
|
+
def test_bang_negates_between
|
|
302
313
|
assert_sql(/WHERE NOT \("users"."age" BETWEEN 18 AND 65\)/,
|
|
303
314
|
User.where { !:age.between?(18, 65) }.to_sql)
|
|
304
315
|
end
|
|
305
316
|
|
|
317
|
+
# Arel spells the negation as the two comparisons rather than NOT BETWEEN,
|
|
318
|
+
# which is the same set of rows, NULLs included.
|
|
319
|
+
def test_not_between
|
|
320
|
+
assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
|
|
321
|
+
User.where { :age.not_between?(18, 65) }.to_sql)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def test_not_in_range
|
|
325
|
+
assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
|
|
326
|
+
User.where { :age.not_in?(18..65) }.to_sql)
|
|
327
|
+
end
|
|
328
|
+
|
|
306
329
|
def test_is_null
|
|
307
330
|
assert_sql(/WHERE "users"."name" IS NULL/,
|
|
308
331
|
User.where { :name.null? }.to_sql)
|
|
@@ -313,6 +336,237 @@ class TestBlockSyntax < Minitest::Test
|
|
|
313
336
|
User.where { :users[:name].null? }.to_sql)
|
|
314
337
|
end
|
|
315
338
|
|
|
339
|
+
def test_is_not_null
|
|
340
|
+
assert_sql(/WHERE "users"."name" IS NOT NULL/,
|
|
341
|
+
User.where { :name.not_null? }.to_sql)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def test_is_not_null_qualified
|
|
345
|
+
assert_sql(/WHERE "users"."name" IS NOT NULL/,
|
|
346
|
+
User.where { :users[:name].not_null? }.to_sql)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# CASE has two shapes, and so does the block: an operand to compare each
|
|
350
|
+
# `when` against, or a condition on every `when`.
|
|
351
|
+
def test_case_with_an_operand
|
|
352
|
+
assert_sql(/SELECT CASE "users"."age" WHEN 10 THEN 'ten' ELSE 'other' END AS "v"/,
|
|
353
|
+
User.select { self.case(:age).when(10).then('ten').else('other').as(:v) }.to_sql)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def test_when_on_a_column_is_the_same_case
|
|
357
|
+
assert_equal(
|
|
358
|
+
User.select { self.case(:age).when(10).then('ten').else('other').as(:v) }.to_sql,
|
|
359
|
+
User.select { :age.when(10).then('ten').else('other').as(:v) }.to_sql)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def test_searched_case
|
|
363
|
+
assert_sql(/SELECT CASE WHEN "users"."age" >= 60 THEN 'senior' ELSE 'other' END AS "v"/,
|
|
364
|
+
User.select { case_when { :age >= 60 }.then('senior').else('other').as(:v) }.to_sql)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def test_case_when_is_the_same_as_case_with_no_operand
|
|
368
|
+
assert_equal(
|
|
369
|
+
User.select { self.case.when { :age >= 60 }.then(1).else(0).as(:v) }.to_sql,
|
|
370
|
+
User.select { case_when { :age >= 60 }.then(1).else(0).as(:v) }.to_sql)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# A value and a block say the same thing; the block is there to read like
|
|
374
|
+
# the blocks around it.
|
|
375
|
+
def test_a_condition_reads_the_same_either_way
|
|
376
|
+
assert_equal(
|
|
377
|
+
User.select { case_when(:age >= 60).then(1).else(0).as(:v) }.to_sql,
|
|
378
|
+
User.select { case_when { :age >= 60 }.then(1).else(0).as(:v) }.to_sql)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def test_case_with_several_whens
|
|
382
|
+
assert_sql(
|
|
383
|
+
/CASE WHEN "users"."age" < 18 THEN 'minor' WHEN "users"."age" >= 60 THEN 'senior' ELSE 'adult' END/,
|
|
384
|
+
User.select {
|
|
385
|
+
case_when { :age < 18 }.then('minor').
|
|
386
|
+
when { :age >= 60 }.then('senior').
|
|
387
|
+
else('adult').as(:v)
|
|
388
|
+
}.to_sql)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Leaving the ELSE off is SQL's own default rather than an omission.
|
|
392
|
+
def test_case_without_an_else
|
|
393
|
+
sql = User.select { case_when { :age >= 60 }.then('senior').as(:v) }.to_sql
|
|
394
|
+
assert_sql(/CASE WHEN "users"."age" >= 60 THEN 'senior' END/, sql)
|
|
395
|
+
refute_match(/ELSE/, sql)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def test_case_takes_expressions_and_columns
|
|
399
|
+
assert_sql(/THEN \("users"."age" - 60\)/,
|
|
400
|
+
User.select { case_when { :age >= 60 }.then { :age - 60 }.else(0).as(:v) }.to_sql)
|
|
401
|
+
assert_sql(/THEN "users"."name"/,
|
|
402
|
+
User.select { case_when { :age >= 60 }.then(:name).else('x').as(:v) }.to_sql)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def test_case_is_an_expression_like_any_other
|
|
406
|
+
assert_sql(/SUM\(CASE WHEN/,
|
|
407
|
+
User.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:v) }.to_sql)
|
|
408
|
+
assert_sql(/WHERE CASE "users"."age" WHEN 10 THEN 1 ELSE 2 END = 1/,
|
|
409
|
+
User.where { self.case(:age).when(10).then(1).else(2) == 1 }.to_sql)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def test_case_execution
|
|
413
|
+
User.delete_all
|
|
414
|
+
User.create!(name: 'senior', age: 70)
|
|
415
|
+
User.create!(name: 'adult', age: 30)
|
|
416
|
+
User.create!(name: 'minor', age: 10)
|
|
417
|
+
assert_equal(%w[adult minor senior],
|
|
418
|
+
User.select {
|
|
419
|
+
case_when { :age < 18 }.then('minor').
|
|
420
|
+
when { :age >= 60 }.then('senior').
|
|
421
|
+
else('adult').as(:v)
|
|
422
|
+
}.map(&:v).sort)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# One case finished two ways: the methods return new nodes rather than
|
|
426
|
+
# adding to the one they were called on.
|
|
427
|
+
def test_a_case_is_not_added_to_in_place
|
|
428
|
+
sql = User.select {
|
|
429
|
+
started = case_when { :age >= 60 }.then(1)
|
|
430
|
+
[started.else(0).as(:a), started.else(9).as(:b)]
|
|
431
|
+
}.to_sql
|
|
432
|
+
assert_sql(/THEN 1 ELSE 0 END AS "a"/, sql)
|
|
433
|
+
assert_sql(/THEN 1 ELSE 9 END AS "b"/, sql)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def test_when_needs_a_value_or_a_block
|
|
437
|
+
assert_raises(ArgumentError) { User.select { case_when.then(1) } }
|
|
438
|
+
e = assert_raises(ArgumentError) { User.select { case_when(1) { 2 }.then(1) } }
|
|
439
|
+
assert_match(/not both/, e.message)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def test_when_needs_a_matching_then
|
|
443
|
+
e = assert_raises(ArgumentError) { User.select { :age.when(10) }.to_sql }
|
|
444
|
+
assert_match(/matching then/, e.message)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Kernel#then would otherwise answer this one, with no block and no noise.
|
|
448
|
+
def test_then_without_a_when_says_so
|
|
449
|
+
e = assert_raises(ArgumentError) { User.select { self.case(:age).then(1) } }
|
|
450
|
+
assert_match(/follows a when/, e.message)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A window is built by chaining, the way Arel's own is.
|
|
454
|
+
def test_over_with_no_window
|
|
455
|
+
assert_sql(/SELECT AVG\("users"."age"\) OVER \(\) AS "v"/,
|
|
456
|
+
User.select { avg(:age).over.as(:v) }.to_sql)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def test_over_partition_and_order
|
|
460
|
+
assert_sql(
|
|
461
|
+
/AVG\("users"."age"\) OVER \(PARTITION BY "users"."name" ORDER BY "users"."age" DESC\)/,
|
|
462
|
+
User.select { avg(:age).over.partition(:name).order(:age.desc).as(:v) }.to_sql)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def test_over_takes_several_expressions
|
|
466
|
+
assert_sql(/PARTITION BY "users"."name", "users"."age"/,
|
|
467
|
+
User.select { count(:*).over.partition(:name, :age).as(:v) }.to_sql)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# The window-only functions, which the adapters that have them at all spell
|
|
471
|
+
# the same way.
|
|
472
|
+
def test_window_functions
|
|
473
|
+
assert_sql(/ROW_NUMBER\(\) OVER \(ORDER BY "users"."age"\)/,
|
|
474
|
+
User.select { row_number.over.order(:age).as(:v) }.to_sql)
|
|
475
|
+
assert_sql(/RANK\(\) OVER/, User.select { rank.over.order(:age).as(:v) }.to_sql)
|
|
476
|
+
assert_sql(/NTILE\(2\) OVER/, User.select { ntile(2).over.order(:age).as(:v) }.to_sql)
|
|
477
|
+
assert_sql(/LAG\("users"."age", 1\) OVER/,
|
|
478
|
+
User.select { lag(:age).over.order(:age).as(:v) }.to_sql)
|
|
479
|
+
assert_sql(/LAG\("users"."age", 2, 0\) OVER/,
|
|
480
|
+
User.select { lag(:age, 2, 0).over.order(:age).as(:v) }.to_sql)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# A frame is a range of rows counted from the current one: negative before
|
|
484
|
+
# it, positive after, 0 the row itself, an open end for unbounded.
|
|
485
|
+
def test_window_frames
|
|
486
|
+
assert_sql(/ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW/,
|
|
487
|
+
User.select { sum(:age).over.order(:age).rows(..0).as(:v) }.to_sql)
|
|
488
|
+
assert_sql(/ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING/,
|
|
489
|
+
User.select { sum(:age).over.order(:age).rows(-1..1).as(:v) }.to_sql)
|
|
490
|
+
assert_sql(/ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING/,
|
|
491
|
+
User.select { sum(:age).over.order(:age).rows(0..).as(:v) }.to_sql)
|
|
492
|
+
assert_sql(/RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW/,
|
|
493
|
+
User.select { sum(:age).over.order(:age).range(..0).as(:v) }.to_sql)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def test_over_is_an_expression_like_any_other
|
|
497
|
+
assert_sql(/\(RANK\(\) OVER \(ORDER BY "users"."age"\) \+ 1\) AS "v"/,
|
|
498
|
+
User.select { (rank.over.order(:age) + 1).as(:v) }.to_sql)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def test_window_execution
|
|
502
|
+
User.delete_all
|
|
503
|
+
User.create!(name: 'a', age: 20)
|
|
504
|
+
User.create!(name: 'b', age: 30)
|
|
505
|
+
User.create!(name: 'c', age: 40)
|
|
506
|
+
assert_equal([1, 2, 3],
|
|
507
|
+
User.select { row_number.over.order(:age).as(:v) }.map {|u| u.v.to_i })
|
|
508
|
+
assert_equal([20, 50, 90],
|
|
509
|
+
User.select { sum(:age).over.order(:age).rows(..0).as(:v) }.map {|u| u.v.to_i })
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# One window finished two ways: the methods return new nodes.
|
|
513
|
+
def test_a_window_is_not_added_to_in_place
|
|
514
|
+
sql = User.select {
|
|
515
|
+
started = sum(:age).over.order(:age)
|
|
516
|
+
[started.partition(:name).as(:a), started.as(:b)]
|
|
517
|
+
}.to_sql
|
|
518
|
+
assert_sql(/PARTITION BY "users"."name" ORDER BY "users"."age"\) AS "a"/, sql)
|
|
519
|
+
assert_sql(/SUM\("users"."age"\) OVER \(ORDER BY "users"."age"\) AS "b"/, sql)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def test_a_window_function_needs_over
|
|
523
|
+
e = assert_raises(ArgumentError) { User.select { row_number.as(:v) }.to_sql }
|
|
524
|
+
assert_match(/needs over/, e.message)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def test_a_window_has_one_frame
|
|
528
|
+
assert_raises(ArgumentError) { User.select { sum(:age).over.rows(..0).range(..0) } }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def test_a_frame_is_a_range_of_rows
|
|
532
|
+
assert_raises(ArgumentError) { User.select { sum(:age).over.rows(3) } }
|
|
533
|
+
assert_raises(ArgumentError) { User.select { sum(:age).over.rows('a'..'b') } }
|
|
534
|
+
e = assert_raises(ArgumentError) { User.select { sum(:age).over.rows(-2...0) } }
|
|
535
|
+
assert_match(/ends on a row/, e.message)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def test_partition_needs_an_expression
|
|
539
|
+
assert_raises(ArgumentError) { User.select { sum(:age).over.partition } }
|
|
540
|
+
assert_raises(ArgumentError) { User.select { sum(:age).over.order } }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def test_case_needs_a_when
|
|
544
|
+
e = assert_raises(ArgumentError) { User.select { self.case(:age).else(1) }.to_sql }
|
|
545
|
+
assert_match(/needs a when/, e.message)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# The claim these methods rest on: the direct spelling is the same rows as
|
|
549
|
+
# negating the positive one, which is where a NULL would show a difference
|
|
550
|
+
# if there were one.
|
|
551
|
+
def test_the_negations_match_what_bang_selects
|
|
552
|
+
User.delete_all
|
|
553
|
+
User.create!(name: 'alice', age: 60, active: true)
|
|
554
|
+
User.create!(name: 'bob', age: 20, active: false)
|
|
555
|
+
User.create!(name: nil, age: 40)
|
|
556
|
+
[
|
|
557
|
+
[-> { :name.not_null? }, -> { !:name.null? }],
|
|
558
|
+
[-> { :age.not_in?([20, 30]) }, -> { !:age.in?([20, 30]) }],
|
|
559
|
+
[-> { :age.not_between?(20, 30) }, -> { !:age.between?(20, 30) }],
|
|
560
|
+
[-> { :name.not_like?('a%') }, -> { !:name.like?('a%') }],
|
|
561
|
+
[-> { :active.not_true? }, -> { !:active.true? }],
|
|
562
|
+
[-> { :active.not_false? }, -> { !:active.false? }],
|
|
563
|
+
].each do |direct, negated|
|
|
564
|
+
assert_equal(User.where(&negated).pluck(:id).sort,
|
|
565
|
+
User.where(&direct).pluck(:id).sort,
|
|
566
|
+
"#{direct.source_location} did not match the ! form")
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
316
570
|
def test_equal_nil_is_rejected
|
|
317
571
|
e = assert_raises(ArgumentError) { User.where { :name == nil } }
|
|
318
572
|
assert_match(/null\?/, e.message)
|
|
@@ -323,6 +577,44 @@ class TestBlockSyntax < Minitest::Test
|
|
|
323
577
|
assert_match(/null\?/, e.message)
|
|
324
578
|
end
|
|
325
579
|
|
|
580
|
+
def test_is_true
|
|
581
|
+
assert_sql(/WHERE "users"."active" IS TRUE/, User.where { :active.true? }.to_sql)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def test_is_not_true
|
|
585
|
+
assert_sql(/WHERE "users"."active" IS NOT TRUE/, User.where { :active.not_true? }.to_sql)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def test_is_false
|
|
589
|
+
assert_sql(/WHERE "users"."active" IS FALSE/, User.where { :active.false? }.to_sql)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def test_is_not_false
|
|
593
|
+
assert_sql(/WHERE "users"."active" IS NOT FALSE/, User.where { :active.not_false? }.to_sql)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# The four are spelled and answered the same way by every adapter, NULL
|
|
597
|
+
# included, which is what makes them worth having over = TRUE.
|
|
598
|
+
def test_truth_values_execution
|
|
599
|
+
User.delete_all
|
|
600
|
+
User.create!([{name: 'yes', active: true}, {name: 'no', active: false},
|
|
601
|
+
{name: 'unset', active: nil}])
|
|
602
|
+
order = ->(relation) { relation.order(:name).pluck(:name) }
|
|
603
|
+
assert_equal(['yes'], order.(User.where { :active.true? }))
|
|
604
|
+
assert_equal(%w[no unset], order.(User.where { :active.not_true? }))
|
|
605
|
+
assert_equal(['no'], order.(User.where { :active.false? }))
|
|
606
|
+
assert_equal(%w[unset yes], order.(User.where { :active.not_false? }))
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Where the difference from a comparison against the literal shows: = TRUE
|
|
610
|
+
# is NULL for a NULL row, and negating it leaves that row out.
|
|
611
|
+
def test_not_true_keeps_the_nulls_equality_drops
|
|
612
|
+
User.delete_all
|
|
613
|
+
User.create!([{name: 'no', active: false}, {name: 'unset', active: nil}])
|
|
614
|
+
assert_equal(%w[no unset], User.where { :active.not_true? }.order(:name).pluck(:name))
|
|
615
|
+
assert_equal(['no'], User.where { !(:active == true) }.order(:name).pluck(:name))
|
|
616
|
+
end
|
|
617
|
+
|
|
326
618
|
def test_in
|
|
327
619
|
assert_sql(/WHERE "users"."age" IN \(1, 2, 3\)/,
|
|
328
620
|
User.where { :age.in?([1, 2, 3]) }.to_sql)
|
|
@@ -333,11 +625,21 @@ class TestBlockSyntax < Minitest::Test
|
|
|
333
625
|
User.where { :users[:age].in?([1, 2, 3]) }.to_sql)
|
|
334
626
|
end
|
|
335
627
|
|
|
336
|
-
def
|
|
628
|
+
def test_bang_negates_in
|
|
337
629
|
assert_sql(/WHERE NOT \("users"."age" IN \(1, 2, 3\)\)/,
|
|
338
630
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
339
631
|
end
|
|
340
632
|
|
|
633
|
+
def test_not_in
|
|
634
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
635
|
+
User.where { :age.not_in?([1, 2, 3]) }.to_sql)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def test_not_in_qualified
|
|
639
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
640
|
+
User.where { :users[:age].not_in?([1, 2, 3]) }.to_sql)
|
|
641
|
+
end
|
|
642
|
+
|
|
341
643
|
# Spelled IS [NOT] DISTINCT FROM on PostgreSQL, IS / IS NOT on SQLite and
|
|
342
644
|
# <=> on MySQL, so only the resulting rows are portable.
|
|
343
645
|
def test_not_distinct_from_execution
|
|
@@ -406,6 +708,66 @@ class TestBlockSyntax < Minitest::Test
|
|
|
406
708
|
Author.where { !:id.in?(Post.select(:author_id)) }.to_sql)
|
|
407
709
|
end
|
|
408
710
|
|
|
711
|
+
def test_any_subquery
|
|
712
|
+
skip_without_quantifiers
|
|
713
|
+
assert_sql(
|
|
714
|
+
/WHERE "users"."age" > ANY\(SELECT "users"."age" FROM "users" WHERE "users"."name" = 'alice'\)/,
|
|
715
|
+
User.where { :age > any(User.where(name: 'alice').select(:age)) }.to_sql)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def test_all_subquery
|
|
719
|
+
skip_without_quantifiers
|
|
720
|
+
assert_sql(/WHERE "users"."age" >= ALL\(SELECT "users"."age" FROM "users"\)/,
|
|
721
|
+
User.where { :age >= all(User.select(:age)) }.to_sql)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# The same default in? has, since both take the relation for a set of rows.
|
|
725
|
+
def test_quantifier_selects_primary_key_by_default
|
|
726
|
+
skip_without_quantifiers
|
|
727
|
+
assert_sql(/WHERE "authors"."id" > ANY\(SELECT "posts"."id" FROM "posts"\)/,
|
|
728
|
+
Author.where { :id > any(Post.all) }.to_sql)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# A list is what in? takes; ANY of one is what a plain comparison says.
|
|
732
|
+
def test_quantifier_without_a_relation_is_rejected
|
|
733
|
+
skip_without_quantifiers
|
|
734
|
+
e = assert_raises(ArgumentError) { User.where { :age > any([20, 30]) } }
|
|
735
|
+
assert_match(/relation/, e.message)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def test_quantifier_is_unsupported_on_sqlite
|
|
739
|
+
if ADAPTER == 'sqlite3'
|
|
740
|
+
e = assert_raises(NotImplementedError) { User.where { :age > any(User.select(:age)) } }
|
|
741
|
+
assert_match(/ANY/, e.message)
|
|
742
|
+
else
|
|
743
|
+
assert_sql(/> ANY\(SELECT/, User.where { :age > any(User.select(:age)) }.to_sql)
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# ANY is satisfied by one row of the subquery and ALL by every row, so the
|
|
748
|
+
# two pick out the ends of the range the subquery covers.
|
|
749
|
+
def test_quantifier_execution
|
|
750
|
+
skip_without_quantifiers
|
|
751
|
+
User.delete_all
|
|
752
|
+
User.create!([{name: 'young', age: 20}, {name: 'middle', age: 40},
|
|
753
|
+
{name: 'old', age: 60}])
|
|
754
|
+
ages = -> { User.select(:age) }
|
|
755
|
+
assert_equal(%w[middle old], User.where { :age > any(ages.call) }.order(:age).pluck(:name))
|
|
756
|
+
assert_equal(['old'], User.where { :age >= all(ages.call) }.pluck(:name))
|
|
757
|
+
assert_equal(['young'], User.where { :age <= all(ages.call) }.pluck(:name))
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# = ANY is IN and != ALL is NOT IN, which is worth a test because it is the
|
|
761
|
+
# part of the quantifiers the gem already had another spelling for.
|
|
762
|
+
def test_quantifier_equality_execution
|
|
763
|
+
skip_without_quantifiers
|
|
764
|
+
User.delete_all
|
|
765
|
+
User.create!([{name: 'young', age: 20}, {name: 'old', age: 60}])
|
|
766
|
+
young = -> { User.where(name: 'young').select(:age) }
|
|
767
|
+
assert_equal(['young'], User.where { :age == any(young.call) }.pluck(:name))
|
|
768
|
+
assert_equal(['old'], User.where { :age != all(young.call) }.pluck(:name))
|
|
769
|
+
end
|
|
770
|
+
|
|
409
771
|
# The subquery correlates with the outer table through qualified columns,
|
|
410
772
|
# and its own where block goes through the DSL too.
|
|
411
773
|
def test_exists
|
|
@@ -457,21 +819,21 @@ class TestBlockSyntax < Minitest::Test
|
|
|
457
819
|
# literal depends on the column type, so assert on the Arel node instead.
|
|
458
820
|
def test_equal_range_is_an_equality
|
|
459
821
|
node = ActiveRecord::Refined::AST::Comparison.new(:period, :==, 18..65).
|
|
460
|
-
to_arel(User.arel_table)
|
|
822
|
+
to_arel(User.arel_table, User)
|
|
461
823
|
assert_instance_of(Arel::Nodes::Equality, node)
|
|
462
824
|
assert_equal(18..65, node.right.value)
|
|
463
825
|
end
|
|
464
826
|
|
|
465
827
|
def test_equal_array_is_an_equality
|
|
466
828
|
node = ActiveRecord::Refined::AST::Comparison.new(:tags, :==, [1, 2, 3]).
|
|
467
|
-
to_arel(User.arel_table)
|
|
829
|
+
to_arel(User.arel_table, User)
|
|
468
830
|
assert_instance_of(Arel::Nodes::Equality, node)
|
|
469
831
|
assert_equal([1, 2, 3], node.right.value)
|
|
470
832
|
end
|
|
471
833
|
|
|
472
834
|
def test_not_equal_array_is_an_inequality
|
|
473
835
|
node = ActiveRecord::Refined::AST::Comparison.new(:tags, :!=, [1, 2, 3]).
|
|
474
|
-
to_arel(User.arel_table)
|
|
836
|
+
to_arel(User.arel_table, User)
|
|
475
837
|
assert_instance_of(Arel::Nodes::NotEqual, node)
|
|
476
838
|
assert_equal([1, 2, 3], node.right.value)
|
|
477
839
|
end
|
|
@@ -547,6 +909,65 @@ class TestBlockSyntax < Minitest::Test
|
|
|
547
909
|
assert_raises(ArgumentError) { Node.from('tree', as: :nodes) }
|
|
548
910
|
end
|
|
549
911
|
|
|
912
|
+
def test_from_cte_takes_the_alias_from_the_model
|
|
913
|
+
declared = Node.with(tree: Node.all)
|
|
914
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/, declared.from_cte(:tree).to_sql)
|
|
915
|
+
assert_equal(declared.from(:tree, as: :nodes).to_sql,
|
|
916
|
+
declared.from_cte(:tree).to_sql)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def test_from_cte_needs_a_symbol
|
|
920
|
+
assert_raises(ArgumentError) { Node.from_cte('tree') }
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# The name has to be one `with` declares, or the query is against a table
|
|
924
|
+
# nobody has -- which the database would say much later and less clearly.
|
|
925
|
+
def test_from_cte_needs_a_cte_of_that_name
|
|
926
|
+
e = assert_raises(ArgumentError) do
|
|
927
|
+
Node.with(tree: Node.all).from_cte(:tre).to_sql
|
|
928
|
+
end
|
|
929
|
+
assert_match(/names no CTE/, e.message)
|
|
930
|
+
assert_match(/:tree/, e.message)
|
|
931
|
+
|
|
932
|
+
e = assert_raises(ArgumentError) { Node.from_cte(:tree).to_sql }
|
|
933
|
+
assert_match(/declares none/, e.message)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Checked when the SQL is built, so where the CTE is declared in the chain
|
|
937
|
+
# does not matter.
|
|
938
|
+
def test_from_cte_takes_a_cte_declared_later
|
|
939
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/,
|
|
940
|
+
Node.from_cte(:tree).with(tree: Node.all).to_sql)
|
|
941
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/,
|
|
942
|
+
Node.from_cte(:tree).merge(Node.with(tree: Node.all)).to_sql)
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# from itself says nothing about CTEs and goes on taking any table.
|
|
946
|
+
def test_from_with_an_alias_is_not_checked
|
|
947
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/, Node.from(:tree, as: :nodes).to_sql)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# The alias is what lets a where find its column, which is the whole reason
|
|
951
|
+
# from_cte exists; without it the SQL names a table the query does not have.
|
|
952
|
+
def test_from_cte_leaves_where_able_to_qualify
|
|
953
|
+
Node.delete_all
|
|
954
|
+
root = Node.create!(name: 'root')
|
|
955
|
+
Node.create!(name: 'child', parent_id: root.id)
|
|
956
|
+
other = Node.create!(name: 'other root')
|
|
957
|
+
Node.create!(name: 'other child', parent_id: other.id)
|
|
958
|
+
forest = Node.with_recursive(
|
|
959
|
+
tree: [
|
|
960
|
+
Node.where { :parent_id.null? }.
|
|
961
|
+
select { [:id, :name, :parent_id, :id.as(:root_id)] },
|
|
962
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
963
|
+
select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
|
|
964
|
+
:tree[:root_id]] },
|
|
965
|
+
]
|
|
966
|
+
).from_cte(:tree)
|
|
967
|
+
assert_equal(%w[child root],
|
|
968
|
+
forest.where { :root_id == root.id }.pluck(:name).sort)
|
|
969
|
+
end
|
|
970
|
+
|
|
550
971
|
# A CTE is joined by name like any other table, so the recursive member's
|
|
551
972
|
# ON clause is a block rather than the string join Rails' own docs use.
|
|
552
973
|
def test_recursive_cte
|
|
@@ -574,33 +995,29 @@ class TestBlockSyntax < Minitest::Test
|
|
|
574
995
|
assert_equal(['child'], q.pluck(:name))
|
|
575
996
|
end
|
|
576
997
|
|
|
577
|
-
def
|
|
578
|
-
assert_sql(/SELECT SUM\("users"."age"\)/,
|
|
579
|
-
User.select { :age.sum }.to_sql)
|
|
998
|
+
def test_select_sum
|
|
999
|
+
assert_sql(/SELECT SUM\("users"."age"\)/, User.select { sum(:age) }.to_sql)
|
|
580
1000
|
end
|
|
581
1001
|
|
|
582
|
-
def
|
|
1002
|
+
def test_select_aggregate_of_qualified_column
|
|
583
1003
|
assert_sql(/SELECT COUNT\("users"."id"\)/,
|
|
584
|
-
User.select { :users[:id]
|
|
1004
|
+
User.select { count(:users[:id]) }.to_sql)
|
|
585
1005
|
end
|
|
586
1006
|
|
|
587
|
-
def
|
|
588
|
-
assert_sql(/SELECT
|
|
589
|
-
|
|
1007
|
+
def test_select_max_and_min
|
|
1008
|
+
assert_sql(/SELECT MAX\("users"."age"\)/, User.select { max(:age) }.to_sql)
|
|
1009
|
+
assert_sql(/SELECT MIN\("users"."age"\)/, User.select { min(:age) }.to_sql)
|
|
590
1010
|
end
|
|
591
1011
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def test_select_minimum
|
|
598
|
-
assert_sql(/SELECT MIN\("users"."age"\)/,
|
|
599
|
-
User.select { :age.minimum }.to_sql)
|
|
1012
|
+
# An aggregate is written as a call, the way SQL writes it; a column has no
|
|
1013
|
+
# method of its own for one.
|
|
1014
|
+
def test_aggregates_have_no_postfix_form
|
|
1015
|
+
assert_raises(NoMethodError) { User.select { :age.sum } }
|
|
1016
|
+
assert_raises(NoMethodError) { User.select { :age.average } }
|
|
600
1017
|
end
|
|
601
1018
|
|
|
602
1019
|
def test_having_aggregate
|
|
603
|
-
sql = User.group(:name).having { :age
|
|
1020
|
+
sql = User.group(:name).having { sum(:age) > 100 }.to_sql
|
|
604
1021
|
assert_sql(/GROUP BY "users"."name"/, sql)
|
|
605
1022
|
assert_sql(/HAVING SUM\("users"."age"\) > 100/, sql)
|
|
606
1023
|
end
|
|
@@ -621,7 +1038,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
621
1038
|
end
|
|
622
1039
|
|
|
623
1040
|
def test_select_count_star_alias
|
|
624
|
-
assert_sql(/SELECT COUNT\(\*\) AS cnt/,
|
|
1041
|
+
assert_sql(/SELECT COUNT\(\*\) AS "cnt"/,
|
|
625
1042
|
User.select { count(:*).as(:cnt) }.to_sql)
|
|
626
1043
|
end
|
|
627
1044
|
|
|
@@ -630,9 +1047,9 @@ class TestBlockSyntax < Minitest::Test
|
|
|
630
1047
|
User.select { count(:name, distinct: true) }.to_sql)
|
|
631
1048
|
end
|
|
632
1049
|
|
|
633
|
-
def
|
|
634
|
-
assert_sql(/SELECT COUNT\(DISTINCT "users"."name"\) AS n/,
|
|
635
|
-
User.select { :name
|
|
1050
|
+
def test_count_distinct_aliased
|
|
1051
|
+
assert_sql(/SELECT COUNT\(DISTINCT "users"."name"\) AS "n"/,
|
|
1052
|
+
User.select { count(:name, distinct: true).as(:n) }.to_sql)
|
|
636
1053
|
end
|
|
637
1054
|
|
|
638
1055
|
def test_count_distinct_in_having
|
|
@@ -690,11 +1107,6 @@ class TestBlockSyntax < Minitest::Test
|
|
|
690
1107
|
assert_sql(/HAVING SUM\("users"."age"\) > 100/, sql)
|
|
691
1108
|
end
|
|
692
1109
|
|
|
693
|
-
def test_function_and_method_syntax_match
|
|
694
|
-
assert_equal User.select { :age.average }.to_sql,
|
|
695
|
-
User.select { avg(:age) }.to_sql
|
|
696
|
-
end
|
|
697
|
-
|
|
698
1110
|
def test_upper_function
|
|
699
1111
|
assert_sql(/SELECT UPPER\("users"."name"\)/,
|
|
700
1112
|
User.select { upper(:name) }.to_sql)
|
|
@@ -723,7 +1135,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
723
1135
|
end
|
|
724
1136
|
|
|
725
1137
|
def test_fn_alias
|
|
726
|
-
assert_sql(/SELECT date_trunc\('day', "users"."name"\) AS d/,
|
|
1138
|
+
assert_sql(/SELECT date_trunc\('day', "users"."name"\) AS "d"/,
|
|
727
1139
|
User.select { fn(:date_trunc, 'day', :name).as(:d) }.to_sql)
|
|
728
1140
|
end
|
|
729
1141
|
|
|
@@ -732,10 +1144,46 @@ class TestBlockSyntax < Minitest::Test
|
|
|
732
1144
|
# given the chance to close the identifier and carry on.
|
|
733
1145
|
INJECTION = %q{a" AS x, (SELECT 1) AS "y}
|
|
734
1146
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1147
|
+
# An alias that is not a plain name is quoted by the adapter rather than
|
|
1148
|
+
# refused, so an injected one becomes an alias with a strange name and
|
|
1149
|
+
# nothing else. Each spells the quoting its own way, so what is asserted is
|
|
1150
|
+
# that the payload arrived as the name of the column it labelled.
|
|
1151
|
+
def test_an_injected_alias_is_quoted_rather_than_refused
|
|
1152
|
+
User.delete_all
|
|
1153
|
+
User.create!(name: 'alice')
|
|
1154
|
+
payload = 'a" FROM users; --'
|
|
1155
|
+
row = User.select { :name.as(payload.to_sym) }.first
|
|
1156
|
+
assert_equal('alice', row[payload])
|
|
1157
|
+
assert_equal(1, User.count)
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
def test_an_alias_that_needs_quoting_gets_it
|
|
1161
|
+
assert_sql(/AS "total sales"/, User.select { :name.as(:'total sales') }.to_sql)
|
|
1162
|
+
assert_sql(/AS "select"/, User.select { :name.as(:select, quote: true) }.to_sql)
|
|
1163
|
+
assert_sql(/AS "up per"/, User.select { upper(:name).as(:'up per') }.to_sql)
|
|
1164
|
+
assert_sql(/AS "d epth"/, User.select { 0.as(:'d epth') }.to_sql)
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Quoted, the name asked for is the name that comes back. Unquoted,
|
|
1168
|
+
# PostgreSQL would fold the capital away and the other two would keep it.
|
|
1169
|
+
def test_an_alias_keeps_the_name_as_written
|
|
1170
|
+
assert_sql(/AS "postCount"/, User.select { :name.as(:postCount) }.to_sql)
|
|
1171
|
+
User.delete_all
|
|
1172
|
+
User.create!(name: 'alice')
|
|
1173
|
+
assert_equal('alice', User.select { :name.as(:postCount) }.first['postCount'])
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
def test_quote_false_asks_for_the_name_as_it_is
|
|
1177
|
+
assert_sql(/AS post_count/, User.select { :name.as(:post_count, quote: false) }.to_sql)
|
|
1178
|
+
refute_match(/"post_count"/,
|
|
1179
|
+
normalize_sql(User.select { :name.as(:post_count, quote: false) }.to_sql))
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# Nothing quotes it, so a name that would be SQL has to be refused.
|
|
1183
|
+
def test_quote_false_refuses_a_name_that_is_not_plain
|
|
1184
|
+
e = assert_raises(ArgumentError) { User.select { :name.as(:'total sales', quote: false) } }
|
|
1185
|
+
assert_match(/plain column alias/, e.message)
|
|
1186
|
+
assert_raises(ArgumentError) { User.select { :name.as(INJECTION.to_sym, quote: false) } }
|
|
739
1187
|
end
|
|
740
1188
|
|
|
741
1189
|
def test_fn_rejects_an_injected_name
|
|
@@ -743,8 +1191,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
743
1191
|
end
|
|
744
1192
|
|
|
745
1193
|
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
|
|
1194
|
+
assert_sql(/AS "post_count"/, User.select { :name.as(:post_count) }.to_sql)
|
|
1195
|
+
assert_sql(/AS "名前"/, User.select { :name.as(:名前) }.to_sql)
|
|
748
1196
|
assert_sql(/SELECT myFunc\(/, User.select { fn(:myFunc, :name) }.to_sql)
|
|
749
1197
|
assert_sql(/SELECT pg_catalog.upper\(/,
|
|
750
1198
|
User.select { fn(:'pg_catalog.upper', :name) }.to_sql)
|
|
@@ -857,7 +1305,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
857
1305
|
def test_datetime_value_function_in_comparison_and_alias
|
|
858
1306
|
assert_sql(/WHERE "users"."name" < CURRENT_TIMESTAMP/,
|
|
859
1307
|
User.where { :name < current_timestamp }.to_sql)
|
|
860
|
-
assert_sql(/SELECT CURRENT_TIMESTAMP AS ts FROM/,
|
|
1308
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP AS "ts" FROM/,
|
|
861
1309
|
User.select { current_timestamp.as(:ts) }.to_sql)
|
|
862
1310
|
end
|
|
863
1311
|
|
|
@@ -1003,7 +1451,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1003
1451
|
end
|
|
1004
1452
|
|
|
1005
1453
|
def test_arithmetic_multiplication
|
|
1006
|
-
assert_sql(/SELECT "users"."age" \* 2 AS dbl/,
|
|
1454
|
+
assert_sql(/SELECT "users"."age" \* 2 AS "dbl"/,
|
|
1007
1455
|
User.select { (:age * 2).as(:dbl) }.to_sql)
|
|
1008
1456
|
end
|
|
1009
1457
|
|
|
@@ -1023,11 +1471,6 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1023
1471
|
User.select { sum(:age * 2) }.to_sql)
|
|
1024
1472
|
end
|
|
1025
1473
|
|
|
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
1474
|
# Arel groups + and - but not * and /, which is how SQL precedence works
|
|
1032
1475
|
# out anyway.
|
|
1033
1476
|
def test_arithmetic_on_qualified_column
|
|
@@ -1035,6 +1478,110 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1035
1478
|
User.select { :users[:age] - 1 }.to_sql)
|
|
1036
1479
|
end
|
|
1037
1480
|
|
|
1481
|
+
def test_bitwise_and_or
|
|
1482
|
+
assert_sql(/SELECT \("users"."flags" & 4\) AS "masked"/,
|
|
1483
|
+
User.select { (:flags & 4).as(:masked) }.to_sql)
|
|
1484
|
+
assert_sql(/SELECT \("users"."flags" \| 4\) AS "set"/,
|
|
1485
|
+
User.select { (:flags | 4).as(:set) }.to_sql)
|
|
1486
|
+
end
|
|
1487
|
+
|
|
1488
|
+
# Ruby puts & above >, so this groups the way it reads, and the node
|
|
1489
|
+
# parenthesises itself so that the adapter's own precedence cannot regroup
|
|
1490
|
+
# it -- PostgreSQL gives & and | the same one.
|
|
1491
|
+
def test_bitwise_in_where_without_parentheses
|
|
1492
|
+
assert_sql(/WHERE \("users"."flags" & 4\) > 0/,
|
|
1493
|
+
User.where { :flags & 4 > 0 }.to_sql)
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
def test_bitwise_shifts
|
|
1497
|
+
assert_sql(/SELECT \("users"."flags" << 2\)/, User.select { :flags << 2 }.to_sql)
|
|
1498
|
+
assert_sql(/SELECT \("users"."flags" >> 1\)/, User.select { :flags >> 1 }.to_sql)
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
def test_bitwise_not
|
|
1502
|
+
assert_sql(/SELECT \( ~ "users"."flags"\)/, User.select { ~:flags }.to_sql)
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# The one operator the three do not share: PostgreSQL's # is where a comment
|
|
1506
|
+
# starts on MySQL, MySQL's ^ is exponentiation to PostgreSQL, and SQLite has
|
|
1507
|
+
# neither, so it gets the two operations XOR is made of.
|
|
1508
|
+
def test_bitwise_xor_is_spelled_per_adapter
|
|
1509
|
+
sql = User.select { :flags ^ 10 }.to_sql
|
|
1510
|
+
case ADAPTER
|
|
1511
|
+
when 'postgresql' then assert_sql(/SELECT \("users"."flags" # 10\)/, sql)
|
|
1512
|
+
when 'mysql2' then assert_sql(/SELECT \("users"."flags" \^ 10\)/, sql)
|
|
1513
|
+
else assert_sql(
|
|
1514
|
+
/SELECT \(\("users"."flags" \| 10\) - \("users"."flags" & 10\)\)/, sql)
|
|
1515
|
+
end
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
# Whatever the spelling, the answers agree.
|
|
1519
|
+
def test_bitwise_execution
|
|
1520
|
+
User.delete_all
|
|
1521
|
+
User.create!(name: 'a', flags: 12)
|
|
1522
|
+
assert_equal(8, User.select { (:flags & 10).as(:v) }.take.v.to_i)
|
|
1523
|
+
assert_equal(14, User.select { (:flags | 10).as(:v) }.take.v.to_i)
|
|
1524
|
+
assert_equal(6, User.select { (:flags ^ 10).as(:v) }.take.v.to_i)
|
|
1525
|
+
assert_equal(48, User.select { (:flags << 2).as(:v) }.take.v.to_i)
|
|
1526
|
+
assert_equal(6, User.select { (:flags >> 1).as(:v) }.take.v.to_i)
|
|
1527
|
+
# MariaDB reads ~ back as the unsigned 64-bit number where the others give
|
|
1528
|
+
# a negative one, so the assertion is on the bits rather than the value.
|
|
1529
|
+
assert_equal(243, User.select { (~:flags & 255).as(:v) }.take.v.to_i)
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
# AND and OR are the conditions' own & and |, and an operand that is a
|
|
1533
|
+
# condition means one of the two was meant.
|
|
1534
|
+
def test_bitwise_refuses_a_condition
|
|
1535
|
+
e = assert_raises(ArgumentError) { User.where { :flags & (:age == 1) } }
|
|
1536
|
+
assert_match(/AND and OR/, e.message)
|
|
1537
|
+
end
|
|
1538
|
+
|
|
1539
|
+
# MySQL and SQLite would take a boolean for the bit it is stored as and
|
|
1540
|
+
# quietly answer as AND would; PostgreSQL has no such operator.
|
|
1541
|
+
def test_bitwise_refuses_a_boolean_column
|
|
1542
|
+
e = assert_raises(ArgumentError) { User.where { (:active & :active) > 0 }.to_sql }
|
|
1543
|
+
assert_match(/true\?/, e.message)
|
|
1544
|
+
assert_raises(ArgumentError) { User.select { ~:active }.to_sql }
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
def test_conditions_still_and_with_the_same_operators
|
|
1548
|
+
assert_sql(/WHERE "users"."age" = 1 AND "users"."name" = 'a'/,
|
|
1549
|
+
User.where { (:age == 1) & (:name == 'a') }.to_sql)
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def test_bit_aggregates
|
|
1553
|
+
skip_without_bit_aggregates
|
|
1554
|
+
User.delete_all
|
|
1555
|
+
User.create!([{name: 'a', flags: 12}, {name: 'b', flags: 10}, {name: 'c', flags: 3}])
|
|
1556
|
+
assert_equal(0, User.select { bit_and(:flags).as(:v) }.take.v.to_i)
|
|
1557
|
+
assert_equal(15, User.select { bit_or(:flags).as(:v) }.take.v.to_i)
|
|
1558
|
+
assert_equal(5, User.select { bit_xor(:flags).as(:v) }.take.v.to_i)
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# PostgreSQL counts the bits of a bit string rather than of a number, so the
|
|
1562
|
+
# argument is cast there; bit(64) is what makes a negative answer alike.
|
|
1563
|
+
def test_bit_count
|
|
1564
|
+
if ADAPTER == 'sqlite3'
|
|
1565
|
+
assert_raises(NotImplementedError) { User.select { bit_count(:flags) } }
|
|
1566
|
+
return
|
|
1567
|
+
end
|
|
1568
|
+
User.delete_all
|
|
1569
|
+
User.create!(name: 'a', flags: 12)
|
|
1570
|
+
User.create!(name: 'b', flags: -1)
|
|
1571
|
+
assert_equal([2, 64], User.select { bit_count(:flags).as(:v) }.order(:name).map {|u| u.v.to_i })
|
|
1572
|
+
assert_sql(/BIT_COUNT\(CAST\("users"."flags" AS bit\(64\)\)\)/,
|
|
1573
|
+
User.select { bit_count(:flags) }.to_sql) if ADAPTER == 'postgresql'
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
def test_bit_aggregates_are_unsupported_on_sqlite
|
|
1577
|
+
if ADAPTER == 'sqlite3'
|
|
1578
|
+
e = assert_raises(NotImplementedError) { User.select { bit_or(:flags) } }
|
|
1579
|
+
assert_match(/bit_or/, e.message)
|
|
1580
|
+
else
|
|
1581
|
+
assert_sql(/BIT_OR\("users"."flags"\)/, User.select { bit_or(:flags) }.to_sql)
|
|
1582
|
+
end
|
|
1583
|
+
end
|
|
1584
|
+
|
|
1038
1585
|
def test_coalesce_function_with_literal
|
|
1039
1586
|
assert_sql(/SELECT COALESCE\("users"."name", 'unknown'\)/,
|
|
1040
1587
|
User.select { coalesce(:name, 'unknown') }.to_sql)
|
|
@@ -1057,7 +1604,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1057
1604
|
|
|
1058
1605
|
def test_aggregate_in
|
|
1059
1606
|
assert_sql(/HAVING SUM\("users"."age"\) BETWEEN 1 AND 10/,
|
|
1060
|
-
User.group(:name).having { :age.
|
|
1607
|
+
User.group(:name).having { sum(:age).in?(1..10) }.to_sql)
|
|
1061
1608
|
end
|
|
1062
1609
|
|
|
1063
1610
|
def test_nested_function
|
|
@@ -1086,22 +1633,22 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1086
1633
|
end
|
|
1087
1634
|
|
|
1088
1635
|
def test_select_function_with_alias
|
|
1089
|
-
assert_sql(/SELECT UPPER\("users"."name"\) AS upper_name, "users"."age"/,
|
|
1636
|
+
assert_sql(/SELECT UPPER\("users"."name"\) AS "upper_name", "users"."age"/,
|
|
1090
1637
|
User.select { [upper(:name).as(:upper_name), :age] }.to_sql)
|
|
1091
1638
|
end
|
|
1092
1639
|
|
|
1093
1640
|
def test_select_column_alias
|
|
1094
|
-
assert_sql(/SELECT "users"."name" AS n/,
|
|
1641
|
+
assert_sql(/SELECT "users"."name" AS "n"/,
|
|
1095
1642
|
User.select { :name.as(:n) }.to_sql)
|
|
1096
1643
|
end
|
|
1097
1644
|
|
|
1098
1645
|
def test_select_qualified_column_alias
|
|
1099
|
-
assert_sql(/SELECT "users"."name" AS n/,
|
|
1646
|
+
assert_sql(/SELECT "users"."name" AS "n"/,
|
|
1100
1647
|
User.select { :users[:name].as(:n) }.to_sql)
|
|
1101
1648
|
end
|
|
1102
1649
|
|
|
1103
1650
|
def test_select_aggregate_alias
|
|
1104
|
-
assert_sql(/SELECT COUNT\("users"."id"\) AS cnt/,
|
|
1651
|
+
assert_sql(/SELECT COUNT\("users"."id"\) AS "cnt"/,
|
|
1105
1652
|
User.select { count(:id).as(:cnt) }.to_sql)
|
|
1106
1653
|
end
|
|
1107
1654
|
|
|
@@ -1175,8 +1722,510 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1175
1722
|
assert_sql(/HAVING SUM\("users"."age"\) > 100/, sql)
|
|
1176
1723
|
end
|
|
1177
1724
|
|
|
1725
|
+
# update_all's hash reads a symbol as the value it is; the block reads it as
|
|
1726
|
+
# the column it names, which is what lets the new value be built from the old.
|
|
1727
|
+
def test_update_all_from_the_column
|
|
1728
|
+
Tally.delete_all
|
|
1729
|
+
Tally.create!(page: '/a', hits: 1)
|
|
1730
|
+
Tally.create!(page: '/b', hits: 2)
|
|
1731
|
+
Tally.update_all { { hits: :hits + 1 } }
|
|
1732
|
+
assert_equal([2, 3], Tally.order(:page).pluck(:hits))
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1735
|
+
def test_update_all_takes_any_expression
|
|
1736
|
+
Tally.delete_all
|
|
1737
|
+
Tally.create!(page: '/a', hits: 5)
|
|
1738
|
+
Tally.update_all { { hits: case_when { :hits > 4 }.then(0).else(:hits), page: upper(:page) } }
|
|
1739
|
+
assert_equal([['/A', 0]], Tally.pluck(:page, :hits))
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
def test_update_all_within_a_scope
|
|
1743
|
+
Tally.delete_all
|
|
1744
|
+
Tally.create!(page: '/a', hits: 1)
|
|
1745
|
+
Tally.create!(page: '/b', hits: 1)
|
|
1746
|
+
Tally.where { :page == '/a' }.update_all { { hits: 9 } }
|
|
1747
|
+
assert_equal([9, 1], Tally.order(:page).pluck(:hits))
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
def test_update_all_without_a_block_is_unchanged
|
|
1751
|
+
Tally.delete_all
|
|
1752
|
+
Tally.create!(page: '/a', hits: 1)
|
|
1753
|
+
Tally.update_all(hits: 4)
|
|
1754
|
+
assert_equal([4], Tally.pluck(:hits))
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
def test_update_all_takes_updates_or_a_block
|
|
1758
|
+
assert_raises(ArgumentError) { Tally.update_all({hits: 1}) { { hits: 2 } } }
|
|
1759
|
+
e = assert_raises(ArgumentError) { Tally.update_all { :hits + 1 } }
|
|
1760
|
+
assert_match(/hash of column/, e.message)
|
|
1761
|
+
end
|
|
1762
|
+
|
|
1763
|
+
# on_duplicate takes SQL text and nothing else, so the block is compiled to
|
|
1764
|
+
# some. `excluded` is the row that could not be inserted.
|
|
1765
|
+
def test_upsert_all_adds_to_what_is_there
|
|
1766
|
+
Tally.delete_all
|
|
1767
|
+
Tally.upsert_all([{page: '/a', hits: 1}], **upsert_target)
|
|
1768
|
+
Tally.upsert_all([{page: '/a', hits: 10}], **upsert_target) {
|
|
1769
|
+
{ hits: :hits + excluded(:hits) }
|
|
1770
|
+
}
|
|
1771
|
+
assert_equal([11], Tally.pluck(:hits))
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
def test_upsert_all_inserts_when_there_is_no_conflict
|
|
1775
|
+
Tally.delete_all
|
|
1776
|
+
Tally.upsert_all([{page: '/new', hits: 3}], **upsert_target) {
|
|
1777
|
+
{ hits: :hits + excluded(:hits) }
|
|
1778
|
+
}
|
|
1779
|
+
assert_equal([3], Tally.pluck(:hits))
|
|
1780
|
+
end
|
|
1781
|
+
|
|
1782
|
+
def test_upsert_all_takes_any_expression
|
|
1783
|
+
Tally.delete_all
|
|
1784
|
+
Tally.upsert_all([{page: '/a', hits: 7}], **upsert_target)
|
|
1785
|
+
Tally.upsert_all([{page: '/a', hits: 2}], **upsert_target) {
|
|
1786
|
+
{ hits: greatest(:hits, excluded(:hits)) }
|
|
1787
|
+
}
|
|
1788
|
+
assert_equal([7], Tally.pluck(:hits))
|
|
1789
|
+
end
|
|
1790
|
+
|
|
1791
|
+
def test_upsert_all_without_a_block_is_unchanged
|
|
1792
|
+
Tally.delete_all
|
|
1793
|
+
Tally.upsert_all([{page: '/a', hits: 1}], **upsert_target)
|
|
1794
|
+
Tally.upsert_all([{page: '/a', hits: 6}], **upsert_target)
|
|
1795
|
+
assert_equal([6], Tally.pluck(:hits))
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
def test_upsert_all_takes_on_duplicate_or_a_block
|
|
1799
|
+
assert_raises(ArgumentError) do
|
|
1800
|
+
Tally.upsert_all([{page: '/a', hits: 1}],
|
|
1801
|
+
on_duplicate: Arel.sql('hits = 1'), **upsert_target) { { hits: 2 } }
|
|
1802
|
+
end
|
|
1803
|
+
e = assert_raises(ArgumentError) do
|
|
1804
|
+
Tally.upsert_all([{page: '/a', hits: 1}], **upsert_target) { {} }
|
|
1805
|
+
end
|
|
1806
|
+
assert_match(/at least one column/, e.message)
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1809
|
+
# Reading inside a JSON document, by the name of what Hash does. No two
|
|
1810
|
+
# adapters spell it alike, so what the tests assert is the value that comes
|
|
1811
|
+
# back rather than the SQL.
|
|
1812
|
+
def seed_docs
|
|
1813
|
+
Doc.delete_all
|
|
1814
|
+
Doc.create!(name: 'one',
|
|
1815
|
+
meta: json_document({ 'a' => { 'b' => 'deep' }, 'n' => 5,
|
|
1816
|
+
'tags' => %w[x y], 'odd key' => 1 }))
|
|
1817
|
+
Doc.create!(name: 'two', meta: json_document({ 'n' => 9 }))
|
|
1818
|
+
end
|
|
1819
|
+
|
|
1820
|
+
def test_dig_a_key
|
|
1821
|
+
seed_docs
|
|
1822
|
+
assert_equal(%w[5 9], Doc.order(:name).select { :meta.dig(:n).as(:v) }.map(&:v))
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
def test_dig_a_path
|
|
1826
|
+
seed_docs
|
|
1827
|
+
assert_equal(['deep', nil],
|
|
1828
|
+
Doc.order(:name).select { :meta.dig(:a, :b).as(:v) }.map(&:v))
|
|
1829
|
+
end
|
|
1830
|
+
|
|
1831
|
+
def test_dig_an_array_index
|
|
1832
|
+
seed_docs
|
|
1833
|
+
assert_equal(['x', nil],
|
|
1834
|
+
Doc.order(:name).select { :meta.dig(:tags, 0).as(:v) }.map(&:v))
|
|
1835
|
+
end
|
|
1836
|
+
|
|
1837
|
+
# A key that is not a plain name travels as itself rather than being refused.
|
|
1838
|
+
def test_dig_a_key_that_needs_quoting
|
|
1839
|
+
seed_docs
|
|
1840
|
+
assert_equal(['1', nil],
|
|
1841
|
+
Doc.order(:name).select { :meta.dig(:'odd key').as(:v) }.map(&:v))
|
|
1842
|
+
end
|
|
1843
|
+
|
|
1844
|
+
# dig gives text on every adapter -- SQLite's ->> would otherwise give the
|
|
1845
|
+
# value with its type -- so a number is compared through a cast.
|
|
1846
|
+
def test_dig_is_text_everywhere
|
|
1847
|
+
seed_docs
|
|
1848
|
+
assert_equal(['one'], Doc.where { :meta.dig(:n) == '5' }.pluck(:name))
|
|
1849
|
+
type = integer_type
|
|
1850
|
+
assert_equal(['two'], Doc.where { cast(:meta.dig(:n), type) > 6 }.pluck(:name))
|
|
1851
|
+
end
|
|
1852
|
+
|
|
1853
|
+
def test_dig_json_keeps_the_json
|
|
1854
|
+
seed_docs
|
|
1855
|
+
value = Doc.where { :name == 'one' }.select { :meta.dig_json(:tags).as(:v) }.first.v
|
|
1856
|
+
assert_equal(%w[x y], value.is_a?(String) ? JSON.parse(value) : value)
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
def test_dig_from_a_qualified_column
|
|
1860
|
+
seed_docs
|
|
1861
|
+
assert_equal(['one'], Doc.where { :docs[:meta].dig(:a, :b) == 'deep' }.pluck(:name))
|
|
1862
|
+
end
|
|
1863
|
+
|
|
1864
|
+
def test_key
|
|
1865
|
+
seed_docs
|
|
1866
|
+
assert_equal(['one'], Doc.where { :meta.key?(:tags) }.pluck(:name))
|
|
1867
|
+
assert_equal(%w[one two], Doc.where { :meta.key?(:n) }.order(:name).pluck(:name))
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1870
|
+
def test_contains
|
|
1871
|
+
skip_without_json_containment
|
|
1872
|
+
seed_docs
|
|
1873
|
+
assert_equal(['one'], Doc.where { :meta.contains?(n: 5) }.pluck(:name))
|
|
1874
|
+
assert_equal([], Doc.where { :meta.contains?(n: 1) }.pluck(:name))
|
|
1875
|
+
end
|
|
1876
|
+
|
|
1877
|
+
def test_contains_says_where_it_cannot_go
|
|
1878
|
+
skip "#{ADAPTER} has JSON containment" unless ADAPTER == 'sqlite3'
|
|
1879
|
+
assert_raises(NotImplementedError) { Doc.where { :meta.contains?(n: 5) }.to_sql }
|
|
1880
|
+
end
|
|
1881
|
+
|
|
1882
|
+
def test_dig_needs_a_path
|
|
1883
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig } }
|
|
1884
|
+
e = assert_raises(ArgumentError) { Doc.select { :meta.dig(1.5) } }
|
|
1885
|
+
assert_match(/key or an array index/, e.message)
|
|
1886
|
+
end
|
|
1887
|
+
|
|
1888
|
+
# FILTER takes the aggregate over the rows a condition holds for. MySQL has
|
|
1889
|
+
# no such clause, so what is asserted across adapters is the number that
|
|
1890
|
+
# comes back rather than the SQL.
|
|
1891
|
+
def seed_for_filter
|
|
1892
|
+
User.delete_all
|
|
1893
|
+
User.create!(name: 'a', age: 10)
|
|
1894
|
+
User.create!(name: 'a', age: 20)
|
|
1895
|
+
User.create!(name: 'b', age: 100)
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
def aggregate(&block)
|
|
1899
|
+
User.select(&block).to_a.first.v
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
def test_filter_a_count
|
|
1903
|
+
seed_for_filter
|
|
1904
|
+
assert_equal(2, aggregate { count(:*).filter { :age < 50 }.as(:v) }.to_i)
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
def test_filter_takes_a_value_as_well_as_a_block
|
|
1908
|
+
seed_for_filter
|
|
1909
|
+
assert_equal(2, aggregate { count(:*).filter(:age < 50).as(:v) }.to_i)
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
def test_filter_a_sum_and_an_average
|
|
1913
|
+
seed_for_filter
|
|
1914
|
+
assert_equal(30, aggregate { sum(:age).filter { :age < 50 }.as(:v) }.to_i)
|
|
1915
|
+
assert_equal(15, aggregate { avg(:age).filter { :age < 50 }.as(:v) }.to_i)
|
|
1916
|
+
end
|
|
1917
|
+
|
|
1918
|
+
def test_filter_a_distinct_count
|
|
1919
|
+
seed_for_filter
|
|
1920
|
+
assert_equal(1, aggregate { count(:name, distinct: true).filter { :age < 50 }.as(:v) }.to_i)
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
def test_filter_is_a_clause_where_there_is_one
|
|
1924
|
+
skip "#{ADAPTER} has no FILTER" if ADAPTER == 'mysql2'
|
|
1925
|
+
assert_sql(/COUNT\(\*\) FILTER \(WHERE "users"."age" < 50\)/,
|
|
1926
|
+
User.select { count(:*).filter { :age < 50 } }.to_sql)
|
|
1927
|
+
end
|
|
1928
|
+
|
|
1929
|
+
# Where there is not, the same rows are reached through a case: an aggregate
|
|
1930
|
+
# passes over a NULL, so a row the condition misses is a row it does not see.
|
|
1931
|
+
def test_filter_becomes_a_case_where_there_is_no_clause
|
|
1932
|
+
skip "#{ADAPTER} has FILTER" unless ADAPTER == 'mysql2'
|
|
1933
|
+
assert_sql(/COUNT\(CASE WHEN "users"."age" < 50 THEN 1 END\)/,
|
|
1934
|
+
User.select { count(:*).filter { :age < 50 } }.to_sql)
|
|
1935
|
+
assert_sql(/SUM\(CASE WHEN "users"."age" < 50 THEN "users"."age" END\)/,
|
|
1936
|
+
User.select { sum(:age).filter { :age < 50 } }.to_sql)
|
|
1937
|
+
end
|
|
1938
|
+
|
|
1939
|
+
def test_filter_needs_a_value_or_a_block
|
|
1940
|
+
assert_raises(ArgumentError) { User.select { count(:*).filter } }
|
|
1941
|
+
e = assert_raises(ArgumentError) { User.select { count(:*).filter(1) { 2 } } }
|
|
1942
|
+
assert_match(/not both/, e.message)
|
|
1943
|
+
end
|
|
1944
|
+
|
|
1945
|
+
# DISTINCT ON keeps the first row of each group the order brings up.
|
|
1946
|
+
def seed_for_distinct_on
|
|
1947
|
+
Author.delete_all
|
|
1948
|
+
Author.create!(name: 'a')
|
|
1949
|
+
Author.create!(name: 'a')
|
|
1950
|
+
Author.create!(name: 'b')
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
def test_distinct_on
|
|
1954
|
+
skip_without_distinct_on
|
|
1955
|
+
seed_for_distinct_on
|
|
1956
|
+
assert_sql(/SELECT DISTINCT ON \( "authors"."name" \)/,
|
|
1957
|
+
Author.distinct_on { :name }.to_sql)
|
|
1958
|
+
assert_equal(%w[a b], Author.distinct_on { :name }.order { :name }.pluck(:name))
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
def test_distinct_on_takes_columns_as_well_as_a_block
|
|
1962
|
+
skip_without_distinct_on
|
|
1963
|
+
assert_equal(Author.distinct_on { :name }.to_sql, Author.distinct_on(:name).to_sql)
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
def test_distinct_on_takes_several
|
|
1967
|
+
skip_without_distinct_on
|
|
1968
|
+
assert_sql(/DISTINCT ON \( "authors"."id", "authors"."name" \)/,
|
|
1969
|
+
Author.distinct_on { [:id, :name] }.to_sql)
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
def test_distinct_on_takes_an_expression
|
|
1973
|
+
skip_without_distinct_on
|
|
1974
|
+
assert_sql(/DISTINCT ON \( UPPER\("authors"."name"\) \)/,
|
|
1975
|
+
Author.distinct_on { upper(:name) }.to_sql)
|
|
1976
|
+
end
|
|
1977
|
+
|
|
1978
|
+
# Arel carries the node and refuses to write it elsewhere, as it does a
|
|
1979
|
+
# regexp, so the gem has nothing of its own to say.
|
|
1980
|
+
def test_distinct_on_says_where_it_cannot_go
|
|
1981
|
+
skip "#{ADAPTER} has DISTINCT ON" if ADAPTER == 'postgresql'
|
|
1982
|
+
assert_raises(NotImplementedError) { Author.distinct_on { :name }.to_sql }
|
|
1983
|
+
end
|
|
1984
|
+
|
|
1985
|
+
def test_distinct_on_needs_a_column
|
|
1986
|
+
assert_raises(ArgumentError) { Author.distinct_on }
|
|
1987
|
+
end
|
|
1988
|
+
|
|
1989
|
+
def test_distinct_on_spawns
|
|
1990
|
+
refute_match(/DISTINCT ON/, Author.all.to_sql)
|
|
1991
|
+
Author.distinct_on { :name }
|
|
1992
|
+
refute_match(/DISTINCT ON/, Author.all.to_sql)
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
# A lateral join lets the relation joined see the row being joined to, which
|
|
1996
|
+
# is what makes the top row of each group reachable in one query.
|
|
1997
|
+
def top_post
|
|
1998
|
+
Post.select { :title }.
|
|
1999
|
+
where { :posts[:author_id] == :authors[:id] }.
|
|
2000
|
+
order { :title.desc }.limit(1)
|
|
2001
|
+
end
|
|
2002
|
+
|
|
2003
|
+
def seed_for_lateral
|
|
2004
|
+
Author.delete_all
|
|
2005
|
+
Post.delete_all
|
|
2006
|
+
author = Author.create!(name: 'writes')
|
|
2007
|
+
Author.create!(name: 'does not')
|
|
2008
|
+
Post.create!(author_id: author.id, title: 'a')
|
|
2009
|
+
Post.create!(author_id: author.id, title: 'b')
|
|
2010
|
+
end
|
|
2011
|
+
|
|
2012
|
+
def test_lateral_join
|
|
2013
|
+
skip_without_lateral
|
|
2014
|
+
seed_for_lateral
|
|
2015
|
+
rows = Author.joins(top_post, as: :top, lateral: true).
|
|
2016
|
+
select { [:name, :top[:title].as(:v)] }.map {|r| [r.name, r.v] }
|
|
2017
|
+
assert_equal([['writes', 'b']], rows)
|
|
2018
|
+
end
|
|
2019
|
+
|
|
2020
|
+
# Left, so that a row with nothing to join to is kept.
|
|
2021
|
+
def test_left_outer_lateral_join
|
|
2022
|
+
skip_without_lateral
|
|
2023
|
+
seed_for_lateral
|
|
2024
|
+
rows = Author.left_outer_joins(top_post, as: :top, lateral: true).
|
|
2025
|
+
select { [:name, :top[:title].as(:v)] }.order { :name }.map {|r| [r.name, r.v] }
|
|
2026
|
+
assert_equal([['does not', nil], ['writes', 'b']], rows)
|
|
2027
|
+
end
|
|
2028
|
+
|
|
2029
|
+
# Without a block the join is ON TRUE; what the subquery may see is said
|
|
2030
|
+
# inside it.
|
|
2031
|
+
def test_lateral_join_takes_an_on_clause
|
|
2032
|
+
skip_without_lateral
|
|
2033
|
+
seed_for_lateral
|
|
2034
|
+
assert_equal(0, Author.joins(top_post, as: :top, lateral: true) {
|
|
2035
|
+
:top[:title] == 'nothing'
|
|
2036
|
+
}.count)
|
|
2037
|
+
assert_sql(/ON TRUE/, Author.joins(top_post, as: :top, lateral: true).to_sql)
|
|
2038
|
+
end
|
|
2039
|
+
|
|
2040
|
+
def test_lateral_join_needs_a_relation_and_a_name
|
|
2041
|
+
e = assert_raises(ArgumentError) { Author.joins(:posts, as: :top, lateral: true) }
|
|
2042
|
+
assert_match(/takes a relation/, e.message)
|
|
2043
|
+
e = assert_raises(ArgumentError) { Author.joins(top_post, lateral: true) }
|
|
2044
|
+
assert_match(/needs a name/, e.message)
|
|
2045
|
+
end
|
|
2046
|
+
|
|
2047
|
+
def test_lateral_join_says_where_it_cannot_go
|
|
2048
|
+
skip 'this one has LATERAL' if ADAPTER == 'postgresql' || (ADAPTER == 'mysql2' && !mariadb?)
|
|
2049
|
+
e = assert_raises(NotImplementedError) { Author.joins(top_post, as: :top, lateral: true) }
|
|
2050
|
+
assert_match(/lateral join has no equivalent/, e.message)
|
|
2051
|
+
end
|
|
2052
|
+
|
|
2053
|
+
# Several groupings asked for at once, the totals of each coming back beside
|
|
2054
|
+
# the rows. What is asserted is the rows, since the point is which totals
|
|
2055
|
+
# arrive rather than how the clause is spelled.
|
|
2056
|
+
def seed_for_grouping
|
|
2057
|
+
Post.delete_all
|
|
2058
|
+
Author.delete_all
|
|
2059
|
+
a = Author.create!(name: 'a')
|
|
2060
|
+
b = Author.create!(name: 'b')
|
|
2061
|
+
Post.create!(author_id: a.id, title: 'x')
|
|
2062
|
+
Post.create!(author_id: a.id, title: 'y')
|
|
2063
|
+
Post.create!(author_id: b.id, title: 'x')
|
|
2064
|
+
end
|
|
2065
|
+
|
|
2066
|
+
def grouped(&block)
|
|
2067
|
+
Post.group(&block).select { [:author_id, :title, count(:*).as(:n)] }.
|
|
2068
|
+
map {|r| [r.author_id, r.title, r.n.to_i] }.sort_by(&:to_s)
|
|
2069
|
+
end
|
|
2070
|
+
|
|
2071
|
+
def test_grouping_sets
|
|
2072
|
+
skip_without_grouping_sets
|
|
2073
|
+
seed_for_grouping
|
|
2074
|
+
rows = grouped { grouping_sets([:author_id], [:title], []) }
|
|
2075
|
+
assert_equal(3, rows.count {|_, title, _| title.nil? }) # by author
|
|
2076
|
+
assert_includes(rows, [nil, 'x', 2]) # by title
|
|
2077
|
+
assert_includes(rows, [nil, nil, 3]) # the whole
|
|
2078
|
+
end
|
|
2079
|
+
|
|
2080
|
+
def test_rollup
|
|
2081
|
+
skip_without_grouping_sets
|
|
2082
|
+
seed_for_grouping
|
|
2083
|
+
rows = grouped { rollup(:author_id, :title) }
|
|
2084
|
+
assert_includes(rows, [nil, nil, 3])
|
|
2085
|
+
assert_sql(/GROUP BY ROLLUP\( "posts"."author_id", "posts"."title" \)/,
|
|
2086
|
+
Post.group { rollup(:author_id, :title) }.to_sql)
|
|
2087
|
+
end
|
|
2088
|
+
|
|
2089
|
+
def test_cube
|
|
2090
|
+
skip_without_grouping_sets
|
|
2091
|
+
seed_for_grouping
|
|
2092
|
+
assert_sql(/GROUP BY CUBE\( "posts"."author_id", "posts"."title" \)/,
|
|
2093
|
+
Post.group { cube(:author_id, :title) }.to_sql)
|
|
2094
|
+
# Every combination: by both, by each, and the whole.
|
|
2095
|
+
assert_equal(8, Post.group { cube(:author_id, :title) }.select { count(:*).as(:n) }.to_a.size)
|
|
2096
|
+
end
|
|
2097
|
+
|
|
2098
|
+
def test_grouping_sets_say_where_they_cannot_go
|
|
2099
|
+
skip 'PostgreSQL has them' if ADAPTER == 'postgresql'
|
|
2100
|
+
assert_raises(NotImplementedError) { Post.group { grouping_sets([:title]) } }
|
|
2101
|
+
assert_raises(NotImplementedError) { Post.group { rollup(:title) } }
|
|
2102
|
+
assert_raises(NotImplementedError) { Post.group { cube(:title) } }
|
|
2103
|
+
end
|
|
2104
|
+
|
|
2105
|
+
def test_grouping_sets_need_something_to_group_by
|
|
2106
|
+
assert_raises(ArgumentError) { Post.group { rollup } }
|
|
2107
|
+
assert_raises(ArgumentError) { Post.group { grouping_sets } }
|
|
2108
|
+
end
|
|
2109
|
+
|
|
2110
|
+
# bury sets what dig reads. The document comes back changed rather than
|
|
2111
|
+
# being written anywhere, so update_all is what makes it stick.
|
|
2112
|
+
def buried(&block)
|
|
2113
|
+
seed_docs
|
|
2114
|
+
Doc.where { :name == 'one' }.update_all(&block)
|
|
2115
|
+
value = Doc.find_by(name: 'one').meta
|
|
2116
|
+
value.is_a?(String) ? JSON.parse(value) : value
|
|
2117
|
+
end
|
|
2118
|
+
|
|
2119
|
+
def test_bury_a_nested_key
|
|
2120
|
+
assert_equal('new', buried { { meta: :meta.bury(:a, :b, 'new') } }.dig('a', 'b'))
|
|
2121
|
+
end
|
|
2122
|
+
|
|
2123
|
+
def test_bury_a_key_that_is_not_there_yet
|
|
2124
|
+
assert_equal(9, buried { { meta: :meta.bury(:fresh, 9) } }['fresh'])
|
|
2125
|
+
end
|
|
2126
|
+
|
|
2127
|
+
# A whole document, which each adapter takes its own way round.
|
|
2128
|
+
def test_bury_an_object_and_an_array
|
|
2129
|
+
assert_equal({ 'x' => 1 }, buried { { meta: :meta.bury(:obj, { 'x' => 1 }) } }['obj'])
|
|
2130
|
+
assert_equal([1, 2], buried { { meta: :meta.bury(:arr, [1, 2]) } }['arr'])
|
|
2131
|
+
end
|
|
2132
|
+
|
|
2133
|
+
def test_bury_an_array_index
|
|
2134
|
+
assert_equal(%w[7 y], buried { { meta: :meta.bury(:tags, 0, '7') } }['tags'])
|
|
2135
|
+
end
|
|
2136
|
+
|
|
2137
|
+
# The value can be read out of the document it is going into.
|
|
2138
|
+
def test_bury_an_expression
|
|
2139
|
+
assert_equal('5', buried { { meta: :meta.bury(:copy, :meta.dig(:n)) } }['copy'].to_s)
|
|
2140
|
+
end
|
|
2141
|
+
|
|
2142
|
+
# It is an expression, so it does not have to be written anywhere.
|
|
2143
|
+
def test_bury_in_a_select
|
|
2144
|
+
seed_docs
|
|
2145
|
+
value = Doc.where { :name == 'one' }.select { :meta.bury(:a, :b, 'x').as(:v) }.first.v
|
|
2146
|
+
assert_equal('x', (value.is_a?(String) ? JSON.parse(value) : value).dig('a', 'b'))
|
|
2147
|
+
end
|
|
2148
|
+
|
|
2149
|
+
def test_bury_needs_a_path
|
|
2150
|
+
assert_raises(ArgumentError) { Doc.select { :meta.bury('v') } }
|
|
2151
|
+
assert_raises(ArgumentError) { Doc.select { :meta.bury(1.5, 'v') } }
|
|
2152
|
+
end
|
|
2153
|
+
|
|
1178
2154
|
def test_default_where_syntax
|
|
1179
2155
|
assert_sql(/WHERE "users"."name" = 'Ruby' AND "users"."age" = 19/,
|
|
1180
2156
|
User.where(name: 'Ruby', age: 19).to_sql)
|
|
1181
2157
|
end
|
|
2158
|
+
|
|
2159
|
+
def test_value_in_a_select_list
|
|
2160
|
+
assert_sql(/SELECT "users"."name", 0 AS "depth"/,
|
|
2161
|
+
User.select { [:name, value(0).as(:depth)] }.to_sql)
|
|
2162
|
+
end
|
|
2163
|
+
|
|
2164
|
+
# Each adapter escapes the apostrophe its own way, so what is asserted is
|
|
2165
|
+
# that the string stays a value rather than reaching the SQL as written.
|
|
2166
|
+
def test_value_is_quoted
|
|
2167
|
+
User.delete_all
|
|
2168
|
+
User.create!(name: 'alice')
|
|
2169
|
+
payload = "it's a value"
|
|
2170
|
+
assert_sql(/SELECT 'draft' AS "state"/,
|
|
2171
|
+
User.select { value('draft').as(:state) }.to_sql)
|
|
2172
|
+
assert_equal([payload],
|
|
2173
|
+
User.select { value(payload).as(:note) }.map(&:note))
|
|
2174
|
+
end
|
|
2175
|
+
|
|
2176
|
+
def test_a_bare_string_is_still_sql
|
|
2177
|
+
assert_sql(/SELECT "users"."name", 1 \+ 1 AS two/,
|
|
2178
|
+
User.select { [:name, '1 + 1 AS two'] }.to_sql)
|
|
2179
|
+
end
|
|
2180
|
+
|
|
2181
|
+
def test_value_takes_the_predications
|
|
2182
|
+
assert_sql(/WHERE 1 = "users"."age"/, User.where { value(1) == :users[:age] }.to_sql)
|
|
2183
|
+
assert_sql(/WHERE 1 IS NULL/, User.where { value(1).null? }.to_sql)
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2186
|
+
def test_value_takes_the_arithmetics
|
|
2187
|
+
assert_sql(/SELECT \(1 \+ "users"."age"\) AS "next_year"/,
|
|
2188
|
+
User.select { (value(1) + :age).as(:next_year) }.to_sql)
|
|
2189
|
+
end
|
|
2190
|
+
|
|
2191
|
+
def test_value_as_a_function_argument
|
|
2192
|
+
assert_sql(/SELECT COALESCE\("users"."age", 0\)/,
|
|
2193
|
+
User.select { coalesce(:age, value(0)) }.to_sql)
|
|
2194
|
+
end
|
|
2195
|
+
|
|
2196
|
+
def test_integer_shorthand_for_value
|
|
2197
|
+
assert_sql(/SELECT "users"."name", 0 AS "depth"/,
|
|
2198
|
+
User.select { [:name, 0.as(:depth)] }.to_sql)
|
|
2199
|
+
end
|
|
2200
|
+
|
|
2201
|
+
def test_float_shorthand_for_value
|
|
2202
|
+
assert_sql(/SELECT 1\.5 AS "rate"/, User.select { 1.5.as(:rate) }.to_sql)
|
|
2203
|
+
end
|
|
2204
|
+
|
|
2205
|
+
def test_numeric_shorthand_has_no_orderings
|
|
2206
|
+
assert_raises(NoMethodError) { User.order { 1.asc } }
|
|
2207
|
+
assert_raises(NoMethodError) { User.order { 1.desc } }
|
|
2208
|
+
end
|
|
2209
|
+
|
|
2210
|
+
# The alias on a literal is quoted like any other, so a name that is not a
|
|
2211
|
+
# plain one arrives as itself rather than as SQL.
|
|
2212
|
+
def test_a_value_alias_is_quoted_rather_than_refused
|
|
2213
|
+
User.delete_all
|
|
2214
|
+
User.create!(name: 'alice')
|
|
2215
|
+
payload = 'a" FROM users; --'
|
|
2216
|
+
assert_equal(0, User.select { value(0).as(payload.to_sym) }.first[payload].to_i)
|
|
2217
|
+
assert_equal(0, User.select { 0.as(payload.to_sym) }.first[payload].to_i)
|
|
2218
|
+
assert_equal(1, User.count)
|
|
2219
|
+
end
|
|
2220
|
+
|
|
2221
|
+
def test_a_value_selected_reaches_the_row
|
|
2222
|
+
User.delete_all
|
|
2223
|
+
User.create!(name: 'alice', age: 60)
|
|
2224
|
+
assert_equal([['alice', 0]],
|
|
2225
|
+
User.select { [:name, 0.as(:depth)] }.map {|u| [u.name, u.depth] })
|
|
2226
|
+
end
|
|
2227
|
+
|
|
2228
|
+
def test_numeric_shorthand_is_confined_to_the_block
|
|
2229
|
+
assert_raises(NoMethodError) { 0.as(:depth) }
|
|
2230
|
+
end
|
|
1182
2231
|
end
|