activerecord-refined 0.9.0 → 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.
- checksums.yaml +4 -4
- data/.yardopts +17 -0
- data/README.md +93 -965
- data/activerecord-refined.gemspec +12 -7
- data/docs/conditions.md +206 -0
- data/docs/ctes.md +65 -0
- data/docs/expressions.md +125 -0
- data/docs/functions.md +219 -0
- data/docs/grouping.md +55 -0
- data/docs/joins.md +73 -0
- data/docs/json.md +230 -0
- data/docs/ordering.md +55 -0
- data/docs/time_zones.md +30 -0
- data/docs/windows.md +41 -0
- data/docs/writing.md +33 -0
- data/examples/aggregations.rb +18 -0
- data/examples/expressions.rb +35 -5
- data/lib/active_record/refined/ast.rb +461 -246
- data/lib/active_record/refined/dialect/mariadb.rb +25 -0
- data/lib/active_record/refined/dialect/mysql.rb +18 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
- data/lib/active_record/refined/dialect/oracle.rb +110 -0
- data/lib/active_record/refined/dialect/postgresql.rb +120 -0
- data/lib/active_record/refined/dialect/sql_server.rb +115 -0
- data/lib/active_record/refined/dialect/sqlite.rb +57 -0
- data/lib/active_record/refined/dialect.rb +340 -0
- data/lib/active_record/refined.rb +682 -192
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +1 -0
- metadata +58 -16
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -104
- data/.gitignore +0 -19
- data/.rubocop.yml +0 -393
- data/Gemfile +0 -14
- data/Rakefile +0 -53
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2999
- data/test/test_helper.rb +0 -238
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.
|
data/docs/time_zones.md
ADDED
|
@@ -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.
|
data/docs/windows.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Window functions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`over` gives a function a window, which is what turns an aggregate into a
|
|
5
|
+
running one and the only thing `row_number` and its kind can be used with.
|
|
6
|
+
The window is built by chaining, as Arel's own is:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Author.select { avg(:age).over.partition(:country).as(:country_average) }
|
|
10
|
+
# AVG("age") OVER (PARTITION BY "country") AS country_average
|
|
11
|
+
|
|
12
|
+
Author.select { row_number.over.partition(:country).order(:age.desc).as(:rank) }
|
|
13
|
+
# ROW_NUMBER() OVER (PARTITION BY "country" ORDER BY "age" DESC) AS rank
|
|
14
|
+
|
|
15
|
+
Author.select { count(:*).over.as(:total) } # COUNT(*) OVER () — every row
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`,
|
|
19
|
+
`lag`, `lead`, `first_value`, `last_value` and `nth_value` are the functions
|
|
20
|
+
that say nothing without a window; each raises `ArgumentError` if `over` never
|
|
21
|
+
arrives, rather than reaching the database as an error there. Every adapter
|
|
22
|
+
that has window functions at all spells them the same way, so unlike the
|
|
23
|
+
scalar functions there is nothing here to translate.
|
|
24
|
+
|
|
25
|
+
A frame is a range of rows counted from the current one — negative before it,
|
|
26
|
+
positive after, 0 the row itself, and an open end for unbounded:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
Post.select { sum(:likes).over.order(:created_at).rows(..0).as(:running) }
|
|
30
|
+
# SUM("likes") OVER (ORDER BY "created_at" ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
|
|
31
|
+
|
|
32
|
+
Post.select { avg(:likes).over.order(:created_at).rows(-1..1).as(:smoothed) }
|
|
33
|
+
# ... ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
34
|
+
|
|
35
|
+
Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
|
|
36
|
+
# ... ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`range` says `RANGE` where `rows` says `ROWS`, and a window has one frame or
|
|
40
|
+
none. Named windows — `WINDOW w AS (...)` — have no clause in Active Record to
|
|
41
|
+
live in, so they are not here.
|
data/docs/writing.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Writing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`update_all` reads its hash the way Active Record does — `update_all(likes: :likes)`
|
|
5
|
+
sets the column to the symbol itself. The block reads a symbol as the column it
|
|
6
|
+
names, as every other block here does, which is what lets the new value be
|
|
7
|
+
worked out from the old:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Post.where { :published == true }.update_all { { likes: :likes + 1 } }
|
|
11
|
+
# UPDATE "posts" SET "likes" = ("posts"."likes" + 1) WHERE ...
|
|
12
|
+
|
|
13
|
+
Post.update_all { { title: upper(:title), likes: case_when { :likes < 0 }.then(0).else(:likes) } }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`upsert_all` takes one too, for the part that decides what happens to a row
|
|
17
|
+
that is already there. `excluded` is the row that could not be inserted:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
21
|
+
# ... ON CONFLICT ("page") DO UPDATE SET "hits"=("tallies"."hits" + "excluded"."hits")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
PostgreSQL and SQLite name that row `excluded`; MySQL spells the same thing
|
|
25
|
+
`VALUES(column)`, and the block comes out as whichever the adapter reads.
|
|
26
|
+
Active Record's own `on_duplicate:` takes SQL text and nothing else, so this is
|
|
27
|
+
the one place the DSL writes SQL out itself rather than handing Arel a tree —
|
|
28
|
+
and the two cannot both be given.
|
|
29
|
+
|
|
30
|
+
`insert_all` has no block: its values are literals by construction.
|
|
31
|
+
Active Record type-casts each one on the way into the `VALUES` list, so an
|
|
32
|
+
expression does not become SQL there — it becomes nothing, silently. Use
|
|
33
|
+
`upsert_all` where a row's value has to be worked out.
|
data/examples/aggregations.rb
CHANGED
|
@@ -91,3 +91,21 @@ query3 =
|
|
|
91
91
|
puts "--- 3. Comment score aggregation across multi-table JOIN ---"
|
|
92
92
|
puts query3.to_sql
|
|
93
93
|
puts
|
|
94
|
+
|
|
95
|
+
# 4. The titles of each author's posts joined into one string
|
|
96
|
+
# string_agg + order inside the aggregate + filter; group_concat on SQLite
|
|
97
|
+
query4 =
|
|
98
|
+
Author.
|
|
99
|
+
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
100
|
+
group { :authors[:id] }.
|
|
101
|
+
select {
|
|
102
|
+
[
|
|
103
|
+
:authors[:name],
|
|
104
|
+
string_agg(:posts[:title], ", ").order(:posts[:title]).as(:titles),
|
|
105
|
+
string_agg(:posts[:title], ", ").filter { :posts[:published] == true }.as(:published),
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
puts "--- 4. Titles per author, joined (string_agg / order / filter) ---"
|
|
110
|
+
puts query4.to_sql
|
|
111
|
+
puts
|
data/examples/expressions.rb
CHANGED
|
@@ -16,6 +16,7 @@ class Setup < ActiveRecord::Migration[8.1]
|
|
|
16
16
|
t.integer :price
|
|
17
17
|
t.integer :quantity
|
|
18
18
|
t.integer :flags
|
|
19
|
+
t.date :ordered_on
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -24,10 +25,16 @@ Setup.new.up
|
|
|
24
25
|
class LineItem < ActiveRecord::Base
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
LineItem.create!(sku: "A-1", category: "tools", price: 1200, quantity: 2, flags: 12
|
|
28
|
-
|
|
29
|
-
LineItem.create!(sku: "
|
|
30
|
-
|
|
28
|
+
LineItem.create!(sku: "A-1", category: "tools", price: 1200, quantity: 2, flags: 12,
|
|
29
|
+
ordered_on: Date.new(2026, 1, 5))
|
|
30
|
+
LineItem.create!(sku: "A-2", category: "tools", price: 300, quantity: 5, flags: 10,
|
|
31
|
+
ordered_on: Date.new(2026, 1, 20))
|
|
32
|
+
LineItem.create!(sku: "B-1", category: "paper", price: 80, quantity: 10, flags: 3,
|
|
33
|
+
ordered_on: Date.new(2026, 2, 3))
|
|
34
|
+
LineItem.create!(sku: "B-2", category: "paper", price: 80, quantity: 10, flags: 3,
|
|
35
|
+
ordered_on: Date.new(2026, 2, 3))
|
|
36
|
+
LineItem.create!(sku: "C-1", category: nil, price: 50, quantity: 1, flags: 4,
|
|
37
|
+
ordered_on: Date.new(2026, 2, 14))
|
|
31
38
|
|
|
32
39
|
def show(title, relation, rows = nil)
|
|
33
40
|
puts "--- #{title} ---"
|
|
@@ -64,6 +71,17 @@ show("a tax through BigDecimal, exact on the wire",
|
|
|
64
71
|
LineItem.select { [:sku, (BigDecimal("1.1") * :price).as(:taxed)] }.
|
|
65
72
|
map { |i| [i.sku, i.taxed] })
|
|
66
73
|
|
|
74
|
+
# A duration moves a date. Each adapter spells the move its own way; SQLite
|
|
75
|
+
# has date() and datetime(), and a date column keeps being a date.
|
|
76
|
+
show("a date moved by a duration",
|
|
77
|
+
LineItem.where { :ordered_on + 30.days < Date.new(2026, 2, 10) },
|
|
78
|
+
LineItem.where { :ordered_on + 30.days < Date.new(2026, 2, 10) }.pluck(:sku))
|
|
79
|
+
|
|
80
|
+
show("a due date a month on",
|
|
81
|
+
LineItem.select { [:sku, (:ordered_on + 1.month).as(:due_on)] },
|
|
82
|
+
LineItem.select { [:sku, (:ordered_on + 1.month).as(:due_on)] }.
|
|
83
|
+
map { |i| [i.sku, i.due_on] })
|
|
84
|
+
|
|
67
85
|
# The bitwise operators. & and | are AND and OR between conditions, which is
|
|
68
86
|
# what leaves them free here. Each expression parenthesises itself, so the
|
|
69
87
|
# grouping is Ruby's rather than the adapter's.
|
|
@@ -89,12 +107,17 @@ rescue ArgumentError => e
|
|
|
89
107
|
end
|
|
90
108
|
|
|
91
109
|
# 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
|
|
92
|
-
# COUNT(DISTINCT ...); the rest are
|
|
110
|
+
# COUNT(DISTINCT ...), which sum and avg take too; the rest are min and max.
|
|
93
111
|
show("COUNT(*) and COUNT(DISTINCT ...)",
|
|
94
112
|
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] },
|
|
95
113
|
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
|
|
96
114
|
map { |i| [i.rows, i.categories] })
|
|
97
115
|
|
|
116
|
+
show("SUM(DISTINCT ...), each quantity counted once",
|
|
117
|
+
LineItem.select { [sum(:quantity).as(:all), sum(:quantity, distinct: true).as(:once)] },
|
|
118
|
+
LineItem.select { [sum(:quantity).as(:all), sum(:quantity, distinct: true).as(:once)] }.
|
|
119
|
+
map { |i| [i.all, i.once] })
|
|
120
|
+
|
|
98
121
|
# filter takes the aggregate over the rows a condition holds for. SQLite and
|
|
99
122
|
# PostgreSQL have the FILTER clause; MySQL gets the CASE that means the same,
|
|
100
123
|
# since an aggregate passes over the NULL a missed row leaves.
|
|
@@ -173,6 +196,13 @@ show("NULLS LAST",
|
|
|
173
196
|
LineItem.order { [:category.asc.nulls_last, :sku.asc] },
|
|
174
197
|
LineItem.order { [:category.asc.nulls_last, :sku.asc] }.pluck(:category, :sku))
|
|
175
198
|
|
|
199
|
+
# collate names a collation -- how the database compares and orders strings --
|
|
200
|
+
# and gives back an expression, so it carries into a comparison or an order.
|
|
201
|
+
# The names are the database's own; nocase is SQLite's case-insensitive one.
|
|
202
|
+
show("a case-insensitive comparison under a collation",
|
|
203
|
+
LineItem.where { :category.collate(:nocase) == "TOOLS" },
|
|
204
|
+
LineItem.where { :category.collate(:nocase) == "TOOLS" }.pluck(:sku))
|
|
205
|
+
|
|
176
206
|
# Aggregates and expressions can be ordered by, too.
|
|
177
207
|
show("grouped, aggregated and ordered by the aggregate",
|
|
178
208
|
LineItem.
|