activerecord-refined 0.5.0 → 0.6.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/.github/workflows/sandbox.yml +162 -26
- data/.github/workflows/test.yml +15 -2
- data/README.md +486 -52
- data/activerecord-refined.gemspec +3 -2
- data/examples/ctes.rb +41 -10
- data/examples/expressions.rb +71 -5
- data/examples/json.rb +94 -0
- data/examples/postgresql.rb +89 -6
- data/examples/predicates.rb +32 -5
- data/examples/windows.rb +96 -0
- data/examples/writes.rb +84 -0
- data/lib/active_record/refined/ast.rb +831 -94
- data/lib/active_record/refined.rb +351 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +7 -0
- data/test/test_block_syntax.rb +1100 -51
- data/test/test_helper.rb +82 -0
- metadata +4 -1
data/README.md
CHANGED
|
@@ -87,6 +87,41 @@ Author.where { :country.in?(%w[JP US]) } # IN
|
|
|
87
87
|
Author.where { :country.null? } # IS NULL
|
|
88
88
|
```
|
|
89
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
|
+
|
|
90
125
|
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
91
126
|
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
92
127
|
`where(id: relation)` does:
|
|
@@ -105,6 +140,24 @@ Author.where { :age >= Author.select { avg(:age) } }
|
|
|
105
140
|
# "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
|
|
106
141
|
```
|
|
107
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
|
+
|
|
108
161
|
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
109
162
|
subquery with the outer table through qualified columns — its `where` block
|
|
110
163
|
goes through the DSL like any other:
|
|
@@ -241,29 +294,139 @@ Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manage
|
|
|
241
294
|
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
242
295
|
```
|
|
243
296
|
|
|
297
|
+
### Keeping one row per group
|
|
298
|
+
|
|
299
|
+
`distinct_on` is PostgreSQL's `DISTINCT ON`: the first row of each group the
|
|
300
|
+
order brings up.
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] }
|
|
304
|
+
# SELECT DISTINCT ON ( "author_id" ) "posts".* FROM "posts"
|
|
305
|
+
# ORDER BY "author_id", "likes" DESC
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Arel carries the node and refuses to write it for the others, the way it does
|
|
309
|
+
a regexp, so it raises `NotImplementedError` on SQLite and MySQL. The shape
|
|
310
|
+
that runs everywhere is a `row_number` window in a subquery, which says the
|
|
311
|
+
same thing at more length:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
ranked = Post.select {
|
|
315
|
+
[:author_id, :likes, row_number.over.partition(:author_id).order(:likes.desc).as(:rn)]
|
|
316
|
+
}
|
|
317
|
+
Post.from(ranked, :posts).where { :rn == 1 }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The subquery is named after the model's own table for the reason `from_cte`
|
|
321
|
+
is: ActiveRecord goes on qualifying columns with that name, so `where` needs
|
|
322
|
+
to find it.
|
|
323
|
+
|
|
324
|
+
### Grouping several ways at once
|
|
325
|
+
|
|
326
|
+
`grouping_sets`, `rollup` and `cube` ask for more than one grouping in a
|
|
327
|
+
single query, the totals of each coming back beside the rows. Each set is a
|
|
328
|
+
list of its own, and an empty one is the grand total:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Sale.group { grouping_sets([:region], [:product], []) }.
|
|
332
|
+
select { [:region, :product, sum(:amount).as(:total)] }
|
|
333
|
+
# GROUP BY GROUPING SETS( ( "region" ), ( "product" ), ( ) )
|
|
334
|
+
|
|
335
|
+
Sale.group { rollup(:region, :product) } # GROUP BY ROLLUP( "region", "product" )
|
|
336
|
+
Sale.group { cube(:region, :product) } # GROUP BY CUBE( "region", "product" )
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
A row that a set did not group by comes back with NULL there, which is also
|
|
340
|
+
what a real NULL looks like; `fn(:grouping, :region)` tells the two apart.
|
|
341
|
+
|
|
342
|
+
These are PostgreSQL's. SQLite has none of them, and MySQL has only `WITH
|
|
343
|
+
ROLLUP`, which says one of the three and says it elsewhere in the clause, so
|
|
344
|
+
the block raises `NotImplementedError` on both.
|
|
345
|
+
|
|
346
|
+
### Lateral joins
|
|
347
|
+
|
|
348
|
+
`lateral: true` joins a relation rather than a table, and lets it see the row
|
|
349
|
+
being joined to. That is what makes the top row of each group reachable in one
|
|
350
|
+
query:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
top_post = Post.select { :title }.
|
|
354
|
+
where { :posts[:author_id] == :authors[:id] }.
|
|
355
|
+
order { :likes.desc }.limit(1)
|
|
356
|
+
|
|
357
|
+
Author.left_outer_joins(top_post, as: :top, lateral: true).
|
|
358
|
+
select { [:name, :top[:title].as(:top_post)] }
|
|
359
|
+
# SELECT "name", "top"."title" AS "top_post" FROM "authors"
|
|
360
|
+
# LEFT OUTER JOIN LATERAL (SELECT "title" FROM "posts"
|
|
361
|
+
# WHERE "posts"."author_id" = "authors"."id" ORDER BY "likes" DESC LIMIT 1) "top" ON TRUE
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`as` is required — the relation has no name of its own to qualify with. Without
|
|
365
|
+
a block the join is `ON TRUE`, which is the usual shape: what the subquery is
|
|
366
|
+
allowed to see is said inside it. A block writes a real `ON` clause.
|
|
367
|
+
|
|
368
|
+
PostgreSQL has `LATERAL` and so has MySQL, from 8.0.14. SQLite has none, and
|
|
369
|
+
neither has MariaDB, which answers to the same adapter as MySQL; both raise
|
|
370
|
+
`NotImplementedError`. Arel has a node for it but only PostgreSQL's visitor
|
|
371
|
+
writes it, so the SQL is written here instead.
|
|
372
|
+
|
|
244
373
|
### Common table expressions
|
|
245
374
|
|
|
246
375
|
ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
247
376
|
is joined by name like any other table, so its `ON` clause is a block, where
|
|
248
377
|
Rails' own documentation reaches for a string join.
|
|
249
378
|
|
|
250
|
-
`
|
|
251
|
-
|
|
379
|
+
`from_cte` takes the CTE's name and selects it under the model's own table
|
|
380
|
+
name, so the model's columns resolve:
|
|
252
381
|
|
|
253
382
|
```ruby
|
|
254
383
|
Node.with_recursive(
|
|
255
384
|
tree: [
|
|
256
|
-
Node.where { :id == root.id }
|
|
257
|
-
|
|
385
|
+
Node.where { :id == root.id }.
|
|
386
|
+
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
387
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
388
|
+
select { [:id, :name, :parent_id, (:tree[:depth] + 1).as(:depth)] },
|
|
258
389
|
]
|
|
259
|
-
).
|
|
390
|
+
).from_cte(:tree)
|
|
260
391
|
# WITH RECURSIVE "tree" AS (
|
|
261
|
-
# SELECT "nodes"
|
|
392
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
|
|
393
|
+
# FROM "nodes" WHERE "nodes"."id" = 1
|
|
262
394
|
# UNION ALL
|
|
263
|
-
# SELECT "nodes"
|
|
395
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
|
|
396
|
+
# ("tree"."depth" + 1) AS depth
|
|
397
|
+
# FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
264
398
|
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
265
399
|
```
|
|
266
400
|
|
|
401
|
+
The anchor starts the count and the recursive member adds one, which is how
|
|
402
|
+
the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
403
|
+
SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
|
|
404
|
+
can say `.as` directly.
|
|
405
|
+
|
|
406
|
+
The alias on the last line is there for ActiveRecord's sake, not SQL's:
|
|
407
|
+
written by hand that line would be `SELECT * FROM tree`. ActiveRecord goes on qualifying
|
|
408
|
+
columns with the model's table name, so without the alias that name is not in
|
|
409
|
+
the query and anything qualifying a column fails:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
413
|
+
# PG::UndefinedTable: missing FROM-clause entry for table "nodes"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Since the model's name is the only one that works, `from_cte` takes it from
|
|
417
|
+
the model rather than asking. It also checks that the name is one `with`
|
|
418
|
+
declares, so a typo is an `ArgumentError` here rather than a query against a
|
|
419
|
+
table nobody has — checked when the SQL is built, so the CTE may be declared
|
|
420
|
+
later in the chain or by a scope merged into it.
|
|
421
|
+
|
|
422
|
+
`from(:tree, as: :nodes)` is the same thing spelled out, without the check,
|
|
423
|
+
and is what to reach for when the name wanted is not the model's.
|
|
424
|
+
|
|
425
|
+
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
426
|
+
`order` and `select` never qualify, so they work without the alias on every
|
|
427
|
+
adapter; it is `where` and `find_by` that stop. A query can therefore look
|
|
428
|
+
right until the day a condition is added to it.
|
|
429
|
+
|
|
267
430
|
A non-recursive CTE joins the same way:
|
|
268
431
|
|
|
269
432
|
```ruby
|
|
@@ -273,19 +436,46 @@ Node.with(roots: Node.where { :parent_id.null? }).
|
|
|
273
436
|
|
|
274
437
|
`examples/ctes.rb` walks a category tree with these.
|
|
275
438
|
|
|
276
|
-
### Aggregates
|
|
439
|
+
### Aggregates and functions
|
|
277
440
|
|
|
278
441
|
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
279
|
-
scalar functions below, with `fn` for anything else.
|
|
280
|
-
alias, and `.asc` / `.desc` for the sort direction. Return an array to select
|
|
442
|
+
bit aggregates and the scalar functions below, with `fn` for anything else. Return an array to select
|
|
281
443
|
or order by multiple expressions.
|
|
282
444
|
|
|
445
|
+
`filter` takes the aggregate over the rows a condition holds for, as a value
|
|
446
|
+
or a block:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
Author.select { count(:*).filter { :age < 50 }.as(:young) }
|
|
450
|
+
# COUNT(*) FILTER (WHERE "age" < 50) AS "young"
|
|
451
|
+
|
|
452
|
+
Author.select {
|
|
453
|
+
[count(:*).as(:all), sum(:age).filter { :country == 'JP' }.as(:jp_years)]
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
MySQL has no `FILTER` clause, and gets the case that means the same thing —
|
|
458
|
+
`COUNT(CASE WHEN "age" < 50 THEN 1 END)`. An aggregate passes over a NULL, so
|
|
459
|
+
a row the condition misses is a row it does not see, and the number that comes
|
|
460
|
+
back is the same on all three.
|
|
461
|
+
|
|
462
|
+
Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
|
|
463
|
+
`COUNT(DISTINCT ...)`:
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
Author.group { :country }.having { count(:*) > 1 }
|
|
467
|
+
# SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
|
|
468
|
+
|
|
469
|
+
Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
|
|
470
|
+
```
|
|
471
|
+
|
|
283
472
|
The scalar functions are real methods rather than anything caught dynamically,
|
|
284
473
|
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
285
474
|
answers to — `rand` — means the SQL one inside a block:
|
|
286
475
|
|
|
287
476
|
```
|
|
288
|
-
abs acos asin atan atan2
|
|
477
|
+
abs acos asin atan atan2 bit_and bit_count bit_or bit_xor cast
|
|
478
|
+
ceil char_length coalesce concat
|
|
289
479
|
cos current_date current_time current_timestamp date_trunc degrees
|
|
290
480
|
exp extract floor format greatest least length ln localtime
|
|
291
481
|
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
@@ -300,8 +490,51 @@ MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
|
300
490
|
insists on the second argument the others default to zero — SQLite's takes
|
|
301
491
|
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
302
492
|
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
303
|
-
whose spelling is `log(2, x)` — the block raises
|
|
304
|
-
rather than leaving the database to reject the SQL.
|
|
493
|
+
whose spelling is `log(2, x)`, the four `bit_*` on SQLite — the block raises
|
|
494
|
+
`NotImplementedError` rather than leaving the database to reject the SQL.
|
|
495
|
+
|
|
496
|
+
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
497
|
+
same name does something else entirely: it puts separators in a number, and
|
|
498
|
+
reads a printf template as the number zero rather than complaining. `fn` still
|
|
499
|
+
reaches it, spelled as the different thing it is:
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
506
|
+
written, so a case-sensitive one can be spelled exactly:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
Post.select { fn(:date_trunc, 'day', :created_at).as(:day) }
|
|
510
|
+
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Values are quoted by the adapter wherever they appear, as they are in
|
|
514
|
+
ActiveRecord, and so is a column alias. That is what makes the name asked for
|
|
515
|
+
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
516
|
+
other two keep it, so one block would mean two things. It also leaves nothing
|
|
517
|
+
to refuse — a name that would have been SQL becomes an identifier with a
|
|
518
|
+
strange name instead:
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
Author.select { count(:*).as(:postCount) } # AS "postCount" everywhere
|
|
522
|
+
Author.select { count(:*).as(:'total sales') } # AS "total sales"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
`quote: false` asks for the name as written, for a schema that wants the
|
|
526
|
+
folding. Nothing quotes it then, so a name that is not plain is refused:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
Author.select { count(:*).as(:post_count, quote: false) } # AS post_count
|
|
530
|
+
Author.select { count(:*).as(:'total sales', quote: false) } # ArgumentError
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
`fn`'s function name is the one that cannot be quoted: quoting stops
|
|
534
|
+
PostgreSQL folding it, and `"UPPER"(x)` is a function that does not exist.
|
|
535
|
+
That one, `cast`'s type and `extract`'s field are neither values nor
|
|
536
|
+
identifiers, so they have to be plain names and anything else raises
|
|
537
|
+
`ArgumentError` rather than reaching the query.
|
|
305
538
|
|
|
306
539
|
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
307
540
|
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
@@ -333,55 +566,239 @@ Post.select { cast(:price, 'decimal(10,2)').as(:price) }
|
|
|
333
566
|
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
334
567
|
```
|
|
335
568
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
569
|
+
### Expressions
|
|
570
|
+
|
|
571
|
+
`+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
|
|
572
|
+
operators, so an expression groups the way it reads:
|
|
340
573
|
|
|
341
574
|
```ruby
|
|
342
|
-
|
|
575
|
+
Item.where { :price * :quantity > 1000 }
|
|
576
|
+
Item.select { sum(:price * :quantity).as(:total) }
|
|
343
577
|
```
|
|
344
578
|
|
|
345
|
-
|
|
346
|
-
|
|
579
|
+
`&`, `|`, `^`, `~`, `<<` and `>>` are SQL's bitwise operators. Between
|
|
580
|
+
conditions `&` and `|` are AND and OR, and that is where they are defined,
|
|
581
|
+
which leaves them free to mean here what SQL means by them:
|
|
347
582
|
|
|
348
583
|
```ruby
|
|
349
|
-
|
|
350
|
-
#
|
|
584
|
+
Post.where { :flags & 4 > 0 }
|
|
585
|
+
# WHERE ("posts"."flags" & 4) > 0
|
|
351
586
|
|
|
352
|
-
Post.select {
|
|
587
|
+
Post.select { (:flags | 4).as(:flags) }
|
|
588
|
+
Post.select { (~:flags).as(:inverted) }
|
|
353
589
|
```
|
|
354
590
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
optionally qualified by a schema in `fn`'s case. Anything else raises
|
|
359
|
-
`ArgumentError` rather than reaching the query.
|
|
591
|
+
Each parenthesises itself, which is what keeps Ruby's grouping: PostgreSQL
|
|
592
|
+
gives `&` and `|` the same precedence and reads `a | b & c` from the left,
|
|
593
|
+
where Ruby reads the `&` first.
|
|
360
594
|
|
|
361
|
-
|
|
362
|
-
|
|
595
|
+
A boolean column is refused rather than taken for the one bit it is stored as.
|
|
596
|
+
MySQL and SQLite would quietly answer as `AND` would, PostgreSQL has no such
|
|
597
|
+
operator at all, and one block meaning two things is worse than an
|
|
598
|
+
`ArgumentError` saying that `true?` is what makes a boolean column a
|
|
599
|
+
condition. A condition as an operand is refused for the same reason.
|
|
600
|
+
|
|
601
|
+
XOR is the one the three do not share, and the one where guessing costs most:
|
|
602
|
+
MySQL spells it `^`, which is exponentiation to PostgreSQL, and PostgreSQL
|
|
603
|
+
spells it `#`, which is where a comment starts on MySQL — either way a wrong
|
|
604
|
+
answer rather than an error. Each adapter gets its own, and SQLite, which has
|
|
605
|
+
no XOR at all, gets the two operations it is made of, `(a | b) - (a & b)`.
|
|
606
|
+
That names each operand twice, so keep them cheap.
|
|
607
|
+
|
|
608
|
+
`bit_and`, `bit_or` and `bit_xor` are the aggregates of the first three, and
|
|
609
|
+
`bit_count` counts the bits that are set. SQLite has none of the four.
|
|
610
|
+
PostgreSQL counts the bits of a bit string rather than of a number, so the
|
|
611
|
+
argument is cast there, to `bit(64)` because that is what makes a negative
|
|
612
|
+
count as it does on MySQL:
|
|
363
613
|
|
|
364
614
|
```ruby
|
|
365
|
-
Post.
|
|
366
|
-
# SELECT
|
|
615
|
+
Post.group { :author_id }.select { bit_or(:flags).as(:flags) }
|
|
616
|
+
# SELECT BIT_OR("posts"."flags") AS "flags" ... GROUP BY "posts"."author_id"
|
|
617
|
+
|
|
618
|
+
Post.select { bit_count(:flags).as(:bits) }
|
|
619
|
+
# MySQL: BIT_COUNT("posts"."flags")
|
|
620
|
+
# PostgreSQL: BIT_COUNT(CAST("posts"."flags" AS bit(64)))
|
|
367
621
|
```
|
|
368
622
|
|
|
369
|
-
|
|
370
|
-
|
|
623
|
+
`bit_xor` arrived in PostgreSQL 14. `~` is where the three disagree about the
|
|
624
|
+
answer rather than the question: MySQL reads it back as the unsigned 64-bit
|
|
625
|
+
number, the others as a negative one, and the bits are the same either way.
|
|
626
|
+
|
|
627
|
+
One place asks for a value to be said out loud: the top of a select list.
|
|
628
|
+
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
629
|
+
`concat(:name, '-x')` — but ActiveRecord reads a string in `select` as SQL,
|
|
630
|
+
so `value` is how you ask for the other meaning. It carries the predications
|
|
631
|
+
and arithmetic with it, so a literal can be compared and combined like
|
|
632
|
+
anything else. Numbers have a shorthand, since nothing else could be meant by
|
|
633
|
+
one:
|
|
371
634
|
|
|
372
635
|
```ruby
|
|
373
|
-
|
|
374
|
-
|
|
636
|
+
Node.select { [:id, value(0).as(:depth)] }
|
|
637
|
+
# SELECT "nodes"."id", 0 AS depth FROM "nodes"
|
|
638
|
+
|
|
639
|
+
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
640
|
+
|
|
641
|
+
Post.select { [:title, value('draft').as(:state)] }
|
|
642
|
+
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
|
|
646
|
+
SQL in a select list, a value everywhere else — and refining it would make the
|
|
647
|
+
same literal mean one thing or the other depending on whether it had been sent
|
|
648
|
+
a message.
|
|
649
|
+
|
|
650
|
+
`CASE` is grammar rather than a function, and has two shapes. With an operand, each `when` is
|
|
651
|
+
something to compare it against; without one, each `when` carries a condition
|
|
652
|
+
of its own. `case` is a Ruby keyword, so the method behind both is only
|
|
653
|
+
reachable through the receiver — `self.case` — and each shape has a shorthand
|
|
654
|
+
that does not need it:
|
|
655
|
+
|
|
656
|
+
```ruby
|
|
657
|
+
Author.select { :country.when('JP').then('Japan').else('elsewhere').as(:where) }
|
|
658
|
+
# CASE "country" WHEN 'JP' THEN 'Japan' ELSE 'elsewhere' END AS where
|
|
659
|
+
|
|
660
|
+
Author.select { case_when { :age >= 60 }.then('senior').else('adult').as(:band) }
|
|
661
|
+
# CASE WHEN "age" >= 60 THEN 'senior' ELSE 'adult' END AS band
|
|
662
|
+
|
|
663
|
+
Author.select { self.case(mod(:age, 10)).when(0).then('round').else('not').as(:v) }
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
A `when` takes a value or a block, and so do `then` and `else`; the block is
|
|
667
|
+
there to read like the blocks around it, since an argument works just as well
|
|
668
|
+
— `:age >= 60` has already become an expression by the time it is passed.
|
|
669
|
+
Leaving the `else` off is SQL's own default, which is NULL. `when` and `then`
|
|
670
|
+
come in pairs, and one without the other is an `ArgumentError` rather than
|
|
671
|
+
something that reaches the database:
|
|
672
|
+
|
|
673
|
+
```ruby
|
|
674
|
+
Author.select {
|
|
675
|
+
case_when { :age < 18 }.then('minor').
|
|
676
|
+
when { :age >= 60 }.then('senior').
|
|
677
|
+
else('adult').as(:band)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
681
|
+
# SUM(CASE WHEN "age" >= 60 THEN 1 ELSE 0 END) AS seniors
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### JSON
|
|
685
|
+
|
|
686
|
+
`dig` reads inside a JSON document, by the name of what `Hash` does. A string
|
|
687
|
+
or symbol steps into an object, an integer into an array:
|
|
688
|
+
|
|
689
|
+
```ruby
|
|
690
|
+
Post.where { :meta.dig(:author, :name) == 'alice' }
|
|
691
|
+
Post.select { :meta.dig(:tags, 0).as(:first_tag) }
|
|
692
|
+
Post.where { :meta.key?(:draft) }
|
|
693
|
+
Post.where { :meta.contains?(status: 'open') }
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
No two adapters spell any of this alike, and the block is the same on all
|
|
697
|
+
three:
|
|
698
|
+
|
|
699
|
+
| | PostgreSQL | SQLite | MySQL |
|
|
700
|
+
| --- | --- | --- | --- |
|
|
701
|
+
| `dig(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
702
|
+
| `dig_json(:a)` | `#> '{a}'` | `-> '$.a'` | `JSON_EXTRACT(…, '$.a')` |
|
|
703
|
+
| `key?(:a)` | `jsonb_exists(…, 'a')` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
704
|
+
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
705
|
+
|
|
706
|
+
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
707
|
+
MySQL family goes through the functions, which both have.
|
|
708
|
+
|
|
709
|
+
`dig` gives text everywhere. SQLite's `->>` would otherwise hand back the value
|
|
710
|
+
with its type, so a comparison that worked there would fail on the other two;
|
|
711
|
+
a number is compared through a `cast` on all three:
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
Post.where { :meta.dig(:n) == '5' }
|
|
715
|
+
Post.where { cast(:meta.dig(:n), 'integer') > 6 } # 'signed' on MySQL
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
The type is the adapter's own name for it, here as everywhere `cast` is used.
|
|
719
|
+
|
|
720
|
+
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
721
|
+
the path to it. The document comes back changed rather than being written
|
|
722
|
+
anywhere, so `update_all` is what makes it stick:
|
|
723
|
+
|
|
724
|
+
```ruby
|
|
725
|
+
Post.update_all { { meta: :meta.bury(:author, :name, 'alice') } }
|
|
726
|
+
# SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
|
|
727
|
+
# ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
|
|
728
|
+
|
|
729
|
+
Post.update_all { { meta: :meta.bury(:tags, ['ruby', 'sql']) } }
|
|
730
|
+
Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
A whole document goes in as one — an object or an array rather than the string
|
|
734
|
+
that spells it — which each adapter takes its own way round. `bury` is not a
|
|
735
|
+
Ruby method; it is the name Ruby considered for the other end of `dig`, and
|
|
736
|
+
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
737
|
+
the others say `JSON_SET`.
|
|
738
|
+
|
|
739
|
+
`dig_json` keeps the JSON, for a document to be dug into further or compared
|
|
740
|
+
whole. `contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
741
|
+
there — later than the rest, since the adapter is only known when the SQL is
|
|
742
|
+
built. On PostgreSQL, `contains?` and `key?` want a `jsonb` column; the
|
|
743
|
+
`json` type carries neither operator.
|
|
744
|
+
|
|
745
|
+
A key that is not a plain name travels as itself rather than being refused:
|
|
746
|
+
`dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
|
|
747
|
+
|
|
748
|
+
### Window functions
|
|
749
|
+
|
|
750
|
+
`over` gives a function a window, which is what turns an aggregate into a
|
|
751
|
+
running one and the only thing `row_number` and its kind can be used with.
|
|
752
|
+
The window is built by chaining, as Arel's own is:
|
|
753
|
+
|
|
754
|
+
```ruby
|
|
755
|
+
Author.select { avg(:age).over.partition(:country).as(:country_average) }
|
|
756
|
+
# AVG("age") OVER (PARTITION BY "country") AS country_average
|
|
757
|
+
|
|
758
|
+
Author.select { row_number.over.partition(:country).order(:age.desc).as(:rank) }
|
|
759
|
+
# ROW_NUMBER() OVER (PARTITION BY "country" ORDER BY "age" DESC) AS rank
|
|
760
|
+
|
|
761
|
+
Author.select { count(:*).over.as(:total) } # COUNT(*) OVER () — every row
|
|
375
762
|
```
|
|
376
763
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
764
|
+
`row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`,
|
|
765
|
+
`lag`, `lead`, `first_value`, `last_value` and `nth_value` are the functions
|
|
766
|
+
that say nothing without a window; each raises `ArgumentError` if `over` never
|
|
767
|
+
arrives, rather than reaching the database as an error there. Every adapter
|
|
768
|
+
that has window functions at all spells them the same way, so unlike the
|
|
769
|
+
scalar functions there is nothing here to translate.
|
|
770
|
+
|
|
771
|
+
A frame is a range of rows counted from the current one — negative before it,
|
|
772
|
+
positive after, 0 the row itself, and an open end for unbounded:
|
|
773
|
+
|
|
774
|
+
```ruby
|
|
775
|
+
Post.select { sum(:likes).over.order(:created_at).rows(..0).as(:running) }
|
|
776
|
+
# SUM("likes") OVER (ORDER BY "created_at" ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
|
|
777
|
+
|
|
778
|
+
Post.select { avg(:likes).over.order(:created_at).rows(-1..1).as(:smoothed) }
|
|
779
|
+
# ... ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
|
|
780
|
+
|
|
781
|
+
Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
|
|
782
|
+
# ... ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
`range` says `RANGE` where `rows` says `ROWS`, and a window has one frame or
|
|
786
|
+
none. Named windows — `WINDOW w AS (...)` — have no clause in ActiveRecord to
|
|
787
|
+
live in, so they are not here.
|
|
788
|
+
|
|
789
|
+
### Aliases and ordering
|
|
790
|
+
|
|
791
|
+
`.as` gives an expression a column alias, and `.asc` / `.desc` give an
|
|
792
|
+
ordering its direction. The orderings take `.nulls_first` / `.nulls_last` as
|
|
793
|
+
well. MySQL has no such syntax, but Arel emulates it there, so the resulting
|
|
794
|
+
order is the same everywhere:
|
|
380
795
|
|
|
381
796
|
```ruby
|
|
382
797
|
Author.order { :country.asc.nulls_last }
|
|
383
798
|
```
|
|
384
799
|
|
|
800
|
+
Together:
|
|
801
|
+
|
|
385
802
|
```ruby
|
|
386
803
|
Author.
|
|
387
804
|
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
@@ -398,21 +815,38 @@ Author.
|
|
|
398
815
|
}
|
|
399
816
|
```
|
|
400
817
|
|
|
401
|
-
|
|
818
|
+
### Writing
|
|
819
|
+
|
|
820
|
+
`update_all` reads its hash the way ActiveRecord does — `update_all(likes: :likes)`
|
|
821
|
+
sets the column to the symbol itself. The block reads a symbol as the column it
|
|
822
|
+
names, as every other block here does, which is what lets the new value be
|
|
823
|
+
worked out from the old:
|
|
824
|
+
|
|
825
|
+
```ruby
|
|
826
|
+
Post.where { :published == true }.update_all { { likes: :likes + 1 } }
|
|
827
|
+
# UPDATE "posts" SET "likes" = ("posts"."likes" + 1) WHERE ...
|
|
828
|
+
|
|
829
|
+
Post.update_all { { title: upper(:title), likes: case_when { :likes < 0 }.then(0).else(:likes) } }
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
`upsert_all` takes one too, for the part that decides what happens to a row
|
|
833
|
+
that is already there. `excluded` is the row that could not be inserted:
|
|
834
|
+
|
|
835
|
+
```ruby
|
|
836
|
+
Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
837
|
+
# ... ON CONFLICT ("page") DO UPDATE SET "hits"=("tallies"."hits" + "excluded"."hits")
|
|
838
|
+
```
|
|
402
839
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
840
|
+
PostgreSQL and SQLite name that row `excluded`; MySQL spells the same thing
|
|
841
|
+
`VALUES(column)`, and the block comes out as whichever the adapter reads.
|
|
842
|
+
ActiveRecord's own `on_duplicate:` takes SQL text and nothing else, so this is
|
|
843
|
+
the one place the DSL writes SQL out itself rather than handing Arel a tree —
|
|
844
|
+
and the two cannot both be given.
|
|
406
845
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
| `expressions.rb` | arithmetic, aggregates, functions, `NULLS LAST` |
|
|
412
|
-
| `complex_joins.rb` | compound `ON` clauses, outer joins, a self join |
|
|
413
|
-
| `aggregations.rb` | `GROUP BY`, `HAVING` and aggregates across joins |
|
|
414
|
-
| `ctes.rb` | `with` and `with_recursive` |
|
|
415
|
-
| `postgresql.rb` | array columns, regular expressions, `ILIKE` (needs a server) |
|
|
846
|
+
`insert_all` has no block: its values are literals by construction.
|
|
847
|
+
ActiveRecord type-casts each one on the way into the `VALUES` list, so an
|
|
848
|
+
expression does not become SQL there — it becomes nothing, silently. Use
|
|
849
|
+
`upsert_all` where a row's value has to be worked out.
|
|
416
850
|
|
|
417
851
|
## Performance
|
|
418
852
|
|
|
@@ -13,8 +13,9 @@ Gem::Specification.new do |gem|
|
|
|
13
13
|
gem.homepage = 'https://github.com/shugo/activerecord-refined'
|
|
14
14
|
|
|
15
15
|
# sandbox/ is a site, not part of the library: its Gemfile.lock and
|
|
16
|
-
# package-lock.json have no business in anyone's bundle.
|
|
17
|
-
|
|
16
|
+
# package-lock.json have no business in anyone's bundle. CLAUDE.md is
|
|
17
|
+
# addressed to whoever is working on the repository, not to anyone using it.
|
|
18
|
+
gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$})
|
|
18
19
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
19
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
20
21
|
gem.require_paths = ["lib"]
|