activerecord-refined 0.5.1 → 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 +161 -25
- data/.github/workflows/test.yml +15 -2
- data/README.md +412 -49
- data/examples/ctes.rb +3 -3
- 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 +776 -92
- data/lib/active_record/refined.rb +317 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +5 -2
- data/test/test_block_syntax.rb +949 -60
- data/test/test_helper.rb +82 -0
- metadata +4 -1
data/README.md
CHANGED
|
@@ -105,6 +105,23 @@ select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
|
105
105
|
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
106
106
|
age > 40`, which is again the same rows.
|
|
107
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
|
+
|
|
108
125
|
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
109
126
|
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
110
127
|
`where(id: relation)` does:
|
|
@@ -123,6 +140,24 @@ Author.where { :age >= Author.select { avg(:age) } }
|
|
|
123
140
|
# "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
|
|
124
141
|
```
|
|
125
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
|
+
|
|
126
161
|
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
127
162
|
subquery with the outer table through qualified columns — its `where` block
|
|
128
163
|
goes through the DSL like any other:
|
|
@@ -259,6 +294,82 @@ Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manage
|
|
|
259
294
|
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
260
295
|
```
|
|
261
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
|
+
|
|
262
373
|
### Common table expressions
|
|
263
374
|
|
|
264
375
|
ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
@@ -274,8 +385,7 @@ Node.with_recursive(
|
|
|
274
385
|
Node.where { :id == root.id }.
|
|
275
386
|
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
276
387
|
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
277
|
-
select { [:
|
|
278
|
-
(:tree[:depth] + 1).as(:depth)] },
|
|
388
|
+
select { [:id, :name, :parent_id, (:tree[:depth] + 1).as(:depth)] },
|
|
279
389
|
]
|
|
280
390
|
).from_cte(:tree)
|
|
281
391
|
# WITH RECURSIVE "tree" AS (
|
|
@@ -304,8 +414,13 @@ Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
|
304
414
|
```
|
|
305
415
|
|
|
306
416
|
Since the model's name is the only one that works, `from_cte` takes it from
|
|
307
|
-
the model rather than asking.
|
|
308
|
-
|
|
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.
|
|
309
424
|
|
|
310
425
|
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
311
426
|
`order` and `select` never qualify, so they work without the alias on every
|
|
@@ -321,19 +436,46 @@ Node.with(roots: Node.where { :parent_id.null? }).
|
|
|
321
436
|
|
|
322
437
|
`examples/ctes.rb` walks a category tree with these.
|
|
323
438
|
|
|
324
|
-
### Aggregates
|
|
439
|
+
### Aggregates and functions
|
|
325
440
|
|
|
326
441
|
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
327
|
-
scalar functions below, with `fn` for anything else.
|
|
328
|
-
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
|
|
329
443
|
or order by multiple expressions.
|
|
330
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
|
+
|
|
331
472
|
The scalar functions are real methods rather than anything caught dynamically,
|
|
332
473
|
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
333
474
|
answers to — `rand` — means the SQL one inside a block:
|
|
334
475
|
|
|
335
476
|
```
|
|
336
|
-
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
|
|
337
479
|
cos current_date current_time current_timestamp date_trunc degrees
|
|
338
480
|
exp extract floor format greatest least length ln localtime
|
|
339
481
|
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
@@ -348,8 +490,51 @@ MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
|
348
490
|
insists on the second argument the others default to zero — SQLite's takes
|
|
349
491
|
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
350
492
|
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
351
|
-
whose spelling is `log(2, x)` — the block raises
|
|
352
|
-
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.
|
|
353
538
|
|
|
354
539
|
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
355
540
|
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
@@ -381,30 +566,63 @@ Post.select { cast(:price, 'decimal(10,2)').as(:price) }
|
|
|
381
566
|
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
382
567
|
```
|
|
383
568
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
569
|
+
### Expressions
|
|
570
|
+
|
|
571
|
+
`+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
|
|
572
|
+
operators, so an expression groups the way it reads:
|
|
388
573
|
|
|
389
574
|
```ruby
|
|
390
|
-
|
|
575
|
+
Item.where { :price * :quantity > 1000 }
|
|
576
|
+
Item.select { sum(:price * :quantity).as(:total) }
|
|
391
577
|
```
|
|
392
578
|
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
395
582
|
|
|
396
583
|
```ruby
|
|
397
|
-
|
|
398
|
-
#
|
|
584
|
+
Post.where { :flags & 4 > 0 }
|
|
585
|
+
# WHERE ("posts"."flags" & 4) > 0
|
|
399
586
|
|
|
400
|
-
Post.select {
|
|
587
|
+
Post.select { (:flags | 4).as(:flags) }
|
|
588
|
+
Post.select { (~:flags).as(:inverted) }
|
|
401
589
|
```
|
|
402
590
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
594
|
+
|
|
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:
|
|
613
|
+
|
|
614
|
+
```ruby
|
|
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)))
|
|
621
|
+
```
|
|
622
|
+
|
|
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.
|
|
408
626
|
|
|
409
627
|
One place asks for a value to be said out loud: the top of a select list.
|
|
410
628
|
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
@@ -429,30 +647,158 @@ SQL in a select list, a value everywhere else — and refining it would make the
|
|
|
429
647
|
same literal mean one thing or the other depending on whether it had been sent
|
|
430
648
|
a message.
|
|
431
649
|
|
|
432
|
-
`
|
|
433
|
-
|
|
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:
|
|
434
655
|
|
|
435
656
|
```ruby
|
|
436
|
-
|
|
437
|
-
#
|
|
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) }
|
|
438
664
|
```
|
|
439
665
|
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
442
672
|
|
|
443
673
|
```ruby
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
446
716
|
```
|
|
447
717
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
762
|
+
```
|
|
763
|
+
|
|
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:
|
|
451
795
|
|
|
452
796
|
```ruby
|
|
453
797
|
Author.order { :country.asc.nulls_last }
|
|
454
798
|
```
|
|
455
799
|
|
|
800
|
+
Together:
|
|
801
|
+
|
|
456
802
|
```ruby
|
|
457
803
|
Author.
|
|
458
804
|
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
@@ -469,21 +815,38 @@ Author.
|
|
|
469
815
|
}
|
|
470
816
|
```
|
|
471
817
|
|
|
472
|
-
|
|
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
|
+
```
|
|
473
839
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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.
|
|
477
845
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
| `expressions.rb` | arithmetic, aggregates, functions, `NULLS LAST` |
|
|
483
|
-
| `complex_joins.rb` | compound `ON` clauses, outer joins, a self join |
|
|
484
|
-
| `aggregations.rb` | `GROUP BY`, `HAVING` and aggregates across joins |
|
|
485
|
-
| `ctes.rb` | `with` and `with_recursive` |
|
|
486
|
-
| `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.
|
|
487
850
|
|
|
488
851
|
## Performance
|
|
489
852
|
|
data/examples/ctes.rb
CHANGED
|
@@ -63,7 +63,7 @@ forest =
|
|
|
63
63
|
Category.where { :parent_id.null? }.
|
|
64
64
|
select { [:id, :name, :parent_id, :id.as(:root_id), 0.as(:depth)] },
|
|
65
65
|
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] }.
|
|
66
|
-
select { [:
|
|
66
|
+
select { [:id, :name, :parent_id,
|
|
67
67
|
:tree[:root_id], (:tree[:depth] + 1).as(:depth)] },
|
|
68
68
|
]
|
|
69
69
|
).from_cte(:tree).order { [:depth, :id] }
|
|
@@ -98,10 +98,10 @@ puts
|
|
|
98
98
|
expensive =
|
|
99
99
|
Category.with(pricey: Product.where { :price >= 100 }).
|
|
100
100
|
joins(:pricey) { :pricey[:category_id] == :categories[:id] }.
|
|
101
|
-
group { :
|
|
101
|
+
group { :id }.
|
|
102
102
|
select {
|
|
103
103
|
[
|
|
104
|
-
:
|
|
104
|
+
:name.as(:category),
|
|
105
105
|
count(:pricey[:id]).as(:pricey_count),
|
|
106
106
|
max(:pricey[:price]).as(:top_price),
|
|
107
107
|
]
|