activerecord-refined 0.8.1 → 0.10.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +111 -815
  4. data/activerecord-refined.gemspec +36 -17
  5. data/docs/conditions.md +206 -0
  6. data/docs/ctes.md +65 -0
  7. data/docs/expressions.md +125 -0
  8. data/docs/functions.md +219 -0
  9. data/docs/grouping.md +55 -0
  10. data/docs/joins.md +73 -0
  11. data/docs/json.md +230 -0
  12. data/docs/ordering.md +55 -0
  13. data/docs/time_zones.md +30 -0
  14. data/docs/windows.md +41 -0
  15. data/docs/writing.md +33 -0
  16. data/examples/aggregations.rb +31 -11
  17. data/examples/complex_joins.rb +12 -10
  18. data/examples/ctes.rb +22 -20
  19. data/examples/expressions.rb +110 -45
  20. data/examples/json.rb +77 -38
  21. data/examples/postgresql.rb +64 -53
  22. data/examples/predicates.rb +35 -33
  23. data/examples/subqueries.rb +20 -18
  24. data/examples/windows.rb +23 -21
  25. data/examples/writes.rb +26 -24
  26. data/lib/active_record/refined/ast.rb +1117 -373
  27. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  28. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  29. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  30. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  31. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  32. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  33. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  34. data/lib/active_record/refined/dialect.rb +340 -0
  35. data/lib/active_record/refined.rb +877 -307
  36. data/lib/activerecord-refined/version.rb +3 -1
  37. data/lib/activerecord-refined.rb +9 -5
  38. metadata +186 -15
  39. data/.github/workflows/push_gem.yml +0 -45
  40. data/.github/workflows/sandbox.yml +0 -295
  41. data/.github/workflows/test.yml +0 -90
  42. data/.gitignore +0 -19
  43. data/Gemfile +0 -12
  44. data/Rakefile +0 -25
  45. data/benchmark/query_building.rb +0 -129
  46. data/test/test_block_syntax.rb +0 -2493
  47. data/test/test_helper.rb +0 -221
