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