activerecord-refined 0.8.1 → 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.
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`, which will be introduced in Ruby 4.1. `Proc#refined` returns a new proc that
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 'activerecord-refined'
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?('A%') } # 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?('A%') } # NOT LIKE
98
- Author.where { :name.not_ilike?('a%') } # NOT ILIKE / NOT LIKE
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?('A') } # NOT (name LIKE 'A%')
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: 'JP').select(:age)) }
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?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
182
- Author.where { :name.casecmp?('Alice') } # LOWER(name) = LOWER('Alice')
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?('JP') } # keeps the NULL rows
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?('A') } # LIKE 'A%'
201
- Author.where { :name.end_with?('son') } # LIKE '%son'
202
- Author.where { :name.include?('test') } # LIKE '%test%'
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?('A', 'B') }
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?('ruby') } # tags @> '{ruby}'
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 =~ '^A' } # REGEXP / ~
237
- Author.where { :name !~ '^A' } # NOT REGEXP / !~
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 == 'JP') | (:country == 'US')) }
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?('%test%') } # NOT (name LIKE '%test%')
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
- These are PostgreSQL's. SQLite has none of them, and MySQL has only `WITH
364
- ROLLUP`, which says one of the three and says it elsewhere in the clause, so
365
- the block raises `NotImplementedError` on both.
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 == 'JP' }.as(:jp_years)]
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, 'day', :created_at).as(:day) }
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, 'decimal(10,2)').as(: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 Active Record reads a string in `select` as SQL,
652
- so `value` is how you ask for the other meaning. It carries the predications
653
- and arithmetic with it, so a literal can be compared and combined like
654
- anything else. Numbers have a shorthand, since nothing else could be meant by
655
- one:
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, value('draft').as(:state)] }
733
+ Post.select { [:title, "draft".as(:state)] }
664
734
  # SELECT "posts"."title", 'draft' AS state FROM "posts"
665
735
  ```
666
736
 
667
- The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
668
- SQL in a select list, a value everywhere else and refining it would make the
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('JP').then('Japan').else('elsewhere').as(:where) }
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('senior').else('adult').as(:band) }
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('round').else('not').as(:v) }
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('minor').
698
- when { :age >= 60 }.then('senior').
699
- else('adult').as(:band)
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) == 'alice' }
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: 'open') }
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
@@ -725,7 +793,7 @@ three:
725
793
  | --- | --- | --- | --- |
726
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)` | `jsonb_exists(…, 'a')` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.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) == '5' }
740
- Post.where { cast(:meta.dig_text(:n), 'integer') > 6 } # 'signed' on MySQL
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,29 +816,45 @@ 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 a dug value with anything but a string raises `ArgumentError` rather
752
- than being left to the adapters, which answer it three ways: `dig_text(:n) ==
753
- 5` is true on SQLite, an error on PostgreSQL and true on MySQL, and
754
- `dig_text(:flag) == true` is true, an error and false. `cast` is what says
755
- which type was meant, and then all three agree. `dig` is refused the other way
756
- about — the JSON for a string carries its quotes, so `dig(:name) == 'alice'`
757
- is false, an error and true and `dig_text` is the one that gives the value.
758
- What `bury` and `except` give back is JSON as `dig`'s is, and is refused the
759
- same way. A column, a function or another dug value on the right goes through
760
- untouched; only a Ruby literal is refused. Arithmetic and the bit operators
761
- are refused outright on both sides — `dig_text(:n) + 1` is 6 on SQLite, an
762
- error on PostgreSQL and 6.0 on MariaDB — and `cast` settles those too.
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.
763
847
 
764
848
  `bury` sets what `dig` reads: the last argument is the value and the rest are
765
849
  the path to it. The document comes back changed rather than being written
766
850
  anywhere, so `update_all` is what makes it stick:
767
851
 
768
852
  ```ruby
769
- Post.update_all { { meta: :meta.bury(:author, :name, 'alice') } }
853
+ Post.update_all { { meta: :meta.bury(:author, :name, "alice") } }
770
854
  # SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
771
855
  # ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
772
856
 
773
- Post.update_all { { meta: :meta.bury(:tags, ['ruby', 'sql']) } }
857
+ Post.update_all { { meta: :meta.bury(:tags, ["ruby", "sql"]) } }
774
858
  Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
775
859
  ```
776
860
 
@@ -792,7 +876,7 @@ Post.update_all { { meta: :meta.except(:draft) } }
792
876
  # SET "meta" = "meta" - CAST('{"draft"}' AS text[])
793
877
  # ... JSON_REMOVE("meta", '$.draft') elsewhere
794
878
 
795
- Post.update_all { { meta: :meta.bury(:author, :name, 'alice').except(:tmp) } }
879
+ Post.update_all { { meta: :meta.bury(:author, :name, "alice").except(:tmp) } }
796
880
  ```
797
881
 
798
882
  A key that is not there is not an error, as it is not to `Hash#except`. The
@@ -813,14 +897,14 @@ question asked of a part of the document rather than of all of it:
813
897
 
814
898
  ```ruby
815
899
  Post.where { :meta.dig(:author).key?(:email) }
816
- Post.where { :meta.dig(:author).dig_text(:name) == 'alice' }
817
- Post.update_all { { meta: :meta.dig(:author).bury(:name, 'alice') } }
900
+ Post.where { :meta.dig(:author).dig_text(:name) == "alice" }
901
+ Post.update_all { { meta: :meta.dig(:author).bury(:name, "alice") } }
818
902
  ```
819
903
 
820
904
  Containment reads it too, on the adapters that have containment at all:
821
905
 
822
906
  ```ruby
823
- Post.where { :meta.dig(:tags).contains?(['ruby']) }
907
+ Post.where { :meta.dig(:tags).contains?(["ruby"]) }
824
908
  ```
825
909
 
826
910
  Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
@@ -836,6 +920,87 @@ built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
836
920
  A key that is not a plain name travels as itself rather than being refused:
837
921
  `dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
838
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
+
839
1004
  ### Window functions
840
1005
 
841
1006
  `over` gives a function a window, which is what turns an aggregate into a
@@ -994,14 +1159,17 @@ the default; set `ADAPTER` to run the same suite against another one.
994
1159
  ```sh
995
1160
  rake test # sqlite3
996
1161
  ADAPTER=postgresql rake test
997
- ADAPTER=mysql2 rake test
998
- rake test:all # all three in turn
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
999
1165
  ```
1000
1166
 
1001
- PostgreSQL and MySQL are reached on `127.0.0.1` as the current user with no
1002
- password, which is how the devcontainer sets them up. Override with
1003
- `DB_HOST`, `DB_USERNAME` and `DB_PASSWORD`. The `activerecord_refined_test`
1004
- database is created on first use.
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.
1005
1173
 
1006
1174
  The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
1007
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['test/test_*.rb']
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['ADAPTER'] = adapter
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