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.
- checksums.yaml +4 -4
- data/.yardopts +17 -0
- data/README.md +111 -815
- data/activerecord-refined.gemspec +36 -17
- 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 +31 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +110 -45
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +1117 -373
- 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 +877 -307
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +9 -5
- metadata +186 -15
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -90
- data/.gitignore +0 -19
- data/Gemfile +0 -12
- data/Rakefile +0 -25
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2493
- data/test/test_helper.rb +0 -221
data/README.md
CHANGED
|
@@ -26,7 +26,8 @@ Refinements' spec change, that implementation stopped working on Ruby 2.0.0 stab
|
|
|
26
26
|
the project was left dormant for a long time.
|
|
27
27
|
|
|
28
28
|
It has now been renamed to **activerecord-refined** and reimplemented on top of
|
|
29
|
-
`Proc#refined
|
|
29
|
+
[`Proc#refined`](https://docs.ruby-lang.org/en/master/Proc.html#method-i-refined),
|
|
30
|
+
which will be introduced in Ruby 4.1. `Proc#refined` returns a new proc that
|
|
30
31
|
is evaluated with the given refinements activated, so a block written by the caller can
|
|
31
32
|
be re-interpreted under the query DSL's refinements:
|
|
32
33
|
|
|
@@ -69,875 +70,166 @@ Just require the gem, and `where`, `select`, `joins`, `left_outer_joins`, `havin
|
|
|
69
70
|
`order` and `group` will accept a block.
|
|
70
71
|
|
|
71
72
|
```ruby
|
|
72
|
-
require
|
|
73
|
+
require "activerecord-refined"
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
Inside the block, symbols denote columns of the receiver's table, and `:table[:column]`
|
|
76
|
-
denotes a qualified column.
|
|
77
|
+
denotes a qualified column. That holds in every position — on the right of a
|
|
78
|
+
comparison too, so `:age == :retirement_age` compares two columns. A value is
|
|
79
|
+
written as its literal, an enum's as its string; a symbol naming no column of
|
|
80
|
+
the model is refused rather than compared against nothing anyone meant.
|
|
81
|
+
|
|
82
|
+
The reference — every method a symbol answers to inside a block, every
|
|
83
|
+
function a block can call, and what the relation takes — is on
|
|
84
|
+
[rubydoc.info](https://rubydoc.info/gems/activerecord-refined):
|
|
85
|
+
[`BlockSyntax`](https://rubydoc.info/gems/activerecord-refined/ActiveRecord/Refined/BlockSyntax)
|
|
86
|
+
for the symbol,
|
|
87
|
+
[`BlockContext`](https://rubydoc.info/gems/activerecord-refined/ActiveRecord/Refined/BlockContext)
|
|
88
|
+
for the block, and
|
|
89
|
+
[`QueryMethods`](https://rubydoc.info/gems/activerecord-refined/ActiveRecord/Refined/QueryMethods)
|
|
90
|
+
for the relation. What follows is a tour, a topic at a time; each has a page
|
|
91
|
+
of its own under [docs/](docs/) that says the rest.
|
|
77
92
|
|
|
78
93
|
### Conditions
|
|
79
94
|
|
|
80
95
|
```ruby
|
|
81
|
-
Author.where { :age
|
|
82
|
-
Author.where { :
|
|
83
|
-
Author.where {
|
|
84
|
-
Author.where { :
|
|
85
|
-
Author.where { :age.in?(18..) } # >= 18
|
|
86
|
-
Author.where { :country.in?(%w[JP US]) } # IN
|
|
87
|
-
Author.where { :country.null? } # IS NULL
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
`!` negates any of these. Where SQL has a negative of its own, so does the
|
|
91
|
-
block, which is the same rows written the way they would be written by hand:
|
|
92
|
-
|
|
93
|
-
```ruby
|
|
94
|
-
Author.where { :country.not_null? } # IS NOT NULL
|
|
95
|
-
Author.where { :country.not_in?(%w[JP US]) } # NOT IN
|
|
96
|
-
Author.where { :age.not_between?(20, 40) } # not between 20 and 40
|
|
97
|
-
Author.where { :name.not_like?('A%') } # NOT LIKE
|
|
98
|
-
Author.where { :name.not_ilike?('a%') } # NOT ILIKE / NOT LIKE
|
|
99
|
-
|
|
100
|
-
Author.where { !:name.start_with?('A') } # NOT (name LIKE 'A%')
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
|
|
104
|
-
select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
105
|
-
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
106
|
-
age > 40`, which is again the same rows.
|
|
107
|
-
|
|
108
|
-
A boolean column has `true?` and `false?`, which become SQL's `IS TRUE` and
|
|
109
|
-
`IS FALSE`, and the two negations to go with them:
|
|
110
|
-
|
|
111
|
-
```ruby
|
|
112
|
-
Post.where { :published.true? } # IS TRUE
|
|
113
|
-
Post.where { :published.not_true? } # IS NOT TRUE
|
|
114
|
-
Post.where { :published.false? } # IS FALSE
|
|
115
|
-
Post.where { :published.not_false? } # IS NOT FALSE
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
`published = TRUE` selects the same rows as `published IS TRUE`, so the
|
|
119
|
-
difference is in the negation: `published = TRUE` is itself NULL for a row
|
|
120
|
-
where the column is, and a NULL predicate selects nothing, while `IS TRUE`
|
|
121
|
-
answers false there. `not_true?` is therefore "false or never set" and
|
|
122
|
-
`!(:published == true)` only "false". Every adapter spells all four the same
|
|
123
|
-
way and answers them alike.
|
|
124
|
-
|
|
125
|
-
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
126
|
-
subquery selects the relation's primary key, the same way Active Record's own
|
|
127
|
-
`where(id: relation)` does:
|
|
128
|
-
|
|
129
|
-
```ruby
|
|
130
|
-
Author.where { :id.in?(Post.published.select(:author_id)) }
|
|
131
|
-
# "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
A relation on the right of a comparison is a scalar subquery. It has to select
|
|
135
|
-
one value, so unlike `in?` there is no default select list and one is
|
|
136
|
-
required:
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
Author.where { :age >= Author.select { avg(:age) } }
|
|
140
|
-
# "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
`any` and `all` quantify that comparison instead, which is what lifts the
|
|
144
|
-
one-row rule: `> any` asks whether the subquery holds a smaller value anywhere,
|
|
145
|
-
`>= all` whether it holds a larger one nowhere.
|
|
146
|
-
|
|
147
|
-
```ruby
|
|
148
|
-
Author.where { :age > any(Author.where(country: 'JP').select(:age)) }
|
|
149
|
-
# "authors"."age" > ANY(SELECT "authors"."age" FROM "authors" WHERE ...)
|
|
150
|
-
|
|
151
|
-
Author.where { :age >= all(Author.select(:age)) }
|
|
152
|
-
# "authors"."age" >= ALL(SELECT "authors"."age" FROM "authors")
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
The select list follows `in?`'s rule rather than the scalar one: without an
|
|
156
|
-
explicit select the subquery selects the primary key. `== any` is what `IN`
|
|
157
|
-
says and `!= all` what `NOT IN` says, so what the quantifiers add is the four
|
|
158
|
-
comparisons `IN` has no spelling for. SQLite has neither quantifier, and says
|
|
159
|
-
so with `NotImplementedError` rather than leaving its parser to.
|
|
160
|
-
|
|
161
|
-
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
162
|
-
subquery with the outer table through qualified columns — its `where` block
|
|
163
|
-
goes through the DSL like any other:
|
|
164
|
-
|
|
165
|
-
```ruby
|
|
96
|
+
Author.where { :age.between?(20, 40) & :name.like?("A%") }
|
|
97
|
+
Author.where { :country.in?(%w[JP US]) | :country.null? }
|
|
98
|
+
Author.where { !:name.start_with?("A") }
|
|
99
|
+
Author.where { :id.in?(Post.select(:author_id)) } # IN (subquery)
|
|
166
100
|
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
167
|
-
# EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
|
|
168
|
-
|
|
169
|
-
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
170
|
-
# NOT (EXISTS (...))
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
`like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
|
|
174
|
-
Arel would otherwise reach for `ILIKE`. `ilike?` is the one that asks for
|
|
175
|
-
`ILIKE`; off PostgreSQL it is plain `LIKE`, which those adapters already match
|
|
176
|
-
case-insensitively under their default collations. `casecmp?` is
|
|
177
|
-
case-insensitive equality, folded on both sides rather than left to the
|
|
178
|
-
collation, so it means the same thing everywhere:
|
|
179
|
-
|
|
180
|
-
```ruby
|
|
181
|
-
Author.where { :name.ilike?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
|
|
182
|
-
Author.where { :name.casecmp?('Alice') } # LOWER(name) = LOWER('Alice')
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
186
|
-
value, rather than as the unknown that makes `=` and `<>` neither true nor
|
|
187
|
-
false. PostgreSQL spells this `IS [NOT] DISTINCT FROM`, SQLite `IS` / `IS NOT`
|
|
188
|
-
and MySQL `<=>`, and the rows that come back are the same on all three:
|
|
189
|
-
|
|
190
|
-
```ruby
|
|
191
|
-
Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
|
|
192
|
-
Author.where { :country.distinct_from?('JP') } # keeps the NULL rows
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
`start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
|
|
196
|
-
patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
|
|
197
|
-
and `_` in it are escaped rather than matched as wildcards:
|
|
198
|
-
|
|
199
|
-
```ruby
|
|
200
|
-
Author.where { :name.start_with?('A') } # LIKE 'A%'
|
|
201
|
-
Author.where { :name.end_with?('son') } # LIKE '%son'
|
|
202
|
-
Author.where { :name.include?('test') } # LIKE '%test%'
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
Like their String namesakes, `start_with?` and `end_with?` take any number of
|
|
206
|
-
literals; matching any one of them is enough:
|
|
207
|
-
|
|
208
|
-
```ruby
|
|
209
|
-
Author.where { :name.start_with?('A', 'B') }
|
|
210
|
-
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
`member?`, `superset?`, `subset?` and `intersect?` compare against a
|
|
214
|
-
PostgreSQL array column, each carrying the meaning of its Ruby namesake:
|
|
215
|
-
`member?` is Enumerable's element test (which String does not have — that is
|
|
216
|
-
what separates it from `include?`), `superset?` and `subset?` are Set's
|
|
217
|
-
whole-array containment, and `intersect?` is Array's "any element in common":
|
|
218
|
-
|
|
219
|
-
```ruby
|
|
220
|
-
Article.where { :tags.member?('ruby') } # tags @> '{ruby}'
|
|
221
|
-
Article.where { :scores.member?(80) } # scores @> '{80}'
|
|
222
|
-
Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
|
|
223
|
-
Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
|
|
224
|
-
Article.where { :tags.intersect?(%w[ruby go]) } # tags && '{ruby,go}'
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
Like its namesake, `member?` takes one element — `[1, 2].member?([1])` is
|
|
228
|
-
false in Ruby, so an Array argument raises rather than quietly meaning
|
|
229
|
-
something `Array#member?` does not. Requiring every element is `superset?`.
|
|
230
|
-
|
|
231
|
-
`=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
|
|
232
|
-
`~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
|
|
233
|
-
raises there.
|
|
234
|
-
|
|
235
|
-
```ruby
|
|
236
|
-
Author.where { :name =~ '^A' } # REGEXP / ~
|
|
237
|
-
Author.where { :name !~ '^A' } # NOT REGEXP / !~
|
|
238
|
-
Author.where { :name =~ /son$/ } # a Regexp literal works too
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
Only a literal's source crosses over; the database has its own dialect and no
|
|
242
|
-
equivalent of Ruby's flags. Dropping one would silently change what the query
|
|
243
|
-
matches, so `/son$/i` raises instead — pass the pattern as a string if the
|
|
244
|
-
database can express what you mean.
|
|
245
|
-
|
|
246
|
-
`==` always means SQL `=`, and passes its value through untouched. A Range or an
|
|
247
|
-
Array therefore compares against a PostgreSQL range or array column, the same
|
|
248
|
-
way Active Record's own `where(period: from...to)` does for those column types:
|
|
249
|
-
|
|
250
|
-
```ruby
|
|
251
|
-
Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
252
|
-
Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
`!=` is SQL `!=` under the same rules, value passed through untouched.
|
|
256
|
-
|
|
257
|
-
For the same reason `== nil` and `!= nil` raise `ArgumentError`: `= NULL` is
|
|
258
|
-
never true in SQL, so a NULL test has to be spelled as one. Use `null?`:
|
|
259
|
-
|
|
260
|
-
```ruby
|
|
261
|
-
Author.where { :country.null? } # country IS NULL
|
|
262
|
-
Author.where { !:country.null? } # NOT (country IS NULL)
|
|
263
101
|
```
|
|
264
102
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
Author.where { (:age >= 18) & ((:country == 'JP') | (:country == 'US')) }
|
|
271
|
-
Author.where { !(:age.in?(0..17) | :country.null?) }
|
|
272
|
-
Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
|
|
273
|
-
Author.where { !:name.like?('%test%') } # NOT (name LIKE '%test%')
|
|
274
|
-
```
|
|
103
|
+
`&`, `|` and `!` are AND, OR and NOT. A value on the right is quoted as Active
|
|
104
|
+
Record quotes it, a column compares against a column, a relation is a
|
|
105
|
+
subquery. [docs/conditions.md](docs/conditions.md) has the rest: the
|
|
106
|
+
negations SQL spells for itself, `true?` and NULL, regular expressions,
|
|
107
|
+
`ANY` and `ALL`, PostgreSQL arrays.
|
|
275
108
|
|
|
276
109
|
### Joins
|
|
277
110
|
|
|
278
|
-
The block is the `ON` clause:
|
|
279
|
-
|
|
280
|
-
```ruby
|
|
281
|
-
Author.
|
|
282
|
-
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
283
|
-
joins(:comments) { :comments[:post_id] == :posts[:id] }
|
|
284
|
-
|
|
285
|
-
Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
`as` names the table within the query, which is what makes a self join
|
|
289
|
-
expressible — the qualified columns in the block go by that name:
|
|
290
|
-
|
|
291
111
|
```ruby
|
|
112
|
+
Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
292
113
|
Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
|
|
293
|
-
# SELECT "employees".* FROM "employees"
|
|
294
|
-
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
`right_outer_joins` and `full_outer_joins` are the two Active Record has no
|
|
298
|
-
method for, and they take what `joins` takes. An association name is not among
|
|
299
|
-
it: what Active Record reads out of one is an inner or a left join and nothing
|
|
300
|
-
else, so these two want the block that says how to join.
|
|
301
|
-
|
|
302
|
-
```ruby
|
|
303
|
-
Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
304
|
-
Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
MySQL has no `FULL OUTER JOIN` and neither has MariaDB, so `full_outer_joins`
|
|
308
|
-
raises `NotImplementedError` there. SQLite has had one since 3.39.
|
|
309
|
-
|
|
310
|
-
`cross_joins` is every row of one table against every row of the other. There
|
|
311
|
-
is no condition to give, so it takes no block — `as` still names the table:
|
|
312
|
-
|
|
313
|
-
```ruby
|
|
314
|
-
Post.cross_joins(:authors) # FROM "posts" CROSS JOIN "authors"
|
|
315
|
-
Post.cross_joins(:posts, as: :others) # FROM "posts" CROSS JOIN "posts" "others"
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
### Keeping one row per group
|
|
319
|
-
|
|
320
|
-
`distinct_on` is PostgreSQL's `DISTINCT ON`: the first row of each group the
|
|
321
|
-
order brings up.
|
|
322
|
-
|
|
323
|
-
```ruby
|
|
324
|
-
Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] }
|
|
325
|
-
# SELECT DISTINCT ON ( "author_id" ) "posts".* FROM "posts"
|
|
326
|
-
# ORDER BY "author_id", "likes" DESC
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
Arel carries the node and refuses to write it for the others, the way it does
|
|
330
|
-
a regexp, so it raises `NotImplementedError` on SQLite and MySQL. The shape
|
|
331
|
-
that runs everywhere is a `row_number` window in a subquery, which says the
|
|
332
|
-
same thing at more length:
|
|
333
|
-
|
|
334
|
-
```ruby
|
|
335
|
-
ranked = Post.select {
|
|
336
|
-
[:author_id, :likes, row_number.over.partition(:author_id).order(:likes.desc).as(:rn)]
|
|
337
|
-
}
|
|
338
|
-
Post.from(ranked, :posts).where { :rn == 1 }
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
The subquery is named after the model's own table for the reason `from_cte`
|
|
342
|
-
is: Active Record goes on qualifying columns with that name, so `where` needs
|
|
343
|
-
to find it.
|
|
344
|
-
|
|
345
|
-
### Grouping several ways at once
|
|
346
|
-
|
|
347
|
-
`grouping_sets`, `rollup` and `cube` ask for more than one grouping in a
|
|
348
|
-
single query, the totals of each coming back beside the rows. Each set is a
|
|
349
|
-
list of its own, and an empty one is the grand total:
|
|
350
|
-
|
|
351
|
-
```ruby
|
|
352
|
-
Sale.group { grouping_sets([:region], [:product], []) }.
|
|
353
|
-
select { [:region, :product, sum(:amount).as(:total)] }
|
|
354
|
-
# GROUP BY GROUPING SETS( ( "region" ), ( "product" ), ( ) )
|
|
355
|
-
|
|
356
|
-
Sale.group { rollup(:region, :product) } # GROUP BY ROLLUP( "region", "product" )
|
|
357
|
-
Sale.group { cube(:region, :product) } # GROUP BY CUBE( "region", "product" )
|
|
358
114
|
```
|
|
359
115
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
ROLLUP`, which says one of the three and says it elsewhere in the clause, so
|
|
365
|
-
the block raises `NotImplementedError` on both.
|
|
366
|
-
|
|
367
|
-
### Lateral joins
|
|
368
|
-
|
|
369
|
-
A relation marked `lateral` joins in place of a table, and sees the row being
|
|
370
|
-
joined to — in SQL the keyword modifies the subquery, not the join, so that is
|
|
371
|
-
where it is written. It is what makes the top row of each group reachable in
|
|
372
|
-
one query:
|
|
373
|
-
|
|
374
|
-
```ruby
|
|
375
|
-
top_post = Post.select { :title }.
|
|
376
|
-
where { :posts[:author_id] == :authors[:id] }.
|
|
377
|
-
order { :likes.desc }.limit(1)
|
|
378
|
-
|
|
379
|
-
Author.left_outer_joins(top_post.lateral, as: :top).
|
|
380
|
-
select { [:name, :top[:title].as(:top_post)] }
|
|
381
|
-
# SELECT "name", "top"."title" AS "top_post" FROM "authors"
|
|
382
|
-
# LEFT OUTER JOIN LATERAL (SELECT "title" FROM "posts"
|
|
383
|
-
# WHERE "posts"."author_id" = "authors"."id" ORDER BY "likes" DESC LIMIT 1) "top" ON TRUE
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
`as` is required — the relation has no name of its own to qualify with. Without
|
|
387
|
-
a block the join is `ON TRUE`, which is the usual shape: what the subquery is
|
|
388
|
-
allowed to see is said inside it. A block writes a real `ON` clause.
|
|
389
|
-
|
|
390
|
-
PostgreSQL has `LATERAL` and so has MySQL, from 8.0.14. SQLite has none, and
|
|
391
|
-
neither has MariaDB, which answers to the same adapter as MySQL; both raise
|
|
392
|
-
`NotImplementedError`. Arel has a node for it but only PostgreSQL's visitor
|
|
393
|
-
writes it, so the SQL is written here instead.
|
|
394
|
-
|
|
395
|
-
### Common table expressions
|
|
396
|
-
|
|
397
|
-
Active Record's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
398
|
-
is joined by name like any other table, so its `ON` clause is a block, where
|
|
399
|
-
Rails' own documentation reaches for a string join.
|
|
400
|
-
|
|
401
|
-
`from_cte` takes the CTE's name and selects it under the model's own table
|
|
402
|
-
name, so the model's columns resolve:
|
|
403
|
-
|
|
404
|
-
```ruby
|
|
405
|
-
Node.with_recursive(
|
|
406
|
-
tree: [
|
|
407
|
-
Node.where { :id == root.id }.
|
|
408
|
-
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
409
|
-
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
410
|
-
select { [:id, :name, :parent_id, (:tree[:depth] + 1).as(:depth)] },
|
|
411
|
-
]
|
|
412
|
-
).from_cte(:tree)
|
|
413
|
-
# WITH RECURSIVE "tree" AS (
|
|
414
|
-
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
|
|
415
|
-
# FROM "nodes" WHERE "nodes"."id" = 1
|
|
416
|
-
# UNION ALL
|
|
417
|
-
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
|
|
418
|
-
# ("tree"."depth" + 1) AS depth
|
|
419
|
-
# FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
420
|
-
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
The anchor starts the count and the recursive member adds one, which is how
|
|
424
|
-
the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
425
|
-
SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
|
|
426
|
-
can say `.as` directly.
|
|
427
|
-
|
|
428
|
-
The alias on the last line is there for Active Record's sake, not SQL's:
|
|
429
|
-
written by hand that line would be `SELECT * FROM tree`. Active Record goes on qualifying
|
|
430
|
-
columns with the model's table name, so without the alias that name is not in
|
|
431
|
-
the query and anything qualifying a column fails:
|
|
432
|
-
|
|
433
|
-
```ruby
|
|
434
|
-
Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
435
|
-
# PG::UndefinedTable: missing FROM-clause entry for table "nodes"
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
Since the model's name is the only one that works, `from_cte` takes it from
|
|
439
|
-
the model rather than asking. It also checks that the name is one `with`
|
|
440
|
-
declares, so a typo is an `ArgumentError` here rather than a query against a
|
|
441
|
-
table nobody has — checked when the SQL is built, so the CTE may be declared
|
|
442
|
-
later in the chain or by a scope merged into it.
|
|
443
|
-
|
|
444
|
-
`from(:tree, as: :nodes)` is the same thing spelled out, without the check,
|
|
445
|
-
and is what to reach for when the name wanted is not the model's.
|
|
446
|
-
|
|
447
|
-
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
448
|
-
`order` and `select` never qualify, so they work without the alias on every
|
|
449
|
-
adapter; it is `where` and `find_by` that stop. A query can therefore look
|
|
450
|
-
right until the day a condition is added to it.
|
|
451
|
-
|
|
452
|
-
A non-recursive CTE joins the same way:
|
|
453
|
-
|
|
454
|
-
```ruby
|
|
455
|
-
Node.with(roots: Node.where { :parent_id.null? }).
|
|
456
|
-
joins(:roots) { :roots[:id] == :nodes[:parent_id] }
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
`examples/ctes.rb` walks a category tree with these.
|
|
116
|
+
`joins`, `left_outer_joins`, `right_outer_joins`, `full_outer_joins` and
|
|
117
|
+
`cross_joins` take the `ON` as a block and `as:` for a table alias; a
|
|
118
|
+
relation joins as a subquery, and one marked `lateral` as a `LATERAL` one.
|
|
119
|
+
[docs/joins.md](docs/joins.md).
|
|
460
120
|
|
|
461
121
|
### Aggregates and functions
|
|
462
122
|
|
|
463
|
-
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
464
|
-
bit aggregates and the scalar functions below, with `fn` for anything else. Return an array to select
|
|
465
|
-
or order by multiple expressions.
|
|
466
|
-
|
|
467
|
-
`filter` takes the aggregate over the rows a condition holds for, as a value
|
|
468
|
-
or a block:
|
|
469
|
-
|
|
470
|
-
```ruby
|
|
471
|
-
Author.select { count(:*).filter { :age < 50 }.as(:young) }
|
|
472
|
-
# COUNT(*) FILTER (WHERE "age" < 50) AS "young"
|
|
473
|
-
|
|
474
|
-
Author.select {
|
|
475
|
-
[count(:*).as(:all), sum(:age).filter { :country == 'JP' }.as(:jp_years)]
|
|
476
|
-
}
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
MySQL has no `FILTER` clause, and gets the case that means the same thing —
|
|
480
|
-
`COUNT(CASE WHEN "age" < 50 THEN 1 END)`. An aggregate passes over a NULL, so
|
|
481
|
-
a row the condition misses is a row it does not see, and the number that comes
|
|
482
|
-
back is the same on all three.
|
|
483
|
-
|
|
484
|
-
Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
|
|
485
|
-
`COUNT(DISTINCT ...)`:
|
|
486
|
-
|
|
487
123
|
```ruby
|
|
488
124
|
Author.group { :country }.having { count(:*) > 1 }
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
Post.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
The scalar functions are real methods rather than anything caught dynamically,
|
|
495
|
-
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
496
|
-
answers to — `rand` — means the SQL one inside a block:
|
|
497
|
-
|
|
498
|
-
```
|
|
499
|
-
abs acos asin atan atan2 bit_and bit_count bit_or bit_xor cast
|
|
500
|
-
ceil char_length coalesce concat
|
|
501
|
-
cos current_date current_time current_timestamp date_trunc degrees
|
|
502
|
-
exp extract floor format greatest least length ln localtime
|
|
503
|
-
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
504
|
-
power radians rand replace round rtrim sign sin sqrt substr tan
|
|
505
|
-
trim trunc upper
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
Most are spelled the same everywhere. Where they are not, the method names one
|
|
509
|
-
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
510
|
-
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
511
|
-
MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
512
|
-
insists on the second argument the others default to zero — SQLite's takes
|
|
513
|
-
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
514
|
-
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
515
|
-
whose spelling is `log(2, x)`, the four `bit_*` on SQLite — the block raises
|
|
516
|
-
`NotImplementedError` rather than leaving the database to reject the SQL.
|
|
517
|
-
|
|
518
|
-
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
519
|
-
same name does something else entirely: it puts separators in a number, and
|
|
520
|
-
reads a printf template as the number zero rather than complaining. `fn` still
|
|
521
|
-
reaches it, spelled as the different thing it is:
|
|
522
|
-
|
|
523
|
-
```ruby
|
|
524
|
-
Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
528
|
-
written, so a case-sensitive one can be spelled exactly:
|
|
529
|
-
|
|
530
|
-
```ruby
|
|
531
|
-
Post.select { fn(:date_trunc, 'day', :created_at).as(:day) }
|
|
532
|
-
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
Values are quoted by the adapter wherever they appear, as they are in
|
|
536
|
-
Active Record, and so is a column alias. That is what makes the name asked for
|
|
537
|
-
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
538
|
-
other two keep it, so one block would mean two things. It also leaves nothing
|
|
539
|
-
to refuse — a name that would have been SQL becomes an identifier with a
|
|
540
|
-
strange name instead:
|
|
541
|
-
|
|
542
|
-
```ruby
|
|
543
|
-
Author.select { count(:*).as(:postCount) } # AS "postCount" everywhere
|
|
544
|
-
Author.select { count(:*).as(:'total sales') } # AS "total sales"
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
`quote: false` asks for the name as written, for a schema that wants the
|
|
548
|
-
folding. Nothing quotes it then, so a name that is not plain is refused:
|
|
549
|
-
|
|
550
|
-
```ruby
|
|
551
|
-
Author.select { count(:*).as(:post_count, quote: false) } # AS post_count
|
|
552
|
-
Author.select { count(:*).as(:'total sales', quote: false) } # ArgumentError
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
`fn`'s function name is the one that cannot be quoted: quoting stops
|
|
556
|
-
PostgreSQL folding it, and `"UPPER"(x)` is a function that does not exist.
|
|
557
|
-
That one, `cast`'s type and `extract`'s field are neither values nor
|
|
558
|
-
identifiers, so they have to be plain names and anything else raises
|
|
559
|
-
`ArgumentError` rather than reaching the query.
|
|
560
|
-
|
|
561
|
-
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
562
|
-
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
563
|
-
written as calls, PostgreSQL and SQLite would reject them. What does go into
|
|
564
|
-
parentheses is an optional precision — `current_timestamp(3)` — which
|
|
565
|
-
`current_date` never takes and SQLite never accepts. `current_timestamp` is
|
|
566
|
-
the portable spelling of what `now` means, and reaches SQLite where `now`
|
|
567
|
-
does not:
|
|
568
|
-
|
|
569
|
-
```ruby
|
|
570
|
-
Post.where { :published_at <= current_timestamp }
|
|
571
|
-
# SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
|
|
125
|
+
Author.select { [count(:*).filter { :age < 50 }.as(:young), avg(:age).as(:average)] }
|
|
126
|
+
Post.group { :author_id }.select { string_agg(:title, ", ").order(:title).as(:titles) }
|
|
127
|
+
Post.where { :created_at > current_timestamp - 7.days }
|
|
128
|
+
Post.select { cast(:price, "decimal(10,2)").as(:price) }
|
|
572
129
|
```
|
|
573
130
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
own spellings like `double precision` or `decimal(10,2)` pass; anything else
|
|
578
|
-
raises `ArgumentError`. The type is the adapter's own name for the type, and
|
|
579
|
-
whether it exists is the database's to say. SQLite spells everything
|
|
580
|
-
`extract` does as `strftime` formats, which no renaming carries, so `extract`
|
|
581
|
-
raises there:
|
|
582
|
-
|
|
583
|
-
```ruby
|
|
584
|
-
Post.where { extract(:year, :created_at) == 2026 }
|
|
585
|
-
# SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
|
|
586
|
-
|
|
587
|
-
Post.select { cast(:price, 'decimal(10,2)').as(:price) }
|
|
588
|
-
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
589
|
-
```
|
|
131
|
+
The scalar functions are methods — `upper`, `coalesce`, `round` and the rest
|
|
132
|
+
— spelled the adapter's way where the adapters differ, and `fn` reaches any
|
|
133
|
+
other by name. [docs/functions.md](docs/functions.md).
|
|
590
134
|
|
|
591
135
|
### Expressions
|
|
592
136
|
|
|
593
|
-
`+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
|
|
594
|
-
operators, so an expression groups the way it reads:
|
|
595
|
-
|
|
596
|
-
```ruby
|
|
597
|
-
Item.where { :price * :quantity > 1000 }
|
|
598
|
-
Item.select { sum(:price * :quantity).as(:total) }
|
|
599
|
-
```
|
|
600
|
-
|
|
601
|
-
`&`, `|`, `^`, `~`, `<<` and `>>` are SQL's bitwise operators. Between
|
|
602
|
-
conditions `&` and `|` are AND and OR, and that is where they are defined,
|
|
603
|
-
which leaves them free to mean here what SQL means by them:
|
|
604
|
-
|
|
605
137
|
```ruby
|
|
138
|
+
LineItem.where { :price * :quantity > 1000 }
|
|
606
139
|
Post.where { :flags & 4 > 0 }
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
Post.select { (:flags | 4).as(:flags) }
|
|
610
|
-
Post.select { (~:flags).as(:inverted) }
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
Each parenthesises itself, which is what keeps Ruby's grouping: PostgreSQL
|
|
614
|
-
gives `&` and `|` the same precedence and reads `a | b & c` from the left,
|
|
615
|
-
where Ruby reads the `&` first.
|
|
616
|
-
|
|
617
|
-
A boolean column is refused rather than taken for the one bit it is stored as.
|
|
618
|
-
MySQL and SQLite would quietly answer as `AND` would, PostgreSQL has no such
|
|
619
|
-
operator at all, and one block meaning two things is worse than an
|
|
620
|
-
`ArgumentError` saying that `true?` is what makes a boolean column a
|
|
621
|
-
condition. A condition as an operand is refused for the same reason.
|
|
622
|
-
|
|
623
|
-
XOR is the one the three do not share, and the one where guessing costs most:
|
|
624
|
-
MySQL spells it `^`, which is exponentiation to PostgreSQL, and PostgreSQL
|
|
625
|
-
spells it `#`, which is where a comment starts on MySQL — either way a wrong
|
|
626
|
-
answer rather than an error. Each adapter gets its own, and SQLite, which has
|
|
627
|
-
no XOR at all, gets the two operations it is made of, `(a | b) - (a & b)`.
|
|
628
|
-
That names each operand twice, so keep them cheap.
|
|
629
|
-
|
|
630
|
-
`bit_and`, `bit_or` and `bit_xor` are the aggregates of the first three, and
|
|
631
|
-
`bit_count` counts the bits that are set. SQLite has none of the four.
|
|
632
|
-
PostgreSQL counts the bits of a bit string rather than of a number, so the
|
|
633
|
-
argument is cast there, to `bit(64)` because that is what makes a negative
|
|
634
|
-
count as it does on MySQL:
|
|
635
|
-
|
|
636
|
-
```ruby
|
|
637
|
-
Post.group { :author_id }.select { bit_or(:flags).as(:flags) }
|
|
638
|
-
# SELECT BIT_OR("posts"."flags") AS "flags" ... GROUP BY "posts"."author_id"
|
|
639
|
-
|
|
640
|
-
Post.select { bit_count(:flags).as(:bits) }
|
|
641
|
-
# MySQL: BIT_COUNT("posts"."flags")
|
|
642
|
-
# PostgreSQL: BIT_COUNT(CAST("posts"."flags" AS bit(64)))
|
|
140
|
+
Author.select { :country.when("JP").then("Japan").else("elsewhere").as(:where) }
|
|
141
|
+
Author.select { case_when { :age >= 60 }.then("senior").else("adult").as(:band) }
|
|
643
142
|
```
|
|
644
143
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
number, the others as a negative one, and the bits are the same either way.
|
|
648
|
-
|
|
649
|
-
One place asks for a value to be said out loud: the top of a select list.
|
|
650
|
-
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
651
|
-
`concat(:name, '-x')` — but Active Record reads a string in `select` as SQL,
|
|
652
|
-
so `value` is how you ask for the other meaning. It carries the predications
|
|
653
|
-
and arithmetic with it, so a literal can be compared and combined like
|
|
654
|
-
anything else. Numbers have a shorthand, since nothing else could be meant by
|
|
655
|
-
one:
|
|
656
|
-
|
|
657
|
-
```ruby
|
|
658
|
-
Node.select { [:id, value(0).as(:depth)] }
|
|
659
|
-
# SELECT "nodes"."id", 0 AS depth FROM "nodes"
|
|
660
|
-
|
|
661
|
-
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
662
|
-
|
|
663
|
-
Post.select { [:title, value('draft').as(:state)] }
|
|
664
|
-
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
|
|
668
|
-
SQL in a select list, a value everywhere else — and refining it would make the
|
|
669
|
-
same literal mean one thing or the other depending on whether it had been sent
|
|
670
|
-
a message.
|
|
671
|
-
|
|
672
|
-
`CASE` is grammar rather than a function, and has two shapes. With an operand, each `when` is
|
|
673
|
-
something to compare it against; without one, each `when` carries a condition
|
|
674
|
-
of its own. `case` is a Ruby keyword, so the method behind both is only
|
|
675
|
-
reachable through the receiver — `self.case` — and each shape has a shorthand
|
|
676
|
-
that does not need it:
|
|
677
|
-
|
|
678
|
-
```ruby
|
|
679
|
-
Author.select { :country.when('JP').then('Japan').else('elsewhere').as(:where) }
|
|
680
|
-
# CASE "country" WHEN 'JP' THEN 'Japan' ELSE 'elsewhere' END AS where
|
|
681
|
-
|
|
682
|
-
Author.select { case_when { :age >= 60 }.then('senior').else('adult').as(:band) }
|
|
683
|
-
# CASE WHEN "age" >= 60 THEN 'senior' ELSE 'adult' END AS band
|
|
684
|
-
|
|
685
|
-
Author.select { self.case(mod(:age, 10)).when(0).then('round').else('not').as(:v) }
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
A `when` takes a value or a block, and so do `then` and `else`; the block is
|
|
689
|
-
there to read like the blocks around it, since an argument works just as well
|
|
690
|
-
— `:age >= 60` has already become an expression by the time it is passed.
|
|
691
|
-
Leaving the `else` off is SQL's own default, which is NULL. `when` and `then`
|
|
692
|
-
come in pairs, and one without the other is an `ArgumentError` rather than
|
|
693
|
-
something that reaches the database:
|
|
694
|
-
|
|
695
|
-
```ruby
|
|
696
|
-
Author.select {
|
|
697
|
-
case_when { :age < 18 }.then('minor').
|
|
698
|
-
when { :age >= 60 }.then('senior').
|
|
699
|
-
else('adult').as(:band)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
703
|
-
# SUM(CASE WHEN "age" >= 60 THEN 1 ELSE 0 END) AS seniors
|
|
704
|
-
```
|
|
144
|
+
Arithmetic, the bitwise operators and `CASE` are expressions like a column,
|
|
145
|
+
so they compare, alias and aggregate. [docs/expressions.md](docs/expressions.md).
|
|
705
146
|
|
|
706
147
|
### JSON
|
|
707
148
|
|
|
708
|
-
`dig` reads inside a JSON document, by the name of what `Hash` does. A string
|
|
709
|
-
or symbol steps into an object, an integer into an array, and what comes back
|
|
710
|
-
is still JSON — the way `Hash#dig` hands back the structure itself — for a
|
|
711
|
-
document to be dug into further or asked the JSON questions. `dig_text` gives
|
|
712
|
-
the value as text instead, which is what a comparison wants:
|
|
713
|
-
|
|
714
|
-
```ruby
|
|
715
|
-
Post.where { :meta.dig_text(:author, :name) == 'alice' }
|
|
716
|
-
Post.select { :meta.dig(:author).as(:author) }
|
|
717
|
-
Post.where { :meta.key?(:draft) }
|
|
718
|
-
Post.where { :meta.contains?(status: 'open') }
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
No two adapters spell any of this alike, and the block is the same on all
|
|
722
|
-
three:
|
|
723
|
-
|
|
724
|
-
| | PostgreSQL | SQLite | MySQL |
|
|
725
|
-
| --- | --- | --- | --- |
|
|
726
|
-
| `dig(:a, :b)` | `#> '{a,b}'` | `-> '$.a.b'` | `JSON_EXTRACT(…, '$.a.b')` |
|
|
727
|
-
| `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
728
|
-
| `key?(:a)` | `jsonb_exists(…, 'a')` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
729
|
-
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
730
|
-
|
|
731
|
-
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
732
|
-
MySQL family goes through the functions, which both have.
|
|
733
|
-
|
|
734
|
-
`dig_text` gives text everywhere. SQLite's `->>` would otherwise hand back the
|
|
735
|
-
value with its type, so a comparison that worked there would fail on the other
|
|
736
|
-
two; a number is compared through a `cast` on all three:
|
|
737
|
-
|
|
738
149
|
```ruby
|
|
739
|
-
|
|
740
|
-
|
|
150
|
+
Doc.where { :meta.dig_text(:author, :name) == "alice" }
|
|
151
|
+
Doc.where { :meta.key?(:draft) }
|
|
152
|
+
Doc.update_all { { meta: :meta.bury(:author, :name, "alice") } }
|
|
153
|
+
Post.group { :author_id }.select { json_arrayagg(:title).as(:titles) }
|
|
741
154
|
```
|
|
742
155
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
|
|
748
|
-
MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
|
|
749
|
-
three.
|
|
750
|
-
|
|
751
|
-
Comparing a dug value with anything but a string raises `ArgumentError` rather
|
|
752
|
-
than being left to the adapters, which answer it three ways: `dig_text(:n) ==
|
|
753
|
-
5` is true on SQLite, an error on PostgreSQL and true on MySQL, and
|
|
754
|
-
`dig_text(:flag) == true` is true, an error and false. `cast` is what says
|
|
755
|
-
which type was meant, and then all three agree. `dig` is refused the other way
|
|
756
|
-
about — the JSON for a string carries its quotes, so `dig(:name) == 'alice'`
|
|
757
|
-
is false, an error and true — and `dig_text` is the one that gives the value.
|
|
758
|
-
What `bury` and `except` give back is JSON as `dig`'s is, and is refused the
|
|
759
|
-
same way. A column, a function or another dug value on the right goes through
|
|
760
|
-
untouched; only a Ruby literal is refused. Arithmetic and the bit operators
|
|
761
|
-
are refused outright on both sides — `dig_text(:n) + 1` is 6 on SQLite, an
|
|
762
|
-
error on PostgreSQL and 6.0 on MariaDB — and `cast` settles those too.
|
|
763
|
-
|
|
764
|
-
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
765
|
-
the path to it. The document comes back changed rather than being written
|
|
766
|
-
anywhere, so `update_all` is what makes it stick:
|
|
156
|
+
`dig` and `dig_text`, `bury`, `except`, `key?` and `keys` read and change a
|
|
157
|
+
document by the names Hash uses; `json_object` and `json_arrayagg` build one. The same block
|
|
158
|
+
runs on PostgreSQL's `jsonb`, MySQL's JSON and SQLite's.
|
|
159
|
+
[docs/json.md](docs/json.md).
|
|
767
160
|
|
|
768
|
-
|
|
769
|
-
Post.update_all { { meta: :meta.bury(:author, :name, 'alice') } }
|
|
770
|
-
# SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
|
|
771
|
-
# ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
|
|
772
|
-
|
|
773
|
-
Post.update_all { { meta: :meta.bury(:tags, ['ruby', 'sql']) } }
|
|
774
|
-
Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
A whole document goes in as one — an object or an array rather than the string
|
|
778
|
-
that spells it — which each adapter takes its own way round, and a boolean
|
|
779
|
-
goes in as JSON too, which SQLite would otherwise write as its `1`. `bury` is
|
|
780
|
-
not a Ruby method; it is the name Ruby considered for the other end of `dig`,
|
|
781
|
-
and
|
|
782
|
-
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
783
|
-
the others say `JSON_SET`.
|
|
784
|
-
|
|
785
|
-
`except` takes keys out again, and takes them as `Hash#except` does — keys of
|
|
786
|
-
the document, however many, rather than a path, which is `bury`'s way of
|
|
787
|
-
reaching further in. It gives back the document changed, so it chains with
|
|
788
|
-
`bury` and goes where `bury` goes:
|
|
789
|
-
|
|
790
|
-
```ruby
|
|
791
|
-
Post.update_all { { meta: :meta.except(:draft) } }
|
|
792
|
-
# SET "meta" = "meta" - CAST('{"draft"}' AS text[])
|
|
793
|
-
# ... JSON_REMOVE("meta", '$.draft') elsewhere
|
|
794
|
-
|
|
795
|
-
Post.update_all { { meta: :meta.bury(:author, :name, 'alice').except(:tmp) } }
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
A key that is not there is not an error, as it is not to `Hash#except`. The
|
|
799
|
-
cast is not decoration: `jsonb` has three subtractions — a key, an array of
|
|
800
|
-
keys, an element by index — and an array literal written without a type is
|
|
801
|
-
read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
|
|
802
|
-
`{draft}`, which is nothing, and says nothing about it.
|
|
803
|
-
|
|
804
|
-
A key deeper in is reached through the chain: `dig` reads the part out,
|
|
805
|
-
`except` takes the key from it, and `bury` puts it back:
|
|
161
|
+
### Window functions
|
|
806
162
|
|
|
807
163
|
```ruby
|
|
808
|
-
|
|
164
|
+
Author.select { [:name, row_number.over.partition(:country).order(:age.desc).as(:rank)] }
|
|
165
|
+
Post.select { sum(:likes).over.order(:created_at).rows(..0).as(:running_total) }
|
|
809
166
|
```
|
|
810
167
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
```ruby
|
|
815
|
-
Post.where { :meta.dig(:author).key?(:email) }
|
|
816
|
-
Post.where { :meta.dig(:author).dig_text(:name) == 'alice' }
|
|
817
|
-
Post.update_all { { meta: :meta.dig(:author).bury(:name, 'alice') } }
|
|
818
|
-
```
|
|
168
|
+
`over` on any aggregate or window function, then `partition`, `order`,
|
|
169
|
+
`rows` and `range`. [docs/windows.md](docs/windows.md).
|
|
819
170
|
|
|
820
|
-
|
|
171
|
+
### Ordering, aliases and collation
|
|
821
172
|
|
|
822
173
|
```ruby
|
|
823
|
-
|
|
174
|
+
Author.order { :country.asc.nulls_last }
|
|
175
|
+
Author.select { upper(:name).as(:author) }
|
|
176
|
+
Author.where { :name.collate(:nocase) == "alice" }
|
|
824
177
|
```
|
|
825
178
|
|
|
826
|
-
|
|
827
|
-
and reading text back as a document is where the adapters part company —
|
|
828
|
-
SQLite parses it, MySQL takes it as written, and PostgreSQL has no such
|
|
829
|
-
function for text at all.
|
|
830
|
-
|
|
831
|
-
`contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
832
|
-
there — later than the rest, since the adapter is only known when the SQL is
|
|
833
|
-
built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
|
|
834
|
-
`key?`, `contains?`, `bury` and `except` want a `jsonb` column.
|
|
835
|
-
|
|
836
|
-
A key that is not a plain name travels as itself rather than being refused:
|
|
837
|
-
`dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
|
|
838
|
-
|
|
839
|
-
### Window functions
|
|
179
|
+
[docs/ordering.md](docs/ordering.md).
|
|
840
180
|
|
|
841
|
-
|
|
842
|
-
running one and the only thing `row_number` and its kind can be used with.
|
|
843
|
-
The window is built by chaining, as Arel's own is:
|
|
181
|
+
### Grouping, CTEs and DISTINCT ON
|
|
844
182
|
|
|
845
183
|
```ruby
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
Author.select { row_number.over.partition(:country).order(:age.desc).as(:rank) }
|
|
850
|
-
# ROW_NUMBER() OVER (PARTITION BY "country" ORDER BY "age" DESC) AS rank
|
|
851
|
-
|
|
852
|
-
Author.select { count(:*).over.as(:total) } # COUNT(*) OVER () — every row
|
|
184
|
+
Sale.group { rollup(:region, :product) }
|
|
185
|
+
Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] } # PostgreSQL
|
|
186
|
+
Node.with_recursive(tree: [Node.where { :id == 1 }, Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }]).from_cte(:tree)
|
|
853
187
|
```
|
|
854
188
|
|
|
855
|
-
|
|
856
|
-
`lag`, `lead`, `first_value`, `last_value` and `nth_value` are the functions
|
|
857
|
-
that say nothing without a window; each raises `ArgumentError` if `over` never
|
|
858
|
-
arrives, rather than reaching the database as an error there. Every adapter
|
|
859
|
-
that has window functions at all spells them the same way, so unlike the
|
|
860
|
-
scalar functions there is nothing here to translate.
|
|
189
|
+
[docs/grouping.md](docs/grouping.md) and [docs/ctes.md](docs/ctes.md).
|
|
861
190
|
|
|
862
|
-
|
|
863
|
-
positive after, 0 the row itself, and an open end for unbounded:
|
|
191
|
+
### Writing
|
|
864
192
|
|
|
865
193
|
```ruby
|
|
866
|
-
Post.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
Post.select { avg(:likes).over.order(:created_at).rows(-1..1).as(:smoothed) }
|
|
870
|
-
# ... ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
871
|
-
|
|
872
|
-
Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
|
|
873
|
-
# ... ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
|
|
194
|
+
Post.where { :published == true }.update_all { { likes: :likes + 1 } }
|
|
195
|
+
Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
874
196
|
```
|
|
875
197
|
|
|
876
|
-
|
|
877
|
-
none. Named windows — `WINDOW w AS (...)` — have no clause in Active Record to
|
|
878
|
-
live in, so they are not here.
|
|
879
|
-
|
|
880
|
-
### Aliases and ordering
|
|
881
|
-
|
|
882
|
-
`.as` gives an expression a column alias, and `.asc` / `.desc` give an
|
|
883
|
-
ordering its direction. The orderings take `.nulls_first` / `.nulls_last` as
|
|
884
|
-
well. MySQL has no such syntax, but Arel emulates it there, so the resulting
|
|
885
|
-
order is the same everywhere:
|
|
886
|
-
|
|
887
|
-
```ruby
|
|
888
|
-
Author.order { :country.asc.nulls_last }
|
|
889
|
-
```
|
|
198
|
+
[docs/writing.md](docs/writing.md).
|
|
890
199
|
|
|
891
|
-
|
|
200
|
+
### Time zones
|
|
892
201
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
having { count(:posts[:id]) > 1 }.
|
|
899
|
-
order { count(:posts[:id]).desc }.
|
|
900
|
-
select {
|
|
901
|
-
[
|
|
902
|
-
upper(:authors[:name]).as(:author),
|
|
903
|
-
count(:posts[:id]).as(:post_count),
|
|
904
|
-
avg(:posts[:likes]).as(:avg_likes),
|
|
905
|
-
]
|
|
906
|
-
}
|
|
907
|
-
```
|
|
202
|
+
Active Record stores in UTC and a `Time` in a block is quoted the way Active
|
|
203
|
+
Record quotes one, so `where { :created_at > Time.current - 1.day }` is right
|
|
204
|
+
whatever `Time.zone` is; what the database says the time is —
|
|
205
|
+
`current_timestamp`, `extract` — is the session's business.
|
|
206
|
+
[docs/time_zones.md](docs/time_zones.md).
|
|
908
207
|
|
|
909
|
-
|
|
208
|
+
## Other adapters
|
|
910
209
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
210
|
+
SQLite, PostgreSQL, MySQL, MariaDB, Oracle and SQL Server are built in: each
|
|
211
|
+
is a `Dialect`, one class per family of spellings, asked for whatever the
|
|
212
|
+
databases write differently. An adapter the gem does not know keeps the
|
|
213
|
+
standard spellings, which reach further than you might expect; where they
|
|
214
|
+
fall short, a dialect of your own says the rest. Subclass
|
|
215
|
+
`ActiveRecord::Refined::Dialect` — or the built-in family the database
|
|
216
|
+
descends from — override only what it spells differently, and register it
|
|
217
|
+
under the adapter's name:
|
|
915
218
|
|
|
916
219
|
```ruby
|
|
917
|
-
|
|
918
|
-
#
|
|
919
|
-
|
|
920
|
-
Post.update_all { { title: upper(:title), likes: case_when { :likes < 0 }.then(0).else(:likes) } }
|
|
921
|
-
```
|
|
220
|
+
class ExampleDialect < ActiveRecord::Refined::Dialect
|
|
221
|
+
# The example database spells char_length LEN, and has no random ordering.
|
|
222
|
+
FUNCTIONS = { char_length: "LEN", rand: nil }.freeze
|
|
922
223
|
|
|
923
|
-
|
|
924
|
-
|
|
224
|
+
def full_outer_join_supported? = false
|
|
225
|
+
end
|
|
925
226
|
|
|
926
|
-
|
|
927
|
-
Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
928
|
-
# ... ON CONFLICT ("page") DO UPDATE SET "hits"=("tallies"."hits" + "excluded"."hits")
|
|
227
|
+
ActiveRecord::Refined::Dialect.register("exampledb", ExampleDialect)
|
|
929
228
|
```
|
|
930
229
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
the one place the DSL writes SQL out itself rather than handing Arel a tree —
|
|
935
|
-
and the two cannot both be given.
|
|
936
|
-
|
|
937
|
-
`insert_all` has no block: its values are literals by construction.
|
|
938
|
-
Active Record type-casts each one on the way into the `VALUES` list, so an
|
|
939
|
-
expression does not become SQL there — it becomes nothing, silently. Use
|
|
940
|
-
`upsert_all` where a row's value has to be worked out.
|
|
230
|
+
`register` also takes a block for an adapter whose dialect only the
|
|
231
|
+
connection can name — the way `mysql2` answers for MySQL and MariaDB both —
|
|
232
|
+
receiving the model and returning the class.
|
|
941
233
|
|
|
942
234
|
## Performance
|
|
943
235
|
|
|
@@ -994,25 +286,29 @@ the default; set `ADAPTER` to run the same suite against another one.
|
|
|
994
286
|
```sh
|
|
995
287
|
rake test # sqlite3
|
|
996
288
|
ADAPTER=postgresql rake test
|
|
997
|
-
ADAPTER=mysql2 rake test
|
|
998
|
-
rake test:
|
|
289
|
+
ADAPTER=mysql2 rake test # MariaDB
|
|
290
|
+
rake test:mysql8 # Oracle's MySQL, on port 3307
|
|
291
|
+
rake test:all # all of the above; MySQL skipped when 3307 is empty
|
|
999
292
|
```
|
|
1000
293
|
|
|
1001
|
-
PostgreSQL and
|
|
1002
|
-
password, which is how the devcontainer sets them up
|
|
1003
|
-
|
|
1004
|
-
|
|
294
|
+
PostgreSQL and the MySQLs are reached on `127.0.0.1` as the current user with
|
|
295
|
+
no password, which is how the devcontainer sets them up — MariaDB on its own
|
|
296
|
+
port and Oracle's MySQL on 3307, since the two answer the `mysql2` adapter
|
|
297
|
+
differently and CI runs both. Override with `DB_HOST`, `DB_PORT`,
|
|
298
|
+
`DB_USERNAME` and `DB_PASSWORD`. The `activerecord_refined_test` database is
|
|
299
|
+
created on first use.
|
|
1005
300
|
|
|
1006
|
-
The
|
|
1007
|
-
needs
|
|
1008
|
-
|
|
301
|
+
The client gems sit in optional Gemfile groups named after their adapters,
|
|
302
|
+
since building each needs its client library installed. A plain bundle
|
|
303
|
+
serves SQLite with nothing extra; opt in to the adapters you will reach:
|
|
1009
304
|
|
|
1010
305
|
```sh
|
|
1011
|
-
bundle config set --local
|
|
306
|
+
bundle config set --local with postgresql mysql2 trilogy
|
|
1012
307
|
```
|
|
1013
308
|
|
|
1014
|
-
CI runs
|
|
1015
|
-
|
|
309
|
+
CI runs one job per adapter with the servers as service containers, Oracle
|
|
310
|
+
and SQL Server included — those two have no local server here and run on CI
|
|
311
|
+
alone, their clients opted in the same way.
|
|
1016
312
|
|
|
1017
313
|
## Releasing
|
|
1018
314
|
|