activerecord-refined 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +158 -0
- data/README.md +128 -13
- data/activerecord-refined.gemspec +4 -1
- data/benchmark/query_building.rb +12 -12
- data/examples/ctes.rb +39 -8
- data/examples/predicates.rb +11 -11
- data/examples/subqueries.rb +5 -5
- data/lib/active_record/refined/ast.rb +144 -15
- data/lib/active_record/refined.rb +109 -10
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +4 -0
- data/test/test_block_syntax.rb +349 -38
- metadata +2 -1
data/test/test_block_syntax.rb
CHANGED
|
@@ -2,11 +2,11 @@ require_relative 'test_helper'
|
|
|
2
2
|
|
|
3
3
|
class TestBlockSyntax < Minitest::Test
|
|
4
4
|
def test_equal
|
|
5
|
-
assert_sql(/WHERE "users"."name" = '
|
|
5
|
+
assert_sql(/WHERE "users"."name" = 'alice'/, User.where { :name == 'alice' }.to_sql)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def test_not_equal
|
|
9
|
-
assert_sql(/WHERE "users"."name" != '
|
|
9
|
+
assert_sql(/WHERE "users"."name" != 'bob'/, User.where { :name != 'bob' }.to_sql)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def test_greater_than
|
|
@@ -34,23 +34,23 @@ class TestBlockSyntax < Minitest::Test
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def test_and
|
|
37
|
-
assert_sql(/WHERE "users"."name" = '
|
|
38
|
-
User.where { (:name == '
|
|
37
|
+
assert_sql(/WHERE "users"."name" = 'alice' AND "users"."age" > 18/,
|
|
38
|
+
User.where { (:name == 'alice') & (:age > 18) }.to_sql)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def test_or
|
|
42
|
-
assert_sql(/WHERE \(?\"users\".\"name\" = '
|
|
43
|
-
User.where { (:name == '
|
|
42
|
+
assert_sql(/WHERE \(?\"users\".\"name\" = 'alice' OR \"users\".\"name\" = 'bob'\)?/,
|
|
43
|
+
User.where { (:name == 'alice') | (:name == 'bob') }.to_sql)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def test_not
|
|
47
|
-
assert_sql(/WHERE NOT \(?\"users\".\"name\" = '
|
|
48
|
-
User.where { !(:name == '
|
|
47
|
+
assert_sql(/WHERE NOT \(?\"users\".\"name\" = 'alice'\)?/,
|
|
48
|
+
User.where { !(:name == 'alice') }.to_sql)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def test_complex_combination
|
|
52
|
-
sql = User.where { ((:name == '
|
|
53
|
-
assert_sql(/"users"."name" = '
|
|
52
|
+
sql = User.where { ((:name == 'alice') & (:age > 18)) | !(:name == 'bob') }.to_sql
|
|
53
|
+
assert_sql(/"users"."name" = 'alice'/, sql)
|
|
54
54
|
assert_sql(/"users"."age" > 18/, sql)
|
|
55
55
|
assert_sql(/NOT/, sql)
|
|
56
56
|
assert_sql(/OR/, sql)
|
|
@@ -70,13 +70,13 @@ class TestBlockSyntax < Minitest::Test
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def test_casecmp
|
|
73
|
-
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('
|
|
74
|
-
User.where { :name.casecmp?('
|
|
73
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Alice'\)/,
|
|
74
|
+
User.where { :name.casecmp?('Alice') }.to_sql)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def test_casecmp_qualified
|
|
78
|
-
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('
|
|
79
|
-
User.where { :users[:name].casecmp?('
|
|
78
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Alice'\)/,
|
|
79
|
+
User.where { :users[:name].casecmp?('Alice') }.to_sql)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def test_casecmp_nil_is_rejected
|
|
@@ -86,16 +86,27 @@ class TestBlockSyntax < Minitest::Test
|
|
|
86
86
|
|
|
87
87
|
def test_casecmp_execution
|
|
88
88
|
User.delete_all
|
|
89
|
-
User.create!(name: '
|
|
90
|
-
User.create!(name: '
|
|
91
|
-
assert_equal(['
|
|
89
|
+
User.create!(name: 'Alice')
|
|
90
|
+
User.create!(name: 'bob')
|
|
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)
|
|
@@ -115,8 +126,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
115
126
|
# of literals; matching any one of them is enough.
|
|
116
127
|
def test_start_with_multiple
|
|
117
128
|
assert_sql(
|
|
118
|
-
/WHERE \("users"."name" LIKE '
|
|
119
|
-
User.where { :name.start_with?('
|
|
129
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\)/,
|
|
130
|
+
User.where { :name.start_with?('al', 'bo') }.to_sql)
|
|
120
131
|
end
|
|
121
132
|
|
|
122
133
|
def test_end_with_multiple
|
|
@@ -128,8 +139,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
128
139
|
# The OR arrives grouped, so a following & applies to the whole list.
|
|
129
140
|
def test_start_with_multiple_combined
|
|
130
141
|
assert_sql(
|
|
131
|
-
/WHERE \("users"."name" LIKE '
|
|
132
|
-
User.where { :name.start_with?('
|
|
142
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\) AND "users"."age" > 18/,
|
|
143
|
+
User.where { :name.start_with?('al', 'bo') & (:age > 18) }.to_sql)
|
|
133
144
|
end
|
|
134
145
|
|
|
135
146
|
def test_start_with_no_arguments
|
|
@@ -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,36 @@ 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
|
+
# The claim these methods rest on: the direct spelling is the same rows as
|
|
350
|
+
# negating the positive one, which is where a NULL would show a difference
|
|
351
|
+
# if there were one.
|
|
352
|
+
def test_the_negations_match_what_bang_selects
|
|
353
|
+
User.delete_all
|
|
354
|
+
User.create!(name: 'alice', age: 60)
|
|
355
|
+
User.create!(name: 'bob', age: 20)
|
|
356
|
+
User.create!(name: nil, age: 40)
|
|
357
|
+
[
|
|
358
|
+
[-> { :name.not_null? }, -> { !:name.null? }],
|
|
359
|
+
[-> { :age.not_in?([20, 30]) }, -> { !:age.in?([20, 30]) }],
|
|
360
|
+
[-> { :age.not_between?(20, 30) }, -> { !:age.between?(20, 30) }],
|
|
361
|
+
[-> { :name.not_like?('a%') }, -> { !:name.like?('a%') }],
|
|
362
|
+
].each do |direct, negated|
|
|
363
|
+
assert_equal(User.where(&negated).pluck(:id).sort,
|
|
364
|
+
User.where(&direct).pluck(:id).sort,
|
|
365
|
+
"#{direct.source_location} did not match the ! form")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
316
369
|
def test_equal_nil_is_rejected
|
|
317
370
|
e = assert_raises(ArgumentError) { User.where { :name == nil } }
|
|
318
371
|
assert_match(/null\?/, e.message)
|
|
@@ -333,11 +386,21 @@ class TestBlockSyntax < Minitest::Test
|
|
|
333
386
|
User.where { :users[:age].in?([1, 2, 3]) }.to_sql)
|
|
334
387
|
end
|
|
335
388
|
|
|
336
|
-
def
|
|
389
|
+
def test_bang_negates_in
|
|
337
390
|
assert_sql(/WHERE NOT \("users"."age" IN \(1, 2, 3\)\)/,
|
|
338
391
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
339
392
|
end
|
|
340
393
|
|
|
394
|
+
def test_not_in
|
|
395
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
396
|
+
User.where { :age.not_in?([1, 2, 3]) }.to_sql)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def test_not_in_qualified
|
|
400
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
401
|
+
User.where { :users[:age].not_in?([1, 2, 3]) }.to_sql)
|
|
402
|
+
end
|
|
403
|
+
|
|
341
404
|
# Spelled IS [NOT] DISTINCT FROM on PostgreSQL, IS / IS NOT on SQLite and
|
|
342
405
|
# <=> on MySQL, so only the resulting rows are portable.
|
|
343
406
|
def test_not_distinct_from_execution
|
|
@@ -350,11 +413,11 @@ class TestBlockSyntax < Minitest::Test
|
|
|
350
413
|
|
|
351
414
|
def test_not_distinct_from_a_value_execution
|
|
352
415
|
User.delete_all
|
|
353
|
-
User.create!(name: '
|
|
416
|
+
User.create!(name: 'alice')
|
|
354
417
|
User.create!(name: nil)
|
|
355
|
-
assert_equal(['
|
|
418
|
+
assert_equal(['alice'], User.where { :name.not_distinct_from?('alice') }.pluck(:name))
|
|
356
419
|
# Unlike !=, this keeps the NULL row.
|
|
357
|
-
assert_equal([nil], User.where { :name.distinct_from?('
|
|
420
|
+
assert_equal([nil], User.where { :name.distinct_from?('alice') }.pluck(:name))
|
|
358
421
|
end
|
|
359
422
|
|
|
360
423
|
def test_distinct_from_postgresql_syntax
|
|
@@ -421,8 +484,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
421
484
|
|
|
422
485
|
def test_exists_combined
|
|
423
486
|
assert_sql(
|
|
424
|
-
/WHERE "authors"."name" = '
|
|
425
|
-
Author.where { (:name == '
|
|
487
|
+
/WHERE "authors"."name" = 'alice' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
488
|
+
Author.where { (:name == 'alice') & exists?(Post.where(title: 'pub')) }.to_sql)
|
|
426
489
|
end
|
|
427
490
|
|
|
428
491
|
def test_exists_execution
|
|
@@ -477,8 +540,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
477
540
|
end
|
|
478
541
|
|
|
479
542
|
def test_qualified_column
|
|
480
|
-
assert_sql(/WHERE "users"."name" = '
|
|
481
|
-
User.where { :users[:name] == '
|
|
543
|
+
assert_sql(/WHERE "users"."name" = 'alice'/,
|
|
544
|
+
User.where { :users[:name] == 'alice' }.to_sql)
|
|
482
545
|
end
|
|
483
546
|
|
|
484
547
|
def test_column_to_column_comparison
|
|
@@ -547,6 +610,36 @@ class TestBlockSyntax < Minitest::Test
|
|
|
547
610
|
assert_raises(ArgumentError) { Node.from('tree', as: :nodes) }
|
|
548
611
|
end
|
|
549
612
|
|
|
613
|
+
def test_from_cte_takes_the_alias_from_the_model
|
|
614
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/, Node.from_cte(:tree).to_sql)
|
|
615
|
+
assert_equal(Node.from(:tree, as: :nodes).to_sql, Node.from_cte(:tree).to_sql)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def test_from_cte_needs_a_symbol
|
|
619
|
+
assert_raises(ArgumentError) { Node.from_cte('tree') }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# The alias is what lets a where find its column, which is the whole reason
|
|
623
|
+
# from_cte exists; without it the SQL names a table the query does not have.
|
|
624
|
+
def test_from_cte_leaves_where_able_to_qualify
|
|
625
|
+
Node.delete_all
|
|
626
|
+
root = Node.create!(name: 'root')
|
|
627
|
+
Node.create!(name: 'child', parent_id: root.id)
|
|
628
|
+
other = Node.create!(name: 'other root')
|
|
629
|
+
Node.create!(name: 'other child', parent_id: other.id)
|
|
630
|
+
forest = Node.with_recursive(
|
|
631
|
+
tree: [
|
|
632
|
+
Node.where { :parent_id.null? }.
|
|
633
|
+
select { [:id, :name, :parent_id, :id.as(:root_id)] },
|
|
634
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
635
|
+
select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
|
|
636
|
+
:tree[:root_id]] },
|
|
637
|
+
]
|
|
638
|
+
).from_cte(:tree)
|
|
639
|
+
assert_equal(%w[child root],
|
|
640
|
+
forest.where { :root_id == root.id }.pluck(:name).sort)
|
|
641
|
+
end
|
|
642
|
+
|
|
550
643
|
# A CTE is joined by name like any other table, so the recursive member's
|
|
551
644
|
# ON clause is a block rather than the string join Rails' own docs use.
|
|
552
645
|
def test_recursive_cte
|
|
@@ -755,8 +848,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
755
848
|
# rather than opening the condition up.
|
|
756
849
|
def test_values_are_quoted
|
|
757
850
|
User.delete_all
|
|
758
|
-
User.create!(name: '
|
|
759
|
-
User.create!(name: '
|
|
851
|
+
User.create!(name: 'alice')
|
|
852
|
+
User.create!(name: 'bob')
|
|
760
853
|
payload = "x' OR 1=1 --"
|
|
761
854
|
assert_empty(User.where { :name == payload }.pluck(:name))
|
|
762
855
|
assert_empty(User.where { :name.like?(payload) }.pluck(:name))
|
|
@@ -793,9 +886,9 @@ class TestBlockSyntax < Minitest::Test
|
|
|
793
886
|
|
|
794
887
|
def test_scalar_functions_run
|
|
795
888
|
User.delete_all
|
|
796
|
-
User.create!(name: '
|
|
797
|
-
assert_equal(['
|
|
798
|
-
assert_equal([
|
|
889
|
+
User.create!(name: 'alice', age: 60)
|
|
890
|
+
assert_equal(['ALICE-x'], User.select { concat(upper(:name), '-x').as(:v) }.map(&:v))
|
|
891
|
+
assert_equal([5], User.select { char_length(:name).as(:v) }.map(&:v))
|
|
799
892
|
assert_equal([60], User.select { greatest(:age, 18).as(:v) }.map(&:v))
|
|
800
893
|
end
|
|
801
894
|
|
|
@@ -826,8 +919,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
826
919
|
assert_raises(NotImplementedError) { User.select { format('%s!', :name) } }
|
|
827
920
|
else
|
|
828
921
|
User.delete_all
|
|
829
|
-
User.create!(name: '
|
|
830
|
-
assert_equal(['
|
|
922
|
+
User.create!(name: 'alice')
|
|
923
|
+
assert_equal(['alice!'], User.select { format('%s!', :name).as(:v) }.map(&:v))
|
|
831
924
|
end
|
|
832
925
|
end
|
|
833
926
|
|
|
@@ -845,6 +938,157 @@ class TestBlockSyntax < Minitest::Test
|
|
|
845
938
|
end
|
|
846
939
|
end
|
|
847
940
|
|
|
941
|
+
# CURRENT_TIMESTAMP and its relatives are grammar rather than calls, so
|
|
942
|
+
# they come out without the parentheses PostgreSQL and SQLite reject.
|
|
943
|
+
def test_datetime_value_functions_are_emitted_bare
|
|
944
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP FROM/,
|
|
945
|
+
User.select { current_timestamp }.to_sql)
|
|
946
|
+
assert_sql(/SELECT CURRENT_DATE FROM/, User.select { current_date }.to_sql)
|
|
947
|
+
assert_sql(/SELECT CURRENT_TIME FROM/, User.select { current_time }.to_sql)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def test_datetime_value_function_in_comparison_and_alias
|
|
951
|
+
assert_sql(/WHERE "users"."name" < CURRENT_TIMESTAMP/,
|
|
952
|
+
User.where { :name < current_timestamp }.to_sql)
|
|
953
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP AS ts FROM/,
|
|
954
|
+
User.select { current_timestamp.as(:ts) }.to_sql)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def test_current_timestamp_runs
|
|
958
|
+
User.delete_all
|
|
959
|
+
User.create!(name: 'alice')
|
|
960
|
+
refute_nil(User.select { current_timestamp.as(:v) }.sole.v)
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# The one thing that does go into the parentheses is a precision, which
|
|
964
|
+
# current_date never takes and SQLite never accepts.
|
|
965
|
+
def test_datetime_value_function_with_precision
|
|
966
|
+
if ADAPTER == 'sqlite3'
|
|
967
|
+
e = assert_raises(NotImplementedError) { User.select { current_timestamp(3) } }
|
|
968
|
+
assert_match(/precision/, e.message)
|
|
969
|
+
else
|
|
970
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP\(3\) FROM/,
|
|
971
|
+
User.select { current_timestamp(3) }.to_sql)
|
|
972
|
+
assert_sql(/SELECT CURRENT_TIME\(0\) FROM/,
|
|
973
|
+
User.select { current_time(0) }.to_sql)
|
|
974
|
+
User.delete_all
|
|
975
|
+
User.create!(name: 'alice')
|
|
976
|
+
refute_nil(User.select { current_timestamp(0).as(:v) }.sole.v)
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def test_current_date_takes_no_precision
|
|
981
|
+
assert_raises(ArgumentError) { User.select { current_date(0) } }
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# The precision is written into the SQL as given, so only an Integer is
|
|
985
|
+
# accepted there.
|
|
986
|
+
def test_precision_must_be_an_integer
|
|
987
|
+
assert_raises(ArgumentError) do
|
|
988
|
+
User.select { current_timestamp(:'3); DROP TABLE users --') }
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def test_localtime_is_unsupported_on_sqlite
|
|
993
|
+
if ADAPTER == 'sqlite3'
|
|
994
|
+
assert_raises(NotImplementedError) { User.select { localtime } }
|
|
995
|
+
assert_raises(NotImplementedError) { User.select { localtimestamp } }
|
|
996
|
+
else
|
|
997
|
+
assert_sql(/SELECT LOCALTIME FROM/, User.select { localtime }.to_sql)
|
|
998
|
+
assert_sql(/SELECT LOCALTIMESTAMP FROM/,
|
|
999
|
+
User.select { localtimestamp }.to_sql)
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def test_math_functions
|
|
1004
|
+
assert_sql(/SELECT SIGN\("users"."age"\)/, User.select { sign(:age) }.to_sql)
|
|
1005
|
+
assert_sql(/SELECT ATAN2\("users"."age", 2\)/,
|
|
1006
|
+
User.select { atan2(:age, 2) }.to_sql)
|
|
1007
|
+
assert_sql(/SELECT PI\(\)/, User.select { pi }.to_sql)
|
|
1008
|
+
assert_sql(/SELECT DEGREES\(RADIANS\("users"."age"\)\)/,
|
|
1009
|
+
User.select { degrees(radians(:age)) }.to_sql)
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def test_math_functions_run
|
|
1013
|
+
User.delete_all
|
|
1014
|
+
User.create!(name: 'alice', age: 60)
|
|
1015
|
+
assert_equal(1, User.select { sign(:age).as(:v) }.sole.v.to_i)
|
|
1016
|
+
assert_equal(60,
|
|
1017
|
+
User.select { round(degrees(radians(:age))).as(:v) }.sole.v.to_i)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# PostgreSQL spells log2(x) as log(2, x), which no renaming carries.
|
|
1021
|
+
def test_log2_is_unsupported_on_postgresql
|
|
1022
|
+
if ADAPTER == 'postgresql'
|
|
1023
|
+
assert_raises(NotImplementedError) { User.select { log2(:age) } }
|
|
1024
|
+
else
|
|
1025
|
+
assert_sql(/SELECT LOG2\("users"."age"\)/, User.select { log2(:age) }.to_sql)
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
# MySQL spells trunc TRUNCATE, and insists on the second argument the
|
|
1030
|
+
# others default to zero.
|
|
1031
|
+
def test_trunc
|
|
1032
|
+
expected = ADAPTER == 'mysql2' ? 'TRUNCATE' : 'TRUNC'
|
|
1033
|
+
assert_sql(/SELECT #{expected}\("users"."age", 0\)/,
|
|
1034
|
+
User.select { trunc(:age, 0) }.to_sql)
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# EXTRACT(field FROM expr): the field is a keyword rather than a value.
|
|
1038
|
+
# SQLite spells all of this as strftime formats, which no renaming
|
|
1039
|
+
# carries.
|
|
1040
|
+
def test_extract
|
|
1041
|
+
if ADAPTER == 'sqlite3'
|
|
1042
|
+
e = assert_raises(NotImplementedError) { User.select { extract(:year, :name) } }
|
|
1043
|
+
assert_match(/extract/, e.message)
|
|
1044
|
+
else
|
|
1045
|
+
assert_sql(/SELECT EXTRACT\(YEAR FROM "users"."name"\)/,
|
|
1046
|
+
User.select { extract(:year, :name) }.to_sql)
|
|
1047
|
+
assert_sql(/WHERE EXTRACT\(YEAR FROM "users"."name"\) = 2026/,
|
|
1048
|
+
User.where { extract(:year, :name) == 2026 }.to_sql)
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# A bad field is an ArgumentError on every adapter, before SQLite gets to
|
|
1053
|
+
# say it has no extract at all.
|
|
1054
|
+
def test_extract_rejects_an_injected_field
|
|
1055
|
+
assert_raises(ArgumentError) { User.select { extract(INJECTION.to_sym, :name) } }
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def test_extract_runs
|
|
1059
|
+
skip "#{ADAPTER} has no extract" if ADAPTER == 'sqlite3'
|
|
1060
|
+
User.delete_all
|
|
1061
|
+
User.create!(name: 'alice')
|
|
1062
|
+
assert_equal(2026,
|
|
1063
|
+
User.select { extract(:year, cast('2026-01-05', :date)).as(:v) }.sole.v.to_i)
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def test_cast
|
|
1067
|
+
assert_sql(/SELECT CAST\("users"."age" AS text\)/,
|
|
1068
|
+
User.select { cast(:age, :text) }.to_sql)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def test_cast_runs
|
|
1072
|
+
User.delete_all
|
|
1073
|
+
User.create!(name: 'alice')
|
|
1074
|
+
assert_equal(12.5,
|
|
1075
|
+
User.select { cast('12.5', 'decimal(10,2)').as(:v) }.sole.v.to_f)
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
# The type is written into the SQL as given, so it has to look like one:
|
|
1079
|
+
# a plain name, at most parenthesized with lengths. The adapters' own
|
|
1080
|
+
# spellings with a space in them pass too.
|
|
1081
|
+
def test_cast_type_names
|
|
1082
|
+
assert_sql(/AS double precision\)/,
|
|
1083
|
+
User.select { cast(:age, 'double precision') }.to_sql)
|
|
1084
|
+
assert_sql(/AS decimal\(10,2\)\)/,
|
|
1085
|
+
User.select { cast(:age, 'decimal(10,2)') }.to_sql)
|
|
1086
|
+
assert_raises(ArgumentError) { User.select { cast(:age, INJECTION.to_sym) } }
|
|
1087
|
+
assert_raises(ArgumentError) do
|
|
1088
|
+
User.select { cast(:age, 'integer); DROP TABLE users --') }
|
|
1089
|
+
end
|
|
1090
|
+
end
|
|
1091
|
+
|
|
848
1092
|
# A name with no method of its own is still a NoMethodError, not a
|
|
849
1093
|
# function call the database has to reject.
|
|
850
1094
|
def test_unknown_function_is_a_no_method_error
|
|
@@ -1028,4 +1272,71 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1028
1272
|
assert_sql(/WHERE "users"."name" = 'Ruby' AND "users"."age" = 19/,
|
|
1029
1273
|
User.where(name: 'Ruby', age: 19).to_sql)
|
|
1030
1274
|
end
|
|
1275
|
+
|
|
1276
|
+
def test_value_in_a_select_list
|
|
1277
|
+
assert_sql(/SELECT "users"."name", 0 AS depth/,
|
|
1278
|
+
User.select { [:name, value(0).as(:depth)] }.to_sql)
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
# Each adapter escapes the apostrophe its own way, so what is asserted is
|
|
1282
|
+
# that the string stays a value rather than reaching the SQL as written.
|
|
1283
|
+
def test_value_is_quoted
|
|
1284
|
+
User.delete_all
|
|
1285
|
+
User.create!(name: 'alice')
|
|
1286
|
+
payload = "it's a value"
|
|
1287
|
+
assert_sql(/SELECT 'draft' AS state/,
|
|
1288
|
+
User.select { value('draft').as(:state) }.to_sql)
|
|
1289
|
+
assert_equal([payload],
|
|
1290
|
+
User.select { value(payload).as(:note) }.map(&:note))
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
def test_a_bare_string_is_still_sql
|
|
1294
|
+
assert_sql(/SELECT "users"."name", 1 \+ 1 AS two/,
|
|
1295
|
+
User.select { [:name, '1 + 1 AS two'] }.to_sql)
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
def test_value_takes_the_predications
|
|
1299
|
+
assert_sql(/WHERE 1 = "users"."age"/, User.where { value(1) == :users[:age] }.to_sql)
|
|
1300
|
+
assert_sql(/WHERE 1 IS NULL/, User.where { value(1).null? }.to_sql)
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def test_value_takes_the_arithmetics
|
|
1304
|
+
assert_sql(/SELECT \(1 \+ "users"."age"\) AS next_year/,
|
|
1305
|
+
User.select { (value(1) + :age).as(:next_year) }.to_sql)
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def test_value_as_a_function_argument
|
|
1309
|
+
assert_sql(/SELECT COALESCE\("users"."age", 0\)/,
|
|
1310
|
+
User.select { coalesce(:age, value(0)) }.to_sql)
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def test_integer_shorthand_for_value
|
|
1314
|
+
assert_sql(/SELECT "users"."name", 0 AS depth/,
|
|
1315
|
+
User.select { [:name, 0.as(:depth)] }.to_sql)
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
def test_float_shorthand_for_value
|
|
1319
|
+
assert_sql(/SELECT 1\.5 AS rate/, User.select { 1.5.as(:rate) }.to_sql)
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def test_numeric_shorthand_has_no_orderings
|
|
1323
|
+
assert_raises(NoMethodError) { User.order { 1.asc } }
|
|
1324
|
+
assert_raises(NoMethodError) { User.order { 1.desc } }
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
def test_value_alias_rejects_an_injected_name
|
|
1328
|
+
assert_raises(ArgumentError) { User.select { value(0).as(INJECTION.to_sym) } }
|
|
1329
|
+
assert_raises(ArgumentError) { User.select { 0.as(INJECTION.to_sym) } }
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def test_a_value_selected_reaches_the_row
|
|
1333
|
+
User.delete_all
|
|
1334
|
+
User.create!(name: 'alice', age: 60)
|
|
1335
|
+
assert_equal([['alice', 0]],
|
|
1336
|
+
User.select { [:name, 0.as(:depth)] }.map {|u| [u.name, u.depth] })
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def test_numeric_shorthand_is_confined_to_the_block
|
|
1340
|
+
assert_raises(NoMethodError) { 0.as(:depth) }
|
|
1341
|
+
end
|
|
1031
1342
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-refined
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shugo Maeda
|
|
@@ -81,6 +81,7 @@ extensions: []
|
|
|
81
81
|
extra_rdoc_files: []
|
|
82
82
|
files:
|
|
83
83
|
- .github/workflows/push_gem.yml
|
|
84
|
+
- .github/workflows/sandbox.yml
|
|
84
85
|
- .github/workflows/test.yml
|
|
85
86
|
- .gitignore
|
|
86
87
|
- Gemfile
|