activerecord-refined 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +14 -0
- data/.rubocop.yml +393 -0
- data/Gemfile +5 -3
- data/README.md +254 -74
- data/Rakefile +31 -3
- data/activerecord-refined.gemspec +29 -15
- data/benchmark/query_building.rb +11 -11
- data/examples/aggregations.rb +13 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +79 -44
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +862 -272
- data/lib/active_record/refined.rb +260 -180
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +8 -5
- data/test/test_block_syntax.rb +879 -323
- data/test/test_helper.rb +56 -39
- metadata +130 -1
data/README.md
CHANGED
|
@@ -26,7 +26,8 @@ Refinements' spec change, that implementation stopped working on Ruby 2.0.0 stab
|
|
|
26
26
|
the project was left dormant for a long time.
|
|
27
27
|
|
|
28
28
|
It has now been renamed to **activerecord-refined** and reimplemented on top of
|
|
29
|
-
`Proc#refined
|
|
29
|
+
[`Proc#refined`](https://docs.ruby-lang.org/en/master/Proc.html#method-i-refined),
|
|
30
|
+
which will be introduced in Ruby 4.1. `Proc#refined` returns a new proc that
|
|
30
31
|
is evaluated with the given refinements activated, so a block written by the caller can
|
|
31
32
|
be re-interpreted under the query DSL's refinements:
|
|
32
33
|
|
|
@@ -69,17 +70,20 @@ Just require the gem, and `where`, `select`, `joins`, `left_outer_joins`, `havin
|
|
|
69
70
|
`order` and `group` will accept a block.
|
|
70
71
|
|
|
71
72
|
```ruby
|
|
72
|
-
require
|
|
73
|
+
require "activerecord-refined"
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
Inside the block, symbols denote columns of the receiver's table, and `:table[:column]`
|
|
76
|
-
denotes a qualified column.
|
|
77
|
+
denotes a qualified column. That holds in every position — on the right of a
|
|
78
|
+
comparison too, so `:age == :retirement_age` compares two columns. A value is
|
|
79
|
+
written as its literal, an enum's as its string; a symbol naming no column of
|
|
80
|
+
the model is refused rather than compared against nothing anyone meant.
|
|
77
81
|
|
|
78
82
|
### Conditions
|
|
79
83
|
|
|
80
84
|
```ruby
|
|
81
85
|
Author.where { :age >= 18 }
|
|
82
|
-
Author.where { :name.like?(
|
|
86
|
+
Author.where { :name.like?("A%") } # LIKE
|
|
83
87
|
Author.where { :age.in?(20..40) } # BETWEEN
|
|
84
88
|
Author.where { :age.between?(20, 40) } # BETWEEN
|
|
85
89
|
Author.where { :age.in?(18..) } # >= 18
|
|
@@ -94,10 +98,10 @@ block, which is the same rows written the way they would be written by hand:
|
|
|
94
98
|
Author.where { :country.not_null? } # IS NOT NULL
|
|
95
99
|
Author.where { :country.not_in?(%w[JP US]) } # NOT IN
|
|
96
100
|
Author.where { :age.not_between?(20, 40) } # not between 20 and 40
|
|
97
|
-
Author.where { :name.not_like?(
|
|
98
|
-
Author.where { :name.not_ilike?(
|
|
101
|
+
Author.where { :name.not_like?("A%") } # NOT LIKE
|
|
102
|
+
Author.where { :name.not_ilike?("a%") } # NOT ILIKE / NOT LIKE
|
|
99
103
|
|
|
100
|
-
Author.where { !:name.start_with?(
|
|
104
|
+
Author.where { !:name.start_with?("A") } # NOT (name LIKE 'A%')
|
|
101
105
|
```
|
|
102
106
|
|
|
103
107
|
Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
|
|
@@ -105,6 +109,14 @@ select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
|
105
109
|
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
106
110
|
age > 40`, which is again the same rows.
|
|
107
111
|
|
|
112
|
+
A number compares as itself, the way a bound `?` does: `:age >= 99.5` says
|
|
113
|
+
`>= 99.5`, where `where(age: 99.5..)` casts to the column's type and says
|
|
114
|
+
`>= 99`, letting an age of 99 through a bound it does not satisfy. Everything
|
|
115
|
+
that is not an `Integer`, `Float` or `BigDecimal` keeps the column's own
|
|
116
|
+
serialization — an enum's name, a time's zone, a custom type's scaling — so a
|
|
117
|
+
custom type that scales a number, money kept in cents, is the one place the
|
|
118
|
+
number has to be written as the column stores it.
|
|
119
|
+
|
|
108
120
|
A boolean column has `true?` and `false?`, which become SQL's `IS TRUE` and
|
|
109
121
|
`IS FALSE`, and the two negations to go with them:
|
|
110
122
|
|
|
@@ -145,7 +157,7 @@ one-row rule: `> any` asks whether the subquery holds a smaller value anywhere,
|
|
|
145
157
|
`>= all` whether it holds a larger one nowhere.
|
|
146
158
|
|
|
147
159
|
```ruby
|
|
148
|
-
Author.where { :age > any(Author.where(country:
|
|
160
|
+
Author.where { :age > any(Author.where(country: "JP").select(:age)) }
|
|
149
161
|
# "authors"."age" > ANY(SELECT "authors"."age" FROM "authors" WHERE ...)
|
|
150
162
|
|
|
151
163
|
Author.where { :age >= all(Author.select(:age)) }
|
|
@@ -178,8 +190,8 @@ case-insensitive equality, folded on both sides rather than left to the
|
|
|
178
190
|
collation, so it means the same thing everywhere:
|
|
179
191
|
|
|
180
192
|
```ruby
|
|
181
|
-
Author.where { :name.ilike?(
|
|
182
|
-
Author.where { :name.casecmp?(
|
|
193
|
+
Author.where { :name.ilike?("ma%") } # ILIKE 'ma%' / LIKE 'ma%'
|
|
194
|
+
Author.where { :name.casecmp?("Alice") } # LOWER(name) = LOWER('Alice')
|
|
183
195
|
```
|
|
184
196
|
|
|
185
197
|
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
@@ -189,7 +201,7 @@ and MySQL `<=>`, and the rows that come back are the same on all three:
|
|
|
189
201
|
|
|
190
202
|
```ruby
|
|
191
203
|
Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
|
|
192
|
-
Author.where { :country.distinct_from?(
|
|
204
|
+
Author.where { :country.distinct_from?("JP") } # keeps the NULL rows
|
|
193
205
|
```
|
|
194
206
|
|
|
195
207
|
`start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
|
|
@@ -197,16 +209,16 @@ patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
|
|
|
197
209
|
and `_` in it are escaped rather than matched as wildcards:
|
|
198
210
|
|
|
199
211
|
```ruby
|
|
200
|
-
Author.where { :name.start_with?(
|
|
201
|
-
Author.where { :name.end_with?(
|
|
202
|
-
Author.where { :name.include?(
|
|
212
|
+
Author.where { :name.start_with?("A") } # LIKE 'A%'
|
|
213
|
+
Author.where { :name.end_with?("son") } # LIKE '%son'
|
|
214
|
+
Author.where { :name.include?("test") } # LIKE '%test%'
|
|
203
215
|
```
|
|
204
216
|
|
|
205
217
|
Like their String namesakes, `start_with?` and `end_with?` take any number of
|
|
206
218
|
literals; matching any one of them is enough:
|
|
207
219
|
|
|
208
220
|
```ruby
|
|
209
|
-
Author.where { :name.start_with?(
|
|
221
|
+
Author.where { :name.start_with?("A", "B") }
|
|
210
222
|
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
211
223
|
```
|
|
212
224
|
|
|
@@ -217,7 +229,7 @@ what separates it from `include?`), `superset?` and `subset?` are Set's
|
|
|
217
229
|
whole-array containment, and `intersect?` is Array's "any element in common":
|
|
218
230
|
|
|
219
231
|
```ruby
|
|
220
|
-
Article.where { :tags.member?(
|
|
232
|
+
Article.where { :tags.member?("ruby") } # tags @> '{ruby}'
|
|
221
233
|
Article.where { :scores.member?(80) } # scores @> '{80}'
|
|
222
234
|
Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
|
|
223
235
|
Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
|
|
@@ -233,8 +245,8 @@ something `Array#member?` does not. Requiring every element is `superset?`.
|
|
|
233
245
|
raises there.
|
|
234
246
|
|
|
235
247
|
```ruby
|
|
236
|
-
Author.where { :name =~
|
|
237
|
-
Author.where { :name !~
|
|
248
|
+
Author.where { :name =~ "^A" } # REGEXP / ~
|
|
249
|
+
Author.where { :name !~ "^A" } # NOT REGEXP / !~
|
|
238
250
|
Author.where { :name =~ /son$/ } # a Regexp literal works too
|
|
239
251
|
```
|
|
240
252
|
|
|
@@ -267,10 +279,10 @@ parentheses around each comparison necessary, though the `?` methods above need
|
|
|
267
279
|
none:
|
|
268
280
|
|
|
269
281
|
```ruby
|
|
270
|
-
Author.where { (:age >= 18) & ((:country ==
|
|
282
|
+
Author.where { (:age >= 18) & ((:country == "JP") | (:country == "US")) }
|
|
271
283
|
Author.where { !(:age.in?(0..17) | :country.null?) }
|
|
272
284
|
Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
|
|
273
|
-
Author.where { !:name.like?(
|
|
285
|
+
Author.where { !:name.like?("%test%") } # NOT (name LIKE '%test%')
|
|
274
286
|
```
|
|
275
287
|
|
|
276
288
|
### Joins
|
|
@@ -360,9 +372,12 @@ Sale.group { cube(:region, :product) } # GROUP BY CUBE( "region", "product"
|
|
|
360
372
|
A row that a set did not group by comes back with NULL there, which is also
|
|
361
373
|
what a real NULL looks like; `fn(:grouping, :region)` tells the two apart.
|
|
362
374
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
375
|
+
`grouping_sets` and `cube` are PostgreSQL's; SQLite has none of the three and
|
|
376
|
+
both raise `NotImplementedError` elsewhere. `rollup` runs on MySQL and
|
|
377
|
+
MariaDB too, spelled as their `WITH ROLLUP` — which trails the whole group
|
|
378
|
+
list, so there a rollup cannot stand beside other group entries the way
|
|
379
|
+
`ROLLUP(...)` can, and the block says so. MariaDB is also the one that
|
|
380
|
+
refuses `ORDER BY` next to it, and the one without `fn(:grouping, ...)`.
|
|
366
381
|
|
|
367
382
|
### Lateral joins
|
|
368
383
|
|
|
@@ -472,7 +487,7 @@ Author.select { count(:*).filter { :age < 50 }.as(:young) }
|
|
|
472
487
|
# COUNT(*) FILTER (WHERE "age" < 50) AS "young"
|
|
473
488
|
|
|
474
489
|
Author.select {
|
|
475
|
-
[count(:*).as(:all), sum(:age).filter { :country ==
|
|
490
|
+
[count(:*).as(:all), sum(:age).filter { :country == "JP" }.as(:jp_years)]
|
|
476
491
|
}
|
|
477
492
|
```
|
|
478
493
|
|
|
@@ -528,10 +543,53 @@ Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
|
528
543
|
written, so a case-sensitive one can be spelled exactly:
|
|
529
544
|
|
|
530
545
|
```ruby
|
|
531
|
-
Post.select { fn(:date_trunc,
|
|
546
|
+
Post.select { fn(:date_trunc, "day", :created_at).as(:day) }
|
|
532
547
|
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
533
548
|
```
|
|
534
549
|
|
|
550
|
+
`op` is the same escape hatch for operators — PostgreSQL alone has dozens
|
|
551
|
+
with no method here, `<@` and `&&` and the geometric ones among them. The
|
|
552
|
+
operator is emitted as written and, like `fn`'s names, whether the adapter
|
|
553
|
+
has it is your assertion; it is checked against the characters PostgreSQL
|
|
554
|
+
allows an operator, so a letter, a space or a quote is refused rather than
|
|
555
|
+
written into the SQL. Both sides take what `fn`'s arguments take — a
|
|
556
|
+
column, an expression, a value quoted by the adapter — and a value is
|
|
557
|
+
spelled in the adapter's own syntax, `to_json` saying a document, `'{a,b}'`
|
|
558
|
+
an array; a Ruby Hash or Array is refused rather than guessed at. The
|
|
559
|
+
result is parenthesized, its precedence being unknown, and so is an
|
|
560
|
+
expression on either side, so a dug value cannot be re-grouped out from
|
|
561
|
+
under it:
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
Post.where { op("&&", :tags, "{ruby,sql}") }
|
|
565
|
+
# WHERE ("posts"."tags" && '{ruby,sql}')
|
|
566
|
+
|
|
567
|
+
Post.where { op("<@", :meta.dig(:author), { name: "alice" }.to_json) }
|
|
568
|
+
# WHERE (("meta" #> '{author}') <@ '{"name":"alice"}')
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`sql` is the last resort, for what neither `fn` nor `op` can spell: the
|
|
572
|
+
statement goes out as written. It is the one way a string means SQL inside a
|
|
573
|
+
block — everywhere else a string is a value — so writing SQL is always asked
|
|
574
|
+
for by name, and an interpolation has a spelling that is not it: `?` and
|
|
575
|
+
`:name` placeholders take values quoted by the adapter, through
|
|
576
|
+
`sanitize_sql_array`. A `?` is rewritten only when there are positional binds
|
|
577
|
+
to put in it, so PostgreSQL's `?` operators can share a statement with named
|
|
578
|
+
binds, or with none. The result carries the predications and arithmetic, and
|
|
579
|
+
is parenthesized where it stands inside a larger expression — its precedence
|
|
580
|
+
is whatever was written — but comes out bare at the top of a select list,
|
|
581
|
+
where parentheses would refuse an alias written into the string:
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
Post.where { sql("length(title) > ?", 10) }
|
|
585
|
+
# WHERE (length(title) > 10)
|
|
586
|
+
|
|
587
|
+
Post.where { sql("score + ?", 10) * 2 >= 60 }
|
|
588
|
+
# WHERE (score + 10) * 2 >= 60
|
|
589
|
+
|
|
590
|
+
Post.select { sql("count(*) FILTER (WHERE score > 0) AS positive") }
|
|
591
|
+
```
|
|
592
|
+
|
|
535
593
|
Values are quoted by the adapter wherever they appear, as they are in
|
|
536
594
|
Active Record, and so is a column alias. That is what makes the name asked for
|
|
537
595
|
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
@@ -584,7 +642,7 @@ raises there:
|
|
|
584
642
|
Post.where { extract(:year, :created_at) == 2026 }
|
|
585
643
|
# SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
|
|
586
644
|
|
|
587
|
-
Post.select { cast(:price,
|
|
645
|
+
Post.select { cast(:price, "decimal(10,2)").as(:price) }
|
|
588
646
|
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
589
647
|
```
|
|
590
648
|
|
|
@@ -598,6 +656,17 @@ Item.where { :price * :quantity > 1000 }
|
|
|
598
656
|
Item.select { sum(:price * :quantity).as(:total) }
|
|
599
657
|
```
|
|
600
658
|
|
|
659
|
+
The number may stand on the left — only a column or an expression on the
|
|
660
|
+
right builds a query, so Ruby's own arithmetic is untouched — and
|
|
661
|
+
`BigDecimal` is a number here, being what a decimal column's values are,
|
|
662
|
+
quoted as the exact decimal on either side. A `Rational` is refused: no
|
|
663
|
+
decimal spells `1/3r` exactly, and `to_d` is what says the decimal meant.
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
Item.select { greatest(20 - :quantity, 0).as(:shortfall) }
|
|
667
|
+
Item.where { BigDecimal("1.08") * :price > 500 }
|
|
668
|
+
```
|
|
669
|
+
|
|
601
670
|
`&`, `|`, `^`, `~`, `<<` and `>>` are SQL's bitwise operators. Between
|
|
602
671
|
conditions `&` and `|` are AND and OR, and that is where they are defined,
|
|
603
672
|
which leaves them free to mean here what SQL means by them:
|
|
@@ -648,11 +717,12 @@ number, the others as a negative one, and the bits are the same either way.
|
|
|
648
717
|
|
|
649
718
|
One place asks for a value to be said out loud: the top of a select list.
|
|
650
719
|
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
651
|
-
`concat(:name, '-x')` — but
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
720
|
+
`concat(:name, '-x')` — but a bare string at the top of the list would be SQL
|
|
721
|
+
to Active Record and a value everywhere else in the block, so it is refused
|
|
722
|
+
rather than read either way: `sql` says the SQL, `value` the value. `value`
|
|
723
|
+
carries the predications and arithmetic with it, so a literal can be compared
|
|
724
|
+
and combined like anything else, and numbers and strings have a shorthand,
|
|
725
|
+
since a literal that has been sent `as` has already said it is a value:
|
|
656
726
|
|
|
657
727
|
```ruby
|
|
658
728
|
Node.select { [:id, value(0).as(:depth)] }
|
|
@@ -660,14 +730,12 @@ Node.select { [:id, value(0).as(:depth)] }
|
|
|
660
730
|
|
|
661
731
|
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
662
732
|
|
|
663
|
-
Post.select { [:title,
|
|
733
|
+
Post.select { [:title, "draft".as(:state)] }
|
|
664
734
|
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
665
735
|
```
|
|
666
736
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
same literal mean one thing or the other depending on whether it had been sent
|
|
670
|
-
a message.
|
|
737
|
+
What the shorthand does not cover, `value` still spells: `value(true)`,
|
|
738
|
+
`value(nil)`, or a literal that goes on to be compared rather than selected.
|
|
671
739
|
|
|
672
740
|
`CASE` is grammar rather than a function, and has two shapes. With an operand, each `when` is
|
|
673
741
|
something to compare it against; without one, each `when` carries a condition
|
|
@@ -676,13 +744,13 @@ reachable through the receiver — `self.case` — and each shape has a shorthan
|
|
|
676
744
|
that does not need it:
|
|
677
745
|
|
|
678
746
|
```ruby
|
|
679
|
-
Author.select { :country.when(
|
|
747
|
+
Author.select { :country.when("JP").then("Japan").else("elsewhere").as(:where) }
|
|
680
748
|
# CASE "country" WHEN 'JP' THEN 'Japan' ELSE 'elsewhere' END AS where
|
|
681
749
|
|
|
682
|
-
Author.select { case_when { :age >= 60 }.then(
|
|
750
|
+
Author.select { case_when { :age >= 60 }.then("senior").else("adult").as(:band) }
|
|
683
751
|
# CASE WHEN "age" >= 60 THEN 'senior' ELSE 'adult' END AS band
|
|
684
752
|
|
|
685
|
-
Author.select { self.case(mod(:age, 10)).when(0).then(
|
|
753
|
+
Author.select { self.case(mod(:age, 10)).when(0).then("round").else("not").as(:v) }
|
|
686
754
|
```
|
|
687
755
|
|
|
688
756
|
A `when` takes a value or a block, and so do `then` and `else`; the block is
|
|
@@ -694,9 +762,9 @@ something that reaches the database:
|
|
|
694
762
|
|
|
695
763
|
```ruby
|
|
696
764
|
Author.select {
|
|
697
|
-
case_when { :age < 18 }.then(
|
|
698
|
-
when { :age >= 60 }.then(
|
|
699
|
-
else(
|
|
765
|
+
case_when { :age < 18 }.then("minor").
|
|
766
|
+
when { :age >= 60 }.then("senior").
|
|
767
|
+
else("adult").as(:band)
|
|
700
768
|
}
|
|
701
769
|
|
|
702
770
|
Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
@@ -712,10 +780,10 @@ document to be dug into further or asked the JSON questions. `dig_text` gives
|
|
|
712
780
|
the value as text instead, which is what a comparison wants:
|
|
713
781
|
|
|
714
782
|
```ruby
|
|
715
|
-
Post.where { :meta.dig_text(:author, :name) ==
|
|
783
|
+
Post.where { :meta.dig_text(:author, :name) == "alice" }
|
|
716
784
|
Post.select { :meta.dig(:author).as(:author) }
|
|
717
785
|
Post.where { :meta.key?(:draft) }
|
|
718
|
-
Post.where { :meta.contains?(status:
|
|
786
|
+
Post.where { :meta.contains?(status: "open") }
|
|
719
787
|
```
|
|
720
788
|
|
|
721
789
|
No two adapters spell any of this alike, and the block is the same on all
|
|
@@ -723,9 +791,9 @@ three:
|
|
|
723
791
|
|
|
724
792
|
| | PostgreSQL | SQLite | MySQL |
|
|
725
793
|
| --- | --- | --- | --- |
|
|
726
|
-
| `dig(:a)` | `#> '{a}'` | `-> '$.a'` | `JSON_EXTRACT(…, '$.a')` |
|
|
794
|
+
| `dig(:a, :b)` | `#> '{a,b}'` | `-> '$.a.b'` | `JSON_EXTRACT(…, '$.a.b')` |
|
|
727
795
|
| `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
728
|
-
| `key?(:a)` |
|
|
796
|
+
| `key?(:a)` | `? 'a'` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
729
797
|
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
730
798
|
|
|
731
799
|
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
@@ -736,8 +804,8 @@ value with its type, so a comparison that worked there would fail on the other
|
|
|
736
804
|
two; a number is compared through a `cast` on all three:
|
|
737
805
|
|
|
738
806
|
```ruby
|
|
739
|
-
Post.where { :meta.dig_text(:n) ==
|
|
740
|
-
Post.where { cast(:meta.dig_text(:n),
|
|
807
|
+
Post.where { :meta.dig_text(:n) == "5" }
|
|
808
|
+
Post.where { cast(:meta.dig_text(:n), "integer") > 6 } # 'signed' on MySQL
|
|
741
809
|
```
|
|
742
810
|
|
|
743
811
|
The type is the adapter's own name for it, here as everywhere `cast` is used.
|
|
@@ -748,32 +816,53 @@ as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
|
|
|
748
816
|
MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
|
|
749
817
|
three.
|
|
750
818
|
|
|
751
|
-
Comparing
|
|
752
|
-
than being left to the adapters, which answer it three
|
|
753
|
-
5` is true on SQLite, an error on PostgreSQL and true
|
|
754
|
-
`dig_text(:flag) == true` is true, an error and false. `cast`
|
|
755
|
-
which type was meant, and then all three agree.
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
819
|
+
Comparing `dig_text`'s value with anything but a string raises
|
|
820
|
+
`ArgumentError` rather than being left to the adapters, which answer it three
|
|
821
|
+
ways: `dig_text(:n) == 5` is true on SQLite, an error on PostgreSQL and true
|
|
822
|
+
on MySQL, and `dig_text(:flag) == true` is true, an error and false. `cast`
|
|
823
|
+
is what says which type was meant, and then all three agree.
|
|
824
|
+
|
|
825
|
+
A JSON comparison — `dig`'s side, and `bury`'s and `except`'s — belongs to
|
|
826
|
+
the JSON types: on PostgreSQL's `jsonb` and MySQL's `JSON` alike, numbers
|
|
827
|
+
compare as numbers and documents structurally, key order and spelling aside,
|
|
828
|
+
so a dug value compares with a Ruby one directly. SQLite and MariaDB have
|
|
829
|
+
only the text of each, which is a different question, and raise
|
|
830
|
+
`NotImplementedError` as the SQL is written. `in?` and `between?` are the
|
|
831
|
+
two MySQL leaves out of its JSON comparisons, so there they are spelled as
|
|
832
|
+
the comparisons they mean — the range as its bounds, the list as one
|
|
833
|
+
equality per element, which names the dug value once per element the way
|
|
834
|
+
SQLite's XOR names its operands twice:
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
Post.where { :meta.dig(:stars) >= 10 } # PostgreSQL and MySQL
|
|
838
|
+
Post.where { :meta.dig(:author) == { "name" => "alice" } }
|
|
839
|
+
Post.where { :meta.dig(:stars).in?([5, 10]) }
|
|
840
|
+
Post.where { cast(:meta.dig_text(:stars), "integer") >= 10 } # everywhere
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
A column, a function or another dug value on the right goes through untouched
|
|
844
|
+
on every adapter. Arithmetic and the bit operators are refused outright on
|
|
845
|
+
both sides — `dig_text(:n) + 1` is 6 on SQLite, an error on PostgreSQL and
|
|
846
|
+
6.0 on MariaDB — and `cast` settles those too.
|
|
760
847
|
|
|
761
848
|
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
762
849
|
the path to it. The document comes back changed rather than being written
|
|
763
850
|
anywhere, so `update_all` is what makes it stick:
|
|
764
851
|
|
|
765
852
|
```ruby
|
|
766
|
-
Post.update_all { { meta: :meta.bury(:author, :name,
|
|
853
|
+
Post.update_all { { meta: :meta.bury(:author, :name, "alice") } }
|
|
767
854
|
# SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
|
|
768
855
|
# ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
|
|
769
856
|
|
|
770
|
-
Post.update_all { { meta: :meta.bury(:tags, [
|
|
771
|
-
Post.update_all { { meta: :meta.bury(:copy, :meta.
|
|
857
|
+
Post.update_all { { meta: :meta.bury(:tags, ["ruby", "sql"]) } }
|
|
858
|
+
Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
|
|
772
859
|
```
|
|
773
860
|
|
|
774
861
|
A whole document goes in as one — an object or an array rather than the string
|
|
775
|
-
that spells it — which each adapter takes its own way round
|
|
776
|
-
|
|
862
|
+
that spells it — which each adapter takes its own way round, and a boolean
|
|
863
|
+
goes in as JSON too, which SQLite would otherwise write as its `1`. `bury` is
|
|
864
|
+
not a Ruby method; it is the name Ruby considered for the other end of `dig`,
|
|
865
|
+
and
|
|
777
866
|
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
778
867
|
the others say `JSON_SET`.
|
|
779
868
|
|
|
@@ -787,7 +876,7 @@ Post.update_all { { meta: :meta.except(:draft) } }
|
|
|
787
876
|
# SET "meta" = "meta" - CAST('{"draft"}' AS text[])
|
|
788
877
|
# ... JSON_REMOVE("meta", '$.draft') elsewhere
|
|
789
878
|
|
|
790
|
-
Post.update_all { { meta: :meta.bury(:author, :name,
|
|
879
|
+
Post.update_all { { meta: :meta.bury(:author, :name, "alice").except(:tmp) } }
|
|
791
880
|
```
|
|
792
881
|
|
|
793
882
|
A key that is not there is not an error, as it is not to `Hash#except`. The
|
|
@@ -796,19 +885,26 @@ keys, an element by index — and an array literal written without a type is
|
|
|
796
885
|
read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
|
|
797
886
|
`{draft}`, which is nothing, and says nothing about it.
|
|
798
887
|
|
|
888
|
+
A key deeper in is reached through the chain: `dig` reads the part out,
|
|
889
|
+
`except` takes the key from it, and `bury` puts it back:
|
|
890
|
+
|
|
891
|
+
```ruby
|
|
892
|
+
Post.update_all { { meta: :meta.bury(:author, :meta.dig(:author).except(:email)) } }
|
|
893
|
+
```
|
|
894
|
+
|
|
799
895
|
What `dig` gives is a document, so the JSON operations read it — the same
|
|
800
896
|
question asked of a part of the document rather than of all of it:
|
|
801
897
|
|
|
802
898
|
```ruby
|
|
803
899
|
Post.where { :meta.dig(:author).key?(:email) }
|
|
804
|
-
Post.where { :meta.dig(:author).dig_text(:name) ==
|
|
805
|
-
Post.update_all { { meta: :meta.dig(:author).bury(:name,
|
|
900
|
+
Post.where { :meta.dig(:author).dig_text(:name) == "alice" }
|
|
901
|
+
Post.update_all { { meta: :meta.dig(:author).bury(:name, "alice") } }
|
|
806
902
|
```
|
|
807
903
|
|
|
808
904
|
Containment reads it too, on the adapters that have containment at all:
|
|
809
905
|
|
|
810
906
|
```ruby
|
|
811
|
-
Post.where { :meta.dig(:tags).contains?([
|
|
907
|
+
Post.where { :meta.dig(:tags).contains?(["ruby"]) }
|
|
812
908
|
```
|
|
813
909
|
|
|
814
910
|
Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
|
|
@@ -818,12 +914,93 @@ function for text at all.
|
|
|
818
914
|
|
|
819
915
|
`contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
820
916
|
there — later than the rest, since the adapter is only known when the SQL is
|
|
821
|
-
built. On PostgreSQL, `
|
|
822
|
-
`
|
|
917
|
+
built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
|
|
918
|
+
`key?`, `contains?`, `bury` and `except` want a `jsonb` column.
|
|
823
919
|
|
|
824
920
|
A key that is not a plain name travels as itself rather than being refused:
|
|
825
921
|
`dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
|
|
826
922
|
|
|
923
|
+
`keys` gives the keys of the document, as `Hash#keys` does — a JSON array
|
|
924
|
+
of them. Only the MySQL family has a function for it; the other two reach
|
|
925
|
+
the same array through a subquery over their key-listing functions, guarded
|
|
926
|
+
by type so that all four answer alike: the keys of anything that is not an
|
|
927
|
+
object are `NULL` — rather than SQLite's array indices or PostgreSQL's
|
|
928
|
+
error — and the keys of `{}` are `[]` rather than PostgreSQL's `NULL`:
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
Post.select { :meta.keys.as(:fields) }
|
|
932
|
+
Post.select { :meta.dig(:author).keys.as(:author_fields) }
|
|
933
|
+
# JSON_KEYS("meta") MySQL
|
|
934
|
+
# CASE WHEN jsonb_typeof("meta") = 'object' THEN COALESCE((…)) PostgreSQL
|
|
935
|
+
# CASE WHEN json_type("meta") = 'object' THEN (SELECT …) SQLite
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
The order the keys come in is the adapters' own: the JSON types give their
|
|
939
|
+
normalized order and the text ones the stored order — the same divide every
|
|
940
|
+
JSON comparison here rides on.
|
|
941
|
+
|
|
942
|
+
`json_array` and `json_object` build a document in the row — `json_array`
|
|
943
|
+
from the values given, `json_object` from a Ruby hash. The names are the
|
|
944
|
+
standard's, which SQLite and the MySQL family say as written; PostgreSQL is
|
|
945
|
+
asked to build `jsonb`. A hash rather than SQL's alternating keys and
|
|
946
|
+
values, because a bare symbol means a column in every block here: the keys
|
|
947
|
+
are Ruby's and the values are expressions, so `title: :title` reads the
|
|
948
|
+
column in under its own name with no rule to remember:
|
|
949
|
+
|
|
950
|
+
```ruby
|
|
951
|
+
Post.select { json_object(title: :title, stars: :meta.dig(:stars)).as(:summary) }
|
|
952
|
+
# jsonb_build_object('title', "title", 'stars', "meta" #> '{stars}') PostgreSQL
|
|
953
|
+
# JSON_OBJECT('title', "title", 'stars', JSON_EXTRACT(…)) elsewhere
|
|
954
|
+
|
|
955
|
+
Post.where { :meta.dig(:author) == json_object(name: :name) }
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
A Ruby value among the arguments goes in as its JSON self — a string or a
|
|
959
|
+
number as themselves, `nil` as `null`, and a boolean or a whole document
|
|
960
|
+
through the same route `bury` takes them, so SQLite's `true` is not its
|
|
961
|
+
`1`. A key that is not a string or a symbol is refused, before the
|
|
962
|
+
adapters answer a NULL key three ways. The empty calls stand —
|
|
963
|
+
`json_array()` is `[]` and `json_object()` is `{}` on all four — and what
|
|
964
|
+
comes back is JSON as `dig`'s is, so the operations and comparisons above
|
|
965
|
+
read it.
|
|
966
|
+
|
|
967
|
+
`json_arrayagg` and `json_objectagg` gather rows into one JSON document — a
|
|
968
|
+
value from each row into an array, a key and a value into an object. The
|
|
969
|
+
names are the SQL standard's, which the MySQL family says as written;
|
|
970
|
+
PostgreSQL is asked the `jsonb` pair and SQLite its own:
|
|
971
|
+
|
|
972
|
+
```ruby
|
|
973
|
+
Post.group { :author_id }.select { json_arrayagg(:title).as(:titles) }
|
|
974
|
+
# jsonb_agg("title") PostgreSQL
|
|
975
|
+
# json_group_array("title") SQLite
|
|
976
|
+
# JSON_ARRAYAGG("title") MySQL
|
|
977
|
+
|
|
978
|
+
Post.select { json_objectagg(:title, :meta.dig(:stars)).as(:stars) }
|
|
979
|
+
|
|
980
|
+
Post.group { :author_id }.
|
|
981
|
+
select { json_arrayagg(json_object(title: :title, stars: :meta.dig(:stars))).as(:posts) }
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
What they give is JSON as `dig`'s is, so it compares the way a dug value
|
|
985
|
+
does, and `filter` and `over` come along as with any aggregate — with two
|
|
986
|
+
refusals where a respelling would change the meaning rather than the
|
|
987
|
+
spelling. The MySQL family has no `FILTER`, and the `CASE` that stands in
|
|
988
|
+
for it elsewhere would leave a JSON `null` in the document for every row it
|
|
989
|
+
drops, so there `filter` raises `NotImplementedError`; MariaDB takes every
|
|
990
|
+
other aggregate as a window function but not these two, so `over` raises
|
|
991
|
+
there too.
|
|
992
|
+
|
|
993
|
+
The documents agree across adapters, up to the edges of their JSON types.
|
|
994
|
+
Over no rows at all SQLite answers `[]` and `{}` where the others answer
|
|
995
|
+
`NULL`, as their aggregates do. A key aggregated twice keeps the last pair
|
|
996
|
+
on the JSON types — `jsonb` and MySQL's — and every pair on the text ones,
|
|
997
|
+
SQLite and MariaDB, and a `NULL` key is an error on the former pair and a
|
|
998
|
+
dropped pair on the latter. And a bare JSON *column* is text to SQLite's
|
|
999
|
+
`json_group_array`, so it lands as the string that spells the document
|
|
1000
|
+
rather than nesting as it does on the other three; a dug value nests
|
|
1001
|
+
everywhere, so `json_arrayagg(:meta.dig(:author))` is the portable way to
|
|
1002
|
+
collect part of a document.
|
|
1003
|
+
|
|
827
1004
|
### Window functions
|
|
828
1005
|
|
|
829
1006
|
`over` gives a function a window, which is what turns an aggregate into a
|
|
@@ -982,14 +1159,17 @@ the default; set `ADAPTER` to run the same suite against another one.
|
|
|
982
1159
|
```sh
|
|
983
1160
|
rake test # sqlite3
|
|
984
1161
|
ADAPTER=postgresql rake test
|
|
985
|
-
ADAPTER=mysql2 rake test
|
|
986
|
-
rake test:
|
|
1162
|
+
ADAPTER=mysql2 rake test # MariaDB
|
|
1163
|
+
rake test:mysql8 # Oracle's MySQL, on port 3307
|
|
1164
|
+
rake test:all # all of the above; MySQL skipped when 3307 is empty
|
|
987
1165
|
```
|
|
988
1166
|
|
|
989
|
-
PostgreSQL and
|
|
990
|
-
password, which is how the devcontainer sets them up
|
|
991
|
-
|
|
992
|
-
|
|
1167
|
+
PostgreSQL and the MySQLs are reached on `127.0.0.1` as the current user with
|
|
1168
|
+
no password, which is how the devcontainer sets them up — MariaDB on its own
|
|
1169
|
+
port and Oracle's MySQL on 3307, since the two answer the `mysql2` adapter
|
|
1170
|
+
differently and CI runs both. Override with `DB_HOST`, `DB_PORT`,
|
|
1171
|
+
`DB_USERNAME` and `DB_PASSWORD`. The `activerecord_refined_test` database is
|
|
1172
|
+
created on first use.
|
|
993
1173
|
|
|
994
1174
|
The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
|
|
995
1175
|
needs the client libraries installed. Skip them if SQLite is all you need,
|
data/Rakefile
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "bundler/gem_tasks"
|
|
2
4
|
require "rake/testtask"
|
|
5
|
+
require "socket"
|
|
3
6
|
|
|
4
7
|
ADAPTERS = %w[sqlite3 postgresql mysql2].freeze
|
|
5
8
|
|
|
9
|
+
# The devcontainer serves Oracle's MySQL beside MariaDB, on 3307. A
|
|
10
|
+
# container from before it existed serves nothing there, and test:all says
|
|
11
|
+
# so rather than failing or keeping quiet.
|
|
12
|
+
def mysql8_reachable?
|
|
13
|
+
Socket.tcp("127.0.0.1", 3307, connect_timeout: 1) { true }
|
|
14
|
+
rescue SystemCallError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
6
18
|
Rake::TestTask.new do |t|
|
|
7
|
-
t.test_files = FileList[
|
|
19
|
+
t.test_files = FileList["test/test_*.rb"]
|
|
8
20
|
end
|
|
9
21
|
|
|
10
22
|
namespace :test do
|
|
@@ -12,14 +24,30 @@ namespace :test do
|
|
|
12
24
|
desc "Run the tests against #{adapter}"
|
|
13
25
|
task adapter do
|
|
14
26
|
puts "==== #{adapter} ===="
|
|
15
|
-
ENV[
|
|
27
|
+
ENV["ADAPTER"] = adapter
|
|
28
|
+
ENV.delete("DB_PORT")
|
|
16
29
|
Rake::Task[:test].reenable
|
|
17
30
|
Rake::Task[:test].invoke
|
|
18
31
|
end
|
|
19
32
|
end
|
|
20
33
|
|
|
34
|
+
desc "Run the tests against MySQL, which the devcontainer serves on 3307"
|
|
35
|
+
task :mysql8 do
|
|
36
|
+
puts "==== mysql2 (MySQL, port 3307) ===="
|
|
37
|
+
ENV["ADAPTER"] = "mysql2"
|
|
38
|
+
ENV["DB_PORT"] = "3307"
|
|
39
|
+
Rake::Task[:test].reenable
|
|
40
|
+
Rake::Task[:test].invoke
|
|
41
|
+
end
|
|
42
|
+
|
|
21
43
|
desc "Run the tests against every adapter in turn"
|
|
22
|
-
task all: ADAPTERS
|
|
44
|
+
task all: ADAPTERS do
|
|
45
|
+
if mysql8_reachable?
|
|
46
|
+
Rake::Task["test:mysql8"].invoke
|
|
47
|
+
else
|
|
48
|
+
puts "MySQL is not listening on 3307; skipped"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
23
51
|
end
|
|
24
52
|
|
|
25
53
|
task default: :test
|