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.
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
- `from` takes the CTE's name as a symbol, with `as` to select it under the
251
- model's own table name so the model's columns resolve:
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
- Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
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
- ).from(:tree, as: :nodes)
390
+ ).from_cte(:tree)
260
391
  # WITH RECURSIVE "tree" AS (
261
- # SELECT "nodes".* FROM "nodes" WHERE "nodes"."id" = 1
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".* FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
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, functions and aliases
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. Use `.as` for a column
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 cast ceil char_length coalesce concat
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 `NotImplementedError`
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
- `format` is printf formatting, and raises on MySQL, where a function of the
337
- same name does something else entirely: it puts separators in a number, and
338
- reads a printf template as the number zero rather than complaining. `fn` still
339
- reaches it, spelled as the different thing it is:
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
- Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
575
+ Item.where { :price * :quantity > 1000 }
576
+ Item.select { sum(:price * :quantity).as(:total) }
343
577
  ```
344
578
 
345
- Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
346
- `COUNT(DISTINCT ...)`:
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
- Author.group { :country }.having { count(:*) > 1 }
350
- # SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
584
+ Post.where { :flags & 4 > 0 }
585
+ # WHERE ("posts"."flags" & 4) > 0
351
586
 
352
- Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
587
+ Post.select { (:flags | 4).as(:flags) }
588
+ Post.select { (~:flags).as(:inverted) }
353
589
  ```
354
590
 
355
- Values are quoted by the adapter wherever they appear, as they are in
356
- ActiveRecord. Column aliases and `fn`'s function name are not they are
357
- written into the SQL as given — so those two have to be plain names,
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
- `fn` reaches functions without a method of their own. Its name is emitted as
362
- written, so a case-sensitive one can be spelled exactly:
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.select { fn(:date_trunc, 'day', :created_at).as(:day) }
366
- # SELECT date_trunc('day', "posts"."created_at") AS day
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
- `+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
370
- operators, so an expression groups the way it reads:
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
- Item.where { :price * :quantity > 1000 }
374
- Item.select { sum(:price * :quantity).as(:total) }
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
- `.asc` and `.desc` take `.nulls_first` / `.nulls_last`. MySQL has no such
378
- syntax, but Arel emulates it there, so the resulting order is the same
379
- everywhere:
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
- ## Examples
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
- `examples/` holds runnable scripts, each printing the SQL it builds and, where
404
- the result is the point, the rows that come back. All but the last run against
405
- an in-memory SQLite database and need no setup.
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
- | `predicates.rb` | the `where` vocabulary: ranges, sets, NULL, text matching |
410
- | `subqueries.rb` | `in?` with a relation, `exists?`, scalar subqueries |
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
- gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/})
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"]