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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +93 -965
  4. data/activerecord-refined.gemspec +12 -7
  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 +18 -0
  17. data/examples/expressions.rb +35 -5
  18. data/lib/active_record/refined/ast.rb +461 -246
  19. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  20. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  21. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  22. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  23. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  24. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  25. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  26. data/lib/active_record/refined/dialect.rb +340 -0
  27. data/lib/active_record/refined.rb +682 -192
  28. data/lib/activerecord-refined/version.rb +1 -1
  29. data/lib/activerecord-refined.rb +1 -0
  30. metadata +58 -16
  31. data/.github/workflows/push_gem.yml +0 -45
  32. data/.github/workflows/sandbox.yml +0 -295
  33. data/.github/workflows/test.yml +0 -104
  34. data/.gitignore +0 -19
  35. data/.rubocop.yml +0 -393
  36. data/Gemfile +0 -14
  37. data/Rakefile +0 -53
  38. data/benchmark/query_building.rb +0 -129
  39. data/test/test_block_syntax.rb +0 -2999
  40. 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.
@@ -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.
@@ -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
@@ -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
- LineItem.create!(sku: "A-2", category: "tools", price: 300, quantity: 5, flags: 10)
29
- LineItem.create!(sku: "B-1", category: "paper", price: 80, quantity: 10, flags: 3)
30
- LineItem.create!(sku: "C-1", category: nil, price: 50, quantity: 1, flags: 4)
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 sum, avg, min and max.
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.