data/docs/functions.md ADDED
@@ -0,0 +1,219 @@
1
+ # Aggregates and functions
2
+
3
+
4
+ `count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
5
+ bit aggregates and the scalar functions below, with `fn` for anything else. Return an array to select
6
+ or order by multiple expressions.
7
+
8
+ `filter` takes the aggregate over the rows a condition holds for, as a value
9
+ or a block:
10
+
11
+ ```ruby
12
+ Author.select { count(:*).filter { :age < 50 }.as(:young) }
13
+ # COUNT(*) FILTER (WHERE "age" < 50) AS "young"
14
+
15
+ Author.select {
16
+ [count(:*).as(:all), sum(:age).filter { :country == "JP" }.as(:jp_years)]
17
+ }
18
+ ```
19
+
20
+ MySQL has no `FILTER` clause, and gets the case that means the same thing —
21
+ `COUNT(CASE WHEN "age" < 50 THEN 1 END)`. An aggregate passes over a NULL, so
22
+ a row the condition misses is a row it does not see, and the number that comes
23
+ back is the same on all three.
24
+
25
+ Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
26
+ `COUNT(DISTINCT ...)`. `sum` and `avg` take `distinct: true` as well, for the
27
+ aggregate over each value once; `min` and `max` would give the same with or
28
+ without it, so they take no such thing:
29
+
30
+ ```ruby
31
+ Author.group { :country }.having { count(:*) > 1 }
32
+ # SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
33
+
34
+ Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
35
+ Post.select { sum(:likes, distinct: true) } # SUM(DISTINCT "likes")
36
+ ```
37
+
38
+ `string_agg` joins the strings of a group into one, a separator between —
39
+ `,` unless another is given — and `.order` says the order they are joined
40
+ in. Each database has it under a name of its own, with the `ORDER BY` in a
41
+ place of its own:
42
+
43
+ ```ruby
44
+ Post.group { :author_id }.select { string_agg(:title, ", ").order(:title).as(:titles) }
45
+ # STRING_AGG("title", ', ' ORDER BY "title") PostgreSQL
46
+ # group_concat("title", ', ' ORDER BY "title") SQLite
47
+ # GROUP_CONCAT("title" ORDER BY "title" SEPARATOR ', ') MySQL
48
+ # STRING_AGG("title", ', ') WITHIN GROUP (ORDER BY "title") SQL Server
49
+ # LISTAGG("title", ', ') WITHIN GROUP (ORDER BY "title") Oracle
50
+ ```
51
+
52
+ `filter` and `over` come along as with any aggregate, `over` where the
53
+ database takes it — the MySQL family and SQL Server do not, and raise.
54
+ Oracle's insists on an order and is given the values' own when none is asked
55
+ for. What is joined is text: PostgreSQL's `STRING_AGG` takes nothing else, so
56
+ a column the model does not declare a string is cast there, where the others
57
+ convert for themselves. MySQL cuts the result at `group_concat_max_len`, 1024
58
+ bytes unless the session says otherwise, and the `ORDER BY` inside SQLite's
59
+ call needs SQLite 3.44.
60
+
61
+ The scalar functions are real methods rather than anything caught dynamically,
62
+ so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
63
+ answers to — `rand` — means the SQL one inside a block:
64
+
65
+ ```
66
+ abs acos asin atan atan2 bit_and bit_count bit_or bit_xor cast
67
+ ceil char_length coalesce concat
68
+ cos current_date current_time current_timestamp date_trunc degrees
69
+ exp extract floor format greatest least length ln localtime
70
+ localtimestamp log log10 log2 lower ltrim mod now nullif pi
71
+ power radians rand replace round rtrim sign sin sqrt substr tan
72
+ trim trunc upper
73
+ ```
74
+
75
+ Most are spelled the same everywhere. Where they are not, the method names one
76
+ meaning and each adapter gets its own spelling: `char_length`, `greatest` and
77
+ `least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
78
+ MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
79
+ insists on the second argument the others default to zero — SQLite's takes
80
+ only the one. Where an adapter has no equivalent — `date_trunc` outside
81
+ PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
82
+ whose spelling is `log(2, x)`, the four `bit_*` on SQLite — the block raises
83
+ `NotImplementedError` rather than leaving the database to reject the SQL.
84
+
85
+ `format` is printf formatting, and raises on MySQL, where a function of the
86
+ same name does something else entirely: it puts separators in a number, and
87
+ reads a printf template as the number zero rather than complaining. `fn` still
88
+ reaches it, spelled as the different thing it is:
89
+
90
+ ```ruby
91
+ Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
92
+ ```
93
+
94
+ `fn` reaches functions without a method of their own. Its name is emitted as
95
+ written, so a case-sensitive one can be spelled exactly:
96
+
97
+ ```ruby
98
+ Post.select { fn(:date_trunc, "day", :created_at).as(:day) }
99
+ # SELECT date_trunc('day', "posts"."created_at") AS day
100
+ ```
101
+
102
+ `op` is the same escape hatch for operators — PostgreSQL alone has dozens
103
+ with no method here, `<@` and `&&` and the geometric ones among them. The
104
+ operator is emitted as written and, like `fn`'s names, whether the adapter
105
+ has it is your assertion; it is checked against the characters PostgreSQL
106
+ allows an operator, so a letter, a space or a quote is refused rather than
107
+ written into the SQL. Both sides take what `fn`'s arguments take — a
108
+ column, an expression, a value quoted by the adapter — and a value is
109
+ spelled in the adapter's own syntax, `to_json` saying a document, `'{a,b}'`
110
+ an array; a Ruby Hash or Array is refused rather than guessed at. The
111
+ result is parenthesized, its precedence being unknown, and so is an
112
+ expression on either side, so a dug value cannot be re-grouped out from
113
+ under it:
114
+
115
+ ```ruby
116
+ Post.where { op("&&", :tags, "{ruby,sql}") }
117
+ # WHERE ("posts"."tags" && '{ruby,sql}')
118
+
119
+ Post.where { op("<@", :meta.dig(:author), { name: "alice" }.to_json) }
120
+ # WHERE (("meta" #> '{author}') <@ '{"name":"alice"}')
121
+ ```
122
+
123
+ `sql` is the last resort, for what neither `fn` nor `op` can spell: the
124
+ statement goes out as written. It is the one way a string means SQL inside a
125
+ block — everywhere else a string is a value — so writing SQL is always asked
126
+ for by name, and an interpolation has a spelling that is not it: `?` and
127
+ `:name` placeholders take values quoted by the adapter, through
128
+ `sanitize_sql_array`. A `?` is rewritten only when there are positional binds
129
+ to put in it, so PostgreSQL's `?` operators can share a statement with named
130
+ binds, or with none. The result carries the predications and arithmetic, and
131
+ is parenthesized where it stands inside a larger expression — its precedence
132
+ is whatever was written — but comes out bare at the top of a select list,
133
+ where parentheses would refuse an alias written into the string:
134
+
135
+ ```ruby
136
+ Post.where { sql("length(title) > ?", 10) }
137
+ # WHERE (length(title) > 10)
138
+
139
+ Post.where { sql("score + ?", 10) * 2 >= 60 }
140
+ # WHERE (score + 10) * 2 >= 60
141
+
142
+ Post.select { sql("count(*) FILTER (WHERE score > 0) AS positive") }
143
+ ```
144
+
145
+ Values are quoted by the adapter wherever they appear, as they are in
146
+ Active Record, and so is a column alias. That is what makes the name asked for
147
+ the name that comes back: unquoted, PostgreSQL folds a capital away where the
148
+ other two keep it, so one block would mean two things. It also leaves nothing
149
+ to refuse — a name that would have been SQL becomes an identifier with a
150
+ strange name instead:
151
+
152
+ ```ruby
153
+ Author.select { count(:*).as(:postCount) } # AS "postCount" everywhere
154
+ Author.select { count(:*).as(:'total sales') } # AS "total sales"
155
+ ```
156
+
157
+ `quote: false` asks for the name as written, for a schema that wants the
158
+ folding. Nothing quotes it then, so a name that is not plain is refused:
159
+
160
+ ```ruby
161
+ Author.select { count(:*).as(:post_count, quote: false) } # AS post_count
162
+ Author.select { count(:*).as(:'total sales', quote: false) } # ArgumentError
163
+ ```
164
+
165
+ `fn`'s function name is the one that cannot be quoted: quoting stops
166
+ PostgreSQL folding it, and `"UPPER"(x)` is a function that does not exist.
167
+ That one, `cast`'s type and `extract`'s field are neither values nor
168
+ identifiers, so they have to be plain names and anything else raises
169
+ `ArgumentError` rather than reaching the query.
170
+
171
+ `current_date`, `current_time`, `current_timestamp`, `localtime` and
172
+ `localtimestamp` come out without parentheses, as the grammar has them —
173
+ written as calls, PostgreSQL and SQLite would reject them. What does go into
174
+ parentheses is an optional precision — `current_timestamp(3)` — which
175
+ `current_date` never takes and SQLite never accepts. `current_timestamp` is
176
+ the portable spelling of what `now` means, and reaches SQLite where `now`
177
+ does not — and SQL Server, which has none of the other four:
178
+
179
+ ```ruby
180
+ Post.where { :published_at <= current_timestamp }
181
+ # SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
182
+ ```
183
+
184
+ `extract` and `cast` are grammar as well: the field and the type go where no
185
+ value could. The field has to be a plain name, and the type has to look like
186
+ a type — a plain name, at most parenthesized with lengths, so the adapters'
187
+ own spellings like `double precision` or `decimal(10,2)` pass; anything else
188
+ raises `ArgumentError`. The type is the adapter's own name for the type, and
189
+ whether it exists is the database's to say. SQLite spells everything
190
+ `extract` does as `strftime` formats, which no renaming carries, so `extract`
191
+ raises there:
192
+
193
+ ```ruby
194
+ Post.where { extract(:year, :created_at) == 2026 }
195
+ # SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
196
+
197
+ Post.select { cast(:price, "decimal(10,2)").as(:price) }
198
+ # SELECT CAST("posts"."price" AS decimal(10,2)) AS price
199
+ ```
200
+
201
+ A duration moves a date. Active Support's `7.days` and the rest go on the
202
+ right of `+` or `-`, and the move comes out the way the adapter spells it — an
203
+ `INTERVAL` literal on PostgreSQL and MySQL, `DATEADD` on SQL Server,
204
+ `datetime(x, '-7 day')` on SQLite:
205
+
206
+ ```ruby
207
+ Post.where { :created_at > current_timestamp - 7.days }
208
+ # WHERE "posts"."created_at" > (CURRENT_TIMESTAMP - INTERVAL '7' DAY)
209
+
210
+ Post.select { (:published_on + 1.month).as(:review_on) }
211
+ ```
212
+
213
+ A duration of several parts, `1.month + 2.days`, is applied a part at a time
214
+ in the order Active Support keeps them, and a week is seven days, which is
215
+ what SQLite and Oracle have of one. Each part has to be a whole number, since
216
+ it is written into the SQL as one: `1.5.days` raises `ArgumentError`. SQLite
217
+ has no date type, so there a column the model declares a date is moved by
218
+ `date()` and stays a date; everything else goes through `datetime()` and comes
219
+ back with a time of day.
data/docs/grouping.md ADDED
@@ -0,0 +1,55 @@
1
+ # Grouping
2
+
3
+ ## Keeping one row per group
4
+
5
+
6
+ `distinct_on` is PostgreSQL's `DISTINCT ON`: the first row of each group the
7
+ order brings up.
8
+
9
+ ```ruby
10
+ Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] }
11
+ # SELECT DISTINCT ON ( "author_id" ) "posts".* FROM "posts"
12
+ # ORDER BY "author_id", "likes" DESC
13
+ ```
14
+
15
+ Arel carries the node and refuses to write it for the others, the way it does
16
+ a regexp, so it raises `NotImplementedError` on SQLite and MySQL. The shape
17
+ that runs everywhere is a `row_number` window in a subquery, which says the
18
+ same thing at more length:
19
+
20
+ ```ruby
21
+ ranked = Post.select {
22
+ [:author_id, :likes, row_number.over.partition(:author_id).order(:likes.desc).as(:rn)]
23
+ }
24
+ Post.from(ranked, :posts).where { :rn == 1 }
25
+ ```
26
+
27
+ The subquery is named after the model's own table for the reason `from_cte`
28
+ is: Active Record goes on qualifying columns with that name, so `where` needs
29
+ to find it.
30
+
31
+ ## Grouping several ways at once
32
+
33
+
34
+ `grouping_sets`, `rollup` and `cube` ask for more than one grouping in a
35
+ single query, the totals of each coming back beside the rows. Each set is a
36
+ list of its own, and an empty one is the grand total:
37
+
38
+ ```ruby
39
+ Sale.group { grouping_sets([:region], [:product], []) }.
40
+ select { [:region, :product, sum(:amount).as(:total)] }
41
+ # GROUP BY GROUPING SETS( ( "region" ), ( "product" ), ( ) )
42
+
43
+ Sale.group { rollup(:region, :product) } # GROUP BY ROLLUP( "region", "product" )
44
+ Sale.group { cube(:region, :product) } # GROUP BY CUBE( "region", "product" )
45
+ ```
46
+
47
+ A row that a set did not group by comes back with NULL there, which is also
48
+ what a real NULL looks like; `fn(:grouping, :region)` tells the two apart.
49
+
50
+ `grouping_sets` and `cube` are PostgreSQL's; SQLite has none of the three and
51
+ both raise `NotImplementedError` elsewhere. `rollup` runs on MySQL and
52
+ MariaDB too, spelled as their `WITH ROLLUP` — which trails the whole group
53
+ list, so there a rollup cannot stand beside other group entries the way
54
+ `ROLLUP(...)` can, and the block says so. MariaDB is also the one that
55
+ refuses `ORDER BY` next to it, and the one without `fn(:grouping, ...)`.
data/docs/joins.md ADDED
@@ -0,0 +1,73 @@
1
+ # Joins
2
+
3
+ ## Joins
4
+
5
+
6
+ The block is the `ON` clause:
7
+
8
+ ```ruby
9
+ Author.
10
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
11
+ joins(:comments) { :comments[:post_id] == :posts[:id] }
12
+
13
+ Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
14
+ ```
15
+
16
+ `as` names the table within the query, which is what makes a self join
17
+ expressible — the qualified columns in the block go by that name:
18
+
19
+ ```ruby
20
+ Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
21
+ # SELECT "employees".* FROM "employees"
22
+ # INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
23
+ ```
24
+
25
+ `right_outer_joins` and `full_outer_joins` are the two Active Record has no
26
+ method for, and they take what `joins` takes. An association name is not among
27
+ it: what Active Record reads out of one is an inner or a left join and nothing
28
+ else, so these two want the block that says how to join.
29
+
30
+ ```ruby
31
+ Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
32
+ Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
33
+ ```
34
+
35
+ MySQL has no `FULL OUTER JOIN` and neither has MariaDB, so `full_outer_joins`
36
+ raises `NotImplementedError` there. SQLite has had one since 3.39.
37
+
38
+ `cross_joins` is every row of one table against every row of the other. There
39
+ is no condition to give, so it takes no block — `as` still names the table:
40
+
41
+ ```ruby
42
+ Post.cross_joins(:authors) # FROM "posts" CROSS JOIN "authors"
43
+ Post.cross_joins(:posts, as: :others) # FROM "posts" CROSS JOIN "posts" "others"
44
+ ```
45
+
46
+ ## Lateral joins
47
+
48
+
49
+ A relation marked `lateral` joins in place of a table, and sees the row being
50
+ joined to — in SQL the keyword modifies the subquery, not the join, so that is
51
+ where it is written. It is what makes the top row of each group reachable in
52
+ one query:
53
+
54
+ ```ruby
55
+ top_post = Post.select { :title }.
56
+ where { :posts[:author_id] == :authors[:id] }.
57
+ order { :likes.desc }.limit(1)
58
+
59
+ Author.left_outer_joins(top_post.lateral, as: :top).
60
+ select { [:name, :top[:title].as(:top_post)] }
61
+ # SELECT "name", "top"."title" AS "top_post" FROM "authors"
62
+ # LEFT OUTER JOIN LATERAL (SELECT "title" FROM "posts"
63
+ # WHERE "posts"."author_id" = "authors"."id" ORDER BY "likes" DESC LIMIT 1) "top" ON TRUE
64
+ ```
65
+
66
+ `as` is required — the relation has no name of its own to qualify with. Without
67
+ a block the join is `ON TRUE`, which is the usual shape: what the subquery is
68
+ allowed to see is said inside it. A block writes a real `ON` clause.
69
+
70
+ PostgreSQL has `LATERAL` and so has MySQL, from 8.0.14. SQLite has none, and
71
+ neither has MariaDB, which answers to the same adapter as MySQL; both raise
72
+ `NotImplementedError`. Arel has a node for it but only PostgreSQL's visitor
73
+ writes it, so the SQL is written here instead.
data/docs/json.md ADDED
@@ -0,0 +1,230 @@
1
+ # JSON
2
+
3
+
4
+ `dig` reads inside a JSON document, by the name of what `Hash` does. A string
5
+ or symbol steps into an object, an integer into an array, and what comes back
6
+ is still JSON — the way `Hash#dig` hands back the structure itself — for a
7
+ document to be dug into further or asked the JSON questions. `dig_text` gives
8
+ the value as text instead, which is what a comparison wants:
9
+
10
+ ```ruby
11
+ Post.where { :meta.dig_text(:author, :name) == "alice" }
12
+ Post.select { :meta.dig(:author).as(:author) }
13
+ Post.where { :meta.key?(:draft) }
14
+ Post.where { :meta.contains?(status: "open") }
15
+ ```
16
+
17
+ No two adapters spell any of this alike, and the block is the same on all
18
+ three:
19
+
20
+ | | PostgreSQL | SQLite | MySQL |
21
+ | --- | --- | --- | --- |
22
+ | `dig(:a, :b)` | `#> '{a,b}'` | `-> '$.a.b'` | `JSON_EXTRACT(…, '$.a.b')` |
23
+ | `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
24
+ | `key?(:a)` | `? 'a'` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
25
+ | `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
26
+
27
+ MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
28
+ MySQL family goes through the functions, which both have.
29
+
30
+ `dig_text` gives text everywhere. SQLite's `->>` would otherwise hand back the
31
+ value with its type, so a comparison that worked there would fail on the other
32
+ two; a number is compared through a `cast` on all three:
33
+
34
+ ```ruby
35
+ Post.where { :meta.dig_text(:n) == "5" }
36
+ Post.where { cast(:meta.dig_text(:n), "integer") > 6 } # 'signed' on MySQL
37
+ ```
38
+
39
+ The type is the adapter's own name for it, here as everywhere `cast` is used.
40
+
41
+ Strings and numbers are where the adapters agree. A JSON boolean comes back as
42
+ `"1"` on SQLite, which turns `true` into SQL's `1` before the text cast, and
43
+ as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
44
+ MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
45
+ three.
46
+
47
+ Comparing `dig_text`'s value with anything but a string raises
48
+ `ArgumentError` rather than being left to the adapters, which answer it three
49
+ ways: `dig_text(:n) == 5` is true on SQLite, an error on PostgreSQL and true
50
+ on MySQL, and `dig_text(:flag) == true` is true, an error and false. `cast`
51
+ is what says which type was meant, and then all three agree.
52
+
53
+ A JSON comparison — `dig`'s side, and `bury`'s and `except`'s — belongs to
54
+ the JSON types: on PostgreSQL's `jsonb` and MySQL's `JSON` alike, numbers
55
+ compare as numbers and documents structurally, key order and spelling aside,
56
+ so a dug value compares with a Ruby one directly. SQLite and MariaDB have
57
+ only the text of each, which is a different question, and raise
58
+ `NotImplementedError` as the SQL is written. `in?` and `between?` are the
59
+ two MySQL leaves out of its JSON comparisons, so there they are spelled as
60
+ the comparisons they mean — the range as its bounds, the list as one
61
+ equality per element, which names the dug value once per element the way
62
+ SQLite's XOR names its operands twice:
63
+
64
+ ```ruby
65
+ Post.where { :meta.dig(:stars) >= 10 } # PostgreSQL and MySQL
66
+ Post.where { :meta.dig(:author) == { "name" => "alice" } }
67
+ Post.where { :meta.dig(:stars).in?([5, 10]) }
68
+ Post.where { cast(:meta.dig_text(:stars), "integer") >= 10 } # everywhere
69
+ ```
70
+
71
+ A column, a function or another dug value on the right goes through untouched
72
+ on every adapter. Arithmetic and the bit operators are refused outright on
73
+ both sides — `dig_text(:n) + 1` is 6 on SQLite, an error on PostgreSQL and
74
+ 6.0 on MariaDB — and `cast` settles those too.
75
+
76
+ `bury` sets what `dig` reads: the last argument is the value and the rest are
77
+ the path to it. The document comes back changed rather than being written
78
+ anywhere, so `update_all` is what makes it stick:
79
+
80
+ ```ruby
81
+ Post.update_all { { meta: :meta.bury(:author, :name, "alice") } }
82
+ # SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
83
+ # ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
84
+
85
+ Post.update_all { { meta: :meta.bury(:tags, ["ruby", "sql"]) } }
86
+ Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
87
+ ```
88
+
89
+ A whole document goes in as one — an object or an array rather than the string
90
+ that spells it — which each adapter takes its own way round, and a boolean
91
+ goes in as JSON too, which SQLite would otherwise write as its `1`. `bury` is
92
+ not a Ruby method; it is the name Ruby considered for the other end of `dig`,
93
+ and
94
+ SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
95
+ the others say `JSON_SET`.
96
+
97
+ `except` takes keys out again, and takes them as `Hash#except` does — keys of
98
+ the document, however many, rather than a path, which is `bury`'s way of
99
+ reaching further in. It gives back the document changed, so it chains with
100
+ `bury` and goes where `bury` goes:
101
+
102
+ ```ruby
103
+ Post.update_all { { meta: :meta.except(:draft) } }
104
+ # SET "meta" = "meta" - CAST('{"draft"}' AS text[])
105
+ # ... JSON_REMOVE("meta", '$.draft') elsewhere
106
+
107
+ Post.update_all { { meta: :meta.bury(:author, :name, "alice").except(:tmp) } }
108
+ ```
109
+
110
+ A key that is not there is not an error, as it is not to `Hash#except`. The
111
+ cast is not decoration: `jsonb` has three subtractions — a key, an array of
112
+ keys, an element by index — and an array literal written without a type is
113
+ read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
114
+ `{draft}`, which is nothing, and says nothing about it.
115
+
116
+ A key deeper in is reached through the chain: `dig` reads the part out,
117
+ `except` takes the key from it, and `bury` puts it back:
118
+
119
+ ```ruby
120
+ Post.update_all { { meta: :meta.bury(:author, :meta.dig(:author).except(:email)) } }
121
+ ```
122
+
123
+ What `dig` gives is a document, so the JSON operations read it — the same
124
+ question asked of a part of the document rather than of all of it:
125
+
126
+ ```ruby
127
+ Post.where { :meta.dig(:author).key?(:email) }
128
+ Post.where { :meta.dig(:author).dig_text(:name) == "alice" }
129
+ Post.update_all { { meta: :meta.dig(:author).bury(:name, "alice") } }
130
+ ```
131
+
132
+ Containment reads it too, on the adapters that have containment at all:
133
+
134
+ ```ruby
135
+ Post.where { :meta.dig(:tags).contains?(["ruby"]) }
136
+ ```
137
+
138
+ Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
139
+ and reading text back as a document is where the adapters part company —
140
+ SQLite parses it, MySQL takes it as written, and PostgreSQL has no such
141
+ function for text at all.
142
+
143
+ `contains?` has no equivalent on SQLite and raises `NotImplementedError`
144
+ there — later than the rest, since the adapter is only known when the SQL is
145
+ built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
146
+ `key?`, `contains?`, `bury` and `except` want a `jsonb` column.
147
+
148
+ A key that is not a plain name travels as itself rather than being refused:
149
+ `dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
150
+
151
+ `keys` gives the keys of the document, as `Hash#keys` does — a JSON array
152
+ of them. Only the MySQL family has a function for it; the other two reach
153
+ the same array through a subquery over their key-listing functions, guarded
154
+ by type so that all four answer alike: the keys of anything that is not an
155
+ object are `NULL` — rather than SQLite's array indices or PostgreSQL's
156
+ error — and the keys of `{}` are `[]` rather than PostgreSQL's `NULL`:
157
+
158
+ ```ruby
159
+ Post.select { :meta.keys.as(:fields) }
160
+ Post.select { :meta.dig(:author).keys.as(:author_fields) }
161
+ # JSON_KEYS("meta") MySQL
162
+ # CASE WHEN jsonb_typeof("meta") = 'object' THEN COALESCE((…)) PostgreSQL
163
+ # CASE WHEN json_type("meta") = 'object' THEN (SELECT …) SQLite
164
+ ```
165
+
166
+ The order the keys come in is the adapters' own: the JSON types give their
167
+ normalized order and the text ones the stored order — the same divide every
168
+ JSON comparison here rides on.
169
+
170
+ `json_array` and `json_object` build a document in the row — `json_array`
171
+ from the values given, `json_object` from a Ruby hash. The names are the
172
+ standard's, which SQLite and the MySQL family say as written; PostgreSQL is
173
+ asked to build `jsonb`. A hash rather than SQL's alternating keys and
174
+ values, because a bare symbol means a column in every block here: the keys
175
+ are Ruby's and the values are expressions, so `title: :title` reads the
176
+ column in under its own name with no rule to remember:
177
+
178
+ ```ruby
179
+ Post.select { json_object(title: :title, stars: :meta.dig(:stars)).as(:summary) }
180
+ # jsonb_build_object('title', "title", 'stars', "meta" #> '{stars}') PostgreSQL
181
+ # JSON_OBJECT('title', "title", 'stars', JSON_EXTRACT(…)) elsewhere
182
+
183
+ Post.where { :meta.dig(:author) == json_object(name: :name) }
184
+ ```
185
+
186
+ A Ruby value among the arguments goes in as its JSON self — a string or a
187
+ number as themselves, `nil` as `null`, and a boolean or a whole document
188
+ through the same route `bury` takes them, so SQLite's `true` is not its
189
+ `1`. A key that is not a string or a symbol is refused, before the
190
+ adapters answer a NULL key three ways. The empty calls stand —
191
+ `json_array()` is `[]` and `json_object()` is `{}` on all four — and what
192
+ comes back is JSON as `dig`'s is, so the operations and comparisons above
193
+ read it.
194
+
195
+ `json_arrayagg` and `json_objectagg` gather rows into one JSON document — a
196
+ value from each row into an array, a key and a value into an object. The
197
+ names are the SQL standard's, which the MySQL family says as written;
198
+ PostgreSQL is asked the `jsonb` pair and SQLite its own:
199
+
200
+ ```ruby
201
+ Post.group { :author_id }.select { json_arrayagg(:title).as(:titles) }
202
+ # jsonb_agg("title") PostgreSQL
203
+ # json_group_array("title") SQLite
204
+ # JSON_ARRAYAGG("title") MySQL
205
+
206
+ Post.select { json_objectagg(:title, :meta.dig(:stars)).as(:stars) }
207
+
208
+ Post.group { :author_id }.
209
+ select { json_arrayagg(json_object(title: :title, stars: :meta.dig(:stars))).as(:posts) }
210
+ ```
211
+
212
+ What they give is JSON as `dig`'s is, so it compares the way a dug value
213
+ does, and `filter` and `over` come along as with any aggregate — with two
214
+ refusals where a respelling would change the meaning rather than the
215
+ spelling. The MySQL family has no `FILTER`, and the `CASE` that stands in
216
+ for it elsewhere would leave a JSON `null` in the document for every row it
217
+ drops, so there `filter` raises `NotImplementedError`; MariaDB takes every
218
+ other aggregate as a window function but not these two, so `over` raises
219
+ there too.
220
+
221
+ The documents agree across adapters, up to the edges of their JSON types.
222
+ Over no rows at all SQLite answers `[]` and `{}` where the others answer
223
+ `NULL`, as their aggregates do. A key aggregated twice keeps the last pair
224
+ on the JSON types — `jsonb` and MySQL's — and every pair on the text ones,
225
+ SQLite and MariaDB, and a `NULL` key is an error on the former pair and a
226
+ dropped pair on the latter. And a bare JSON *column* is text to SQLite's
227
+ `json_group_array`, so it lands as the string that spells the document
228
+ rather than nesting as it does on the other three; a dug value nests
229
+ everywhere, so `json_arrayagg(:meta.dig(:author))` is the portable way to
230
+ collect part of a document.
data/docs/ordering.md ADDED
@@ -0,0 +1,55 @@
1
+ # Aliases, ordering and collation
2
+
3
+ ## Aliases and ordering
4
+
5
+
6
+ `.as` gives an expression a column alias, and `.asc` / `.desc` give an
7
+ ordering its direction. The orderings take `.nulls_first` / `.nulls_last` as
8
+ well. MySQL has no such syntax, but Arel emulates it there, so the resulting
9
+ order is the same everywhere:
10
+
11
+ ```ruby
12
+ Author.order { :country.asc.nulls_last }
13
+ ```
14
+
15
+ Together:
16
+
17
+ ```ruby
18
+ Author.
19
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
20
+ where { :posts[:published] == true }.
21
+ group { :authors[:id] }.
22
+ having { count(:posts[:id]) > 1 }.
23
+ order { count(:posts[:id]).desc }.
24
+ select {
25
+ [
26
+ upper(:authors[:name]).as(:author),
27
+ count(:posts[:id]).as(:post_count),
28
+ avg(:posts[:likes]).as(:avg_likes),
29
+ ]
30
+ }
31
+ ```
32
+
33
+ ## Collation
34
+
35
+
36
+ `.collate` names a collation for a comparison or an ordering — how the
37
+ database decides two strings are equal and which comes first — and gives back
38
+ an expression, so the collation carries into either:
39
+
40
+ ```ruby
41
+ Author.where { :name.collate(:nocase) == "alice" }
42
+ # WHERE "authors"."name" COLLATE nocase = 'alice'
43
+
44
+ Author.order { :name.collate(:nocase).asc }
45
+ ```
46
+
47
+ The collation names are the database's own, so they are not portable — SQLite's
48
+ `nocase`, PostgreSQL's `"C"`, MySQL's `utf8mb4_bin`. PostgreSQL folds an
49
+ unquoted name to lower case, where its built-in names are upper, so it quotes
50
+ the name for you.
51
+
52
+ On the databases that take the name bare, it has to be a plain identifier: a
53
+ hyphen would read as a subtraction, so a name with one is refused. PostgreSQL
54
+ quotes the name, so a hyphen is safe there, and its ICU collations —
55
+ `en-US-x-icu` and the rest — are spelled with them.
@@ -0,0 +1,30 @@
1
+ # Time zones
2
+
3
+
4
+ Active Record stores a datetime in UTC unless `ActiveRecord.default_timezone`
5
+ says `:local`, and a value a block quotes follows it: a `Time` on the right of
6
+ a comparison is converted the way Active Record converts one, so
7
+ `where { :created_at > Time.current - 1.day }` is right whatever `Time.zone`
8
+ is. What is not converted is what the database says the time is.
9
+
10
+ `current_timestamp` and its relatives are the server's clock in the session's
11
+ zone. Active Record sets PostgreSQL's session to UTC and SQLite's clock is UTC
12
+ already, so on those two the clock and the stored values agree. MySQL's
13
+ session keeps the server's own zone, so a server that sits in Tokyo answers
14
+ `CURRENT_TIMESTAMP` nine hours off the values Active Record stored; set the
15
+ session in `database.yml` — `variables: { time_zone: "+00:00" }` — or write
16
+ `Time.current` in the block instead, which is right everywhere. SQL Server
17
+ answers in its operating system's zone and Oracle in the client's, where
18
+ `Time.current` is again the one to reach for. `current_date` is today in UTC,
19
+ which in Tokyo is still yesterday until nine in the morning; `Date.current`
20
+ says the day meant.
21
+
22
+ `extract`, `date_trunc` and a `group` by day cut at UTC's midnight. A zone
23
+ without daylight saving is a fixed offset away —
24
+ `date_trunc("day", :created_at + 9.hours)` — and one with it needs the
25
+ database's own `AT TIME ZONE`, through `sql`.
26
+
27
+ A datetime computed in the query, `(:created_at + 1.hour).as(:later)`, has no
28
+ column to take a type from, so it comes back as a `Time` in UTC — as text on
29
+ SQLite — with no `Time.zone` applied; `in_time_zone` on the Ruby side does
30
+ that.