activerecord-refined 0.6.1 → 0.8.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 +1 -1
- data/README.md +114 -35
- data/activerecord-refined.gemspec +4 -2
- data/benchmark/query_building.rb +1 -1
- data/examples/complex_joins.rb +30 -0
- data/examples/ctes.rb +2 -2
- data/examples/json.rb +45 -17
- data/examples/postgresql.rb +1 -1
- data/examples/subqueries.rb +1 -1
- data/examples/windows.rb +1 -1
- data/examples/writes.rb +4 -4
- data/lib/active_record/refined/ast.rb +155 -20
- data/lib/active_record/refined.rb +94 -13
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +5 -3
- data/test/test_block_syntax.rb +214 -29
- data/test/test_helper.rb +6 -1
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaf1859c70b9ca6e5c245343e664fffbd483d395266a4d442eaa0d5c4107595c
|
|
4
|
+
data.tar.gz: da1ddb190628a8a11ced1d105d0a73bf164892cf9e78dfd68db7fdf135047d95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b075b93194651859a7411d2465fec0c2d0a504eaf9da7d4d296ddea1cc91a366af68fea0b37cbea230e44da9e4bf480ecfd047571c1b49601b9bfa7c3dc2780
|
|
7
|
+
data.tar.gz: c35194020d37a97de9f5816b965f391a47c5e3d12ef7192bfd2c91ff16ea72689e122fc29daba3c6096aceac22dd7fbb535d562fe0c0ba2f3edbcbbb9904c22c
|
|
@@ -128,7 +128,7 @@ jobs:
|
|
|
128
128
|
run: ./bin/prepare-rb
|
|
129
129
|
|
|
130
130
|
# Runs every example through the same WASI shim the page uses. The
|
|
131
|
-
# larger stack is needed to compile
|
|
131
|
+
# larger stack is needed to compile Active Record's relation.rb; see the
|
|
132
132
|
# note in sandbox/README.md.
|
|
133
133
|
- name: Check the examples
|
|
134
134
|
run: node --stack-size=4000 check-examples.mjs
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/activerecord-refined)
|
|
4
4
|
[](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml)
|
|
5
5
|
|
|
6
|
-
Adding clean and powerful query syntax on
|
|
6
|
+
Adding clean and powerful query syntax on Active Record using refinements.
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
9
|
Author.
|
|
@@ -15,7 +15,7 @@ Author.
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
**[Try it in your browser](https://shugo.github.io/activerecord-refined/)** —
|
|
18
|
-
Ruby 4.1,
|
|
18
|
+
Ruby 4.1, Active Record, SQLite and PostgreSQL run in the page, so the examples
|
|
19
19
|
build real SQL and return real rows without a `ruby-master` build of your own.
|
|
20
20
|
|
|
21
21
|
## History
|
|
@@ -43,7 +43,7 @@ works again without monkey-patching `Symbol` globally.
|
|
|
43
43
|
## Requirements
|
|
44
44
|
|
|
45
45
|
* Ruby 4.1 or later (for `Proc#refined`; not released yet, so a `ruby-master` build is needed for now)
|
|
46
|
-
*
|
|
46
|
+
* Active Record 7.0 or later
|
|
47
47
|
|
|
48
48
|
The [sandbox](https://shugo.github.io/activerecord-refined/) is there to skip
|
|
49
49
|
that build: it carries its own Ruby 4.1. `sandbox/` in this repository is what
|
|
@@ -123,7 +123,7 @@ answers false there. `not_true?` is therefore "false or never set" and
|
|
|
123
123
|
way and answers them alike.
|
|
124
124
|
|
|
125
125
|
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
126
|
-
subquery selects the relation's primary key, the same way
|
|
126
|
+
subquery selects the relation's primary key, the same way Active Record's own
|
|
127
127
|
`where(id: relation)` does:
|
|
128
128
|
|
|
129
129
|
```ruby
|
|
@@ -245,7 +245,7 @@ database can express what you mean.
|
|
|
245
245
|
|
|
246
246
|
`==` always means SQL `=`, and passes its value through untouched. A Range or an
|
|
247
247
|
Array therefore compares against a PostgreSQL range or array column, the same
|
|
248
|
-
way
|
|
248
|
+
way Active Record's own `where(period: from...to)` does for those column types:
|
|
249
249
|
|
|
250
250
|
```ruby
|
|
251
251
|
Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
@@ -294,6 +294,27 @@ Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manage
|
|
|
294
294
|
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
`right_outer_joins` and `full_outer_joins` are the two Active Record has no
|
|
298
|
+
method for, and they take what `joins` takes. An association name is not among
|
|
299
|
+
it: what Active Record reads out of one is an inner or a left join and nothing
|
|
300
|
+
else, so these two want the block that says how to join.
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
304
|
+
Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
MySQL has no `FULL OUTER JOIN` and neither has MariaDB, so `full_outer_joins`
|
|
308
|
+
raises `NotImplementedError` there. SQLite has had one since 3.39.
|
|
309
|
+
|
|
310
|
+
`cross_joins` is every row of one table against every row of the other. There
|
|
311
|
+
is no condition to give, so it takes no block — `as` still names the table:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
Post.cross_joins(:authors) # FROM "posts" CROSS JOIN "authors"
|
|
315
|
+
Post.cross_joins(:posts, as: :others) # FROM "posts" CROSS JOIN "posts" "others"
|
|
316
|
+
```
|
|
317
|
+
|
|
297
318
|
### Keeping one row per group
|
|
298
319
|
|
|
299
320
|
`distinct_on` is PostgreSQL's `DISTINCT ON`: the first row of each group the
|
|
@@ -318,7 +339,7 @@ Post.from(ranked, :posts).where { :rn == 1 }
|
|
|
318
339
|
```
|
|
319
340
|
|
|
320
341
|
The subquery is named after the model's own table for the reason `from_cte`
|
|
321
|
-
is:
|
|
342
|
+
is: Active Record goes on qualifying columns with that name, so `where` needs
|
|
322
343
|
to find it.
|
|
323
344
|
|
|
324
345
|
### Grouping several ways at once
|
|
@@ -345,16 +366,17 @@ the block raises `NotImplementedError` on both.
|
|
|
345
366
|
|
|
346
367
|
### Lateral joins
|
|
347
368
|
|
|
348
|
-
`lateral
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
A relation marked `lateral` joins in place of a table, and sees the row being
|
|
370
|
+
joined to — in SQL the keyword modifies the subquery, not the join, so that is
|
|
371
|
+
where it is written. It is what makes the top row of each group reachable in
|
|
372
|
+
one query:
|
|
351
373
|
|
|
352
374
|
```ruby
|
|
353
375
|
top_post = Post.select { :title }.
|
|
354
376
|
where { :posts[:author_id] == :authors[:id] }.
|
|
355
377
|
order { :likes.desc }.limit(1)
|
|
356
378
|
|
|
357
|
-
Author.left_outer_joins(top_post, as: :top
|
|
379
|
+
Author.left_outer_joins(top_post.lateral, as: :top).
|
|
358
380
|
select { [:name, :top[:title].as(:top_post)] }
|
|
359
381
|
# SELECT "name", "top"."title" AS "top_post" FROM "authors"
|
|
360
382
|
# LEFT OUTER JOIN LATERAL (SELECT "title" FROM "posts"
|
|
@@ -372,7 +394,7 @@ writes it, so the SQL is written here instead.
|
|
|
372
394
|
|
|
373
395
|
### Common table expressions
|
|
374
396
|
|
|
375
|
-
|
|
397
|
+
Active Record's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
376
398
|
is joined by name like any other table, so its `ON` clause is a block, where
|
|
377
399
|
Rails' own documentation reaches for a string join.
|
|
378
400
|
|
|
@@ -403,8 +425,8 @@ the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
|
403
425
|
SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
|
|
404
426
|
can say `.as` directly.
|
|
405
427
|
|
|
406
|
-
The alias on the last line is there for
|
|
407
|
-
written by hand that line would be `SELECT * FROM tree`.
|
|
428
|
+
The alias on the last line is there for Active Record's sake, not SQL's:
|
|
429
|
+
written by hand that line would be `SELECT * FROM tree`. Active Record goes on qualifying
|
|
408
430
|
columns with the model's table name, so without the alias that name is not in
|
|
409
431
|
the query and anything qualifying a column fails:
|
|
410
432
|
|
|
@@ -511,7 +533,7 @@ Post.select { fn(:date_trunc, 'day', :created_at).as(:day) }
|
|
|
511
533
|
```
|
|
512
534
|
|
|
513
535
|
Values are quoted by the adapter wherever they appear, as they are in
|
|
514
|
-
|
|
536
|
+
Active Record, and so is a column alias. That is what makes the name asked for
|
|
515
537
|
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
516
538
|
other two keep it, so one block would mean two things. It also leaves nothing
|
|
517
539
|
to refuse — a name that would have been SQL becomes an identifier with a
|
|
@@ -626,7 +648,7 @@ number, the others as a negative one, and the bits are the same either way.
|
|
|
626
648
|
|
|
627
649
|
One place asks for a value to be said out loud: the top of a select list.
|
|
628
650
|
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
629
|
-
`concat(:name, '-x')` — but
|
|
651
|
+
`concat(:name, '-x')` — but Active Record reads a string in `select` as SQL,
|
|
630
652
|
so `value` is how you ask for the other meaning. It carries the predications
|
|
631
653
|
and arithmetic with it, so a literal can be compared and combined like
|
|
632
654
|
anything else. Numbers have a shorthand, since nothing else could be meant by
|
|
@@ -684,11 +706,14 @@ Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
|
684
706
|
### JSON
|
|
685
707
|
|
|
686
708
|
`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
|
|
709
|
+
or symbol steps into an object, an integer into an array, and what comes back
|
|
710
|
+
is still JSON — the way `Hash#dig` hands back the structure itself — for a
|
|
711
|
+
document to be dug into further or asked the JSON questions. `dig_text` gives
|
|
712
|
+
the value as text instead, which is what a comparison wants:
|
|
688
713
|
|
|
689
714
|
```ruby
|
|
690
|
-
Post.where { :meta.
|
|
691
|
-
Post.select { :meta.dig(:
|
|
715
|
+
Post.where { :meta.dig_text(:author, :name) == 'alice' }
|
|
716
|
+
Post.select { :meta.dig(:author).as(:author) }
|
|
692
717
|
Post.where { :meta.key?(:draft) }
|
|
693
718
|
Post.where { :meta.contains?(status: 'open') }
|
|
694
719
|
```
|
|
@@ -698,25 +723,41 @@ three:
|
|
|
698
723
|
|
|
699
724
|
| | PostgreSQL | SQLite | MySQL |
|
|
700
725
|
| --- | --- | --- | --- |
|
|
701
|
-
| `dig(:a
|
|
702
|
-
| `
|
|
726
|
+
| `dig(:a)` | `#> '{a}'` | `-> '$.a'` | `JSON_EXTRACT(…, '$.a')` |
|
|
727
|
+
| `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
703
728
|
| `key?(:a)` | `jsonb_exists(…, 'a')` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
704
729
|
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
705
730
|
|
|
706
731
|
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
707
732
|
MySQL family goes through the functions, which both have.
|
|
708
733
|
|
|
709
|
-
`
|
|
710
|
-
with its type, so a comparison that worked there would fail on the other
|
|
711
|
-
a number is compared through a `cast` on all three:
|
|
734
|
+
`dig_text` gives text everywhere. SQLite's `->>` would otherwise hand back the
|
|
735
|
+
value with its type, so a comparison that worked there would fail on the other
|
|
736
|
+
two; a number is compared through a `cast` on all three:
|
|
712
737
|
|
|
713
738
|
```ruby
|
|
714
|
-
Post.where { :meta.
|
|
715
|
-
Post.where { cast(:meta.
|
|
739
|
+
Post.where { :meta.dig_text(:n) == '5' }
|
|
740
|
+
Post.where { cast(:meta.dig_text(:n), 'integer') > 6 } # 'signed' on MySQL
|
|
716
741
|
```
|
|
717
742
|
|
|
718
743
|
The type is the adapter's own name for it, here as everywhere `cast` is used.
|
|
719
744
|
|
|
745
|
+
Strings and numbers are where the adapters agree. A JSON boolean comes back as
|
|
746
|
+
`"1"` on SQLite, which turns `true` into SQL's `1` before the text cast, and
|
|
747
|
+
as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
|
|
748
|
+
MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
|
|
749
|
+
three.
|
|
750
|
+
|
|
751
|
+
Comparing a dug value with anything but a string raises `ArgumentError` rather
|
|
752
|
+
than being left to the adapters, which answer it three ways: `dig_text(:n) ==
|
|
753
|
+
5` is true on SQLite, an error on PostgreSQL and true on MySQL, and
|
|
754
|
+
`dig_text(:flag) == true` is true, an error and false. `cast` is what says
|
|
755
|
+
which type was meant, and then all three agree. `dig` is refused the other way
|
|
756
|
+
about — the JSON for a string carries its quotes, so `dig(:name) == 'alice'`
|
|
757
|
+
is false, an error and true — and `dig_text` is the one that gives the value.
|
|
758
|
+
A column, a function or another dug value on the right goes through untouched;
|
|
759
|
+
only a Ruby literal is refused.
|
|
760
|
+
|
|
720
761
|
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
721
762
|
the path to it. The document comes back changed rather than being written
|
|
722
763
|
anywhere, so `update_all` is what makes it stick:
|
|
@@ -727,7 +768,7 @@ Post.update_all { { meta: :meta.bury(:author, :name, 'alice') } }
|
|
|
727
768
|
# ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
|
|
728
769
|
|
|
729
770
|
Post.update_all { { meta: :meta.bury(:tags, ['ruby', 'sql']) } }
|
|
730
|
-
Post.update_all { { meta: :meta.bury(:copy, :meta.
|
|
771
|
+
Post.update_all { { meta: :meta.bury(:copy, :meta.dig_text(:n)) } }
|
|
731
772
|
```
|
|
732
773
|
|
|
733
774
|
A whole document goes in as one — an object or an array rather than the string
|
|
@@ -736,8 +777,46 @@ Ruby method; it is the name Ruby considered for the other end of `dig`, and
|
|
|
736
777
|
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
737
778
|
the others say `JSON_SET`.
|
|
738
779
|
|
|
739
|
-
`
|
|
740
|
-
|
|
780
|
+
`except` takes keys out again, and takes them as `Hash#except` does — keys of
|
|
781
|
+
the document, however many, rather than a path, which is `bury`'s way of
|
|
782
|
+
reaching further in. It gives back the document changed, so it chains with
|
|
783
|
+
`bury` and goes where `bury` goes:
|
|
784
|
+
|
|
785
|
+
```ruby
|
|
786
|
+
Post.update_all { { meta: :meta.except(:draft) } }
|
|
787
|
+
# SET "meta" = "meta" - CAST('{"draft"}' AS text[])
|
|
788
|
+
# ... JSON_REMOVE("meta", '$.draft') elsewhere
|
|
789
|
+
|
|
790
|
+
Post.update_all { { meta: :meta.bury(:author, :name, 'alice').except(:tmp) } }
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
A key that is not there is not an error, as it is not to `Hash#except`. The
|
|
794
|
+
cast is not decoration: `jsonb` has three subtractions — a key, an array of
|
|
795
|
+
keys, an element by index — and an array literal written without a type is
|
|
796
|
+
read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
|
|
797
|
+
`{draft}`, which is nothing, and says nothing about it.
|
|
798
|
+
|
|
799
|
+
What `dig` gives is a document, so the JSON operations read it — the same
|
|
800
|
+
question asked of a part of the document rather than of all of it:
|
|
801
|
+
|
|
802
|
+
```ruby
|
|
803
|
+
Post.where { :meta.dig(:author).key?(:email) }
|
|
804
|
+
Post.where { :meta.dig(:author).dig_text(:name) == 'alice' }
|
|
805
|
+
Post.update_all { { meta: :meta.dig(:author).bury(:name, 'alice') } }
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
Containment reads it too, on the adapters that have containment at all:
|
|
809
|
+
|
|
810
|
+
```ruby
|
|
811
|
+
Post.where { :meta.dig(:tags).contains?(['ruby']) }
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
|
|
815
|
+
and reading text back as a document is where the adapters part company —
|
|
816
|
+
SQLite parses it, MySQL takes it as written, and PostgreSQL has no such
|
|
817
|
+
function for text at all.
|
|
818
|
+
|
|
819
|
+
`contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
741
820
|
there — later than the rest, since the adapter is only known when the SQL is
|
|
742
821
|
built. On PostgreSQL, `contains?` and `key?` want a `jsonb` column; the
|
|
743
822
|
`json` type carries neither operator.
|
|
@@ -783,7 +862,7 @@ Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
|
|
|
783
862
|
```
|
|
784
863
|
|
|
785
864
|
`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
|
|
865
|
+
none. Named windows — `WINDOW w AS (...)` — have no clause in Active Record to
|
|
787
866
|
live in, so they are not here.
|
|
788
867
|
|
|
789
868
|
### Aliases and ordering
|
|
@@ -817,7 +896,7 @@ Author.
|
|
|
817
896
|
|
|
818
897
|
### Writing
|
|
819
898
|
|
|
820
|
-
`update_all` reads its hash the way
|
|
899
|
+
`update_all` reads its hash the way Active Record does — `update_all(likes: :likes)`
|
|
821
900
|
sets the column to the symbol itself. The block reads a symbol as the column it
|
|
822
901
|
names, as every other block here does, which is what lets the new value be
|
|
823
902
|
worked out from the old:
|
|
@@ -839,23 +918,23 @@ Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
|
839
918
|
|
|
840
919
|
PostgreSQL and SQLite name that row `excluded`; MySQL spells the same thing
|
|
841
920
|
`VALUES(column)`, and the block comes out as whichever the adapter reads.
|
|
842
|
-
|
|
921
|
+
Active Record's own `on_duplicate:` takes SQL text and nothing else, so this is
|
|
843
922
|
the one place the DSL writes SQL out itself rather than handing Arel a tree —
|
|
844
923
|
and the two cannot both be given.
|
|
845
924
|
|
|
846
925
|
`insert_all` has no block: its values are literals by construction.
|
|
847
|
-
|
|
926
|
+
Active Record type-casts each one on the way into the `VALUES` list, so an
|
|
848
927
|
expression does not become SQL there — it becomes nothing, silently. Use
|
|
849
928
|
`upsert_all` where a row's value has to be worked out.
|
|
850
929
|
|
|
851
930
|
## Performance
|
|
852
931
|
|
|
853
932
|
`benchmark/query_building.rb` compares building the same queries through the
|
|
854
|
-
block DSL and through
|
|
933
|
+
block DSL and through Active Record's other argument styles. Only query
|
|
855
934
|
construction (through `to_sql`) is measured — every style produces the same
|
|
856
935
|
SQL, so execution costs the same regardless.
|
|
857
936
|
|
|
858
|
-
Queries built per second (ruby 4.1.0dev,
|
|
937
|
+
Queries built per second (ruby 4.1.0dev, Active Record 8.1.3, one machine —
|
|
859
938
|
treat the ratios, not the absolute numbers, as the result):
|
|
860
939
|
|
|
861
940
|
| query | string | arel | block (this gem) | hash | relation and/or |
|
|
@@ -930,7 +1009,7 @@ and publishes it through RubyGems.org's trusted publishing, so no API key is
|
|
|
930
1009
|
stored anywhere.
|
|
931
1010
|
|
|
932
1011
|
```sh
|
|
933
|
-
bump patch --tag # or bump {major,minor} etc.
|
|
1012
|
+
bundle exec bump patch --tag # or bump {major,minor} etc.
|
|
934
1013
|
git push --follow-tags
|
|
935
1014
|
```
|
|
936
1015
|
|
|
@@ -8,8 +8,8 @@ Gem::Specification.new do |gem|
|
|
|
8
8
|
gem.version = Activerecord::Refined::VERSION
|
|
9
9
|
gem.authors = ["Shugo Maeda"]
|
|
10
10
|
gem.email = ["shugo@ruby-lang.org"]
|
|
11
|
-
gem.description = 'Adding clean and powerful query syntax on
|
|
12
|
-
gem.summary = '
|
|
11
|
+
gem.description = 'Adding clean and powerful query syntax on Active Record using refinements'
|
|
12
|
+
gem.summary = 'Write Active Record queries as Ruby expressions'
|
|
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
|
|
@@ -28,4 +28,6 @@ Gem::Specification.new do |gem|
|
|
|
28
28
|
gem.add_development_dependency 'sqlite3', ['>= 0']
|
|
29
29
|
gem.add_development_dependency 'minitest', ['>= 0']
|
|
30
30
|
gem.add_development_dependency 'rake', ['>= 0']
|
|
31
|
+
# What cuts a release: `bump patch --tag`, then push with --follow-tags.
|
|
32
|
+
gem.add_development_dependency 'bump', ['>= 0']
|
|
31
33
|
end
|
data/benchmark/query_building.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Compares the cost of building the same queries through this gem's block
|
|
2
|
-
# DSL and through
|
|
2
|
+
# DSL and through Active Record's other argument styles: hash conditions,
|
|
3
3
|
# string conditions, raw Arel, and relation and/or chains. Only query
|
|
4
4
|
# construction (through to_sql) is measured; every style produces the same
|
|
5
5
|
# SQL, which the script prints first as a sanity check.
|
data/examples/complex_joins.rb
CHANGED
|
@@ -80,3 +80,33 @@ query4 =
|
|
|
80
80
|
puts "--- 4. Self join through an alias ---"
|
|
81
81
|
puts query4.to_sql
|
|
82
82
|
puts
|
|
83
|
+
|
|
84
|
+
# 5. The joins Active Record has no method for. RIGHT OUTER keeps the rows of
|
|
85
|
+
# the table joined rather than the one selected from; FULL OUTER keeps
|
|
86
|
+
# both, which MySQL has no spelling for and this gem refuses there.
|
|
87
|
+
query5 =
|
|
88
|
+
Author.
|
|
89
|
+
right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
90
|
+
select { [:authors[:name].as(:author), :posts[:title].as(:post)] }
|
|
91
|
+
|
|
92
|
+
puts "--- 5. RIGHT OUTER JOIN ---"
|
|
93
|
+
puts query5.to_sql
|
|
94
|
+
puts
|
|
95
|
+
|
|
96
|
+
# An association is what joins and left_outer_joins read out; these two want
|
|
97
|
+
# the block that says how to join.
|
|
98
|
+
begin
|
|
99
|
+
Author.right_outer_joins(:posts)
|
|
100
|
+
rescue ArgumentError => e
|
|
101
|
+
puts '--- and it says so without one ---'
|
|
102
|
+
puts " #{e.message}"
|
|
103
|
+
puts
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 6. CROSS JOIN: every row against every row, so there is no condition to
|
|
107
|
+
# give and no block to write it in. `as` still names the table.
|
|
108
|
+
puts '--- 6. CROSS JOIN ---'
|
|
109
|
+
puts Author.cross_joins(:posts).to_sql
|
|
110
|
+
puts Author.cross_joins(:authors, as: :others).
|
|
111
|
+
select { [:authors[:name].as(:a), :others[:name].as(:b)] }.to_sql
|
|
112
|
+
puts
|
data/examples/ctes.rb
CHANGED
|
@@ -34,8 +34,8 @@ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
|
|
|
34
34
|
# rather than the string join Rails' own documentation reaches for.
|
|
35
35
|
# `from_cte` then selects the CTE under the model's table name.
|
|
36
36
|
#
|
|
37
|
-
# That alias is
|
|
38
|
-
# last line would be `SELECT * FROM tree`.
|
|
37
|
+
# That alias is Active Record's requirement rather than SQL's: by hand the
|
|
38
|
+
# last line would be `SELECT * FROM tree`. Active Record keeps qualifying
|
|
39
39
|
# columns with the model's table name, so without it `where` and `find_by`
|
|
40
40
|
# look for a table the query does not have. `count`, `order` and `select`
|
|
41
41
|
# never qualify and would work either way, which makes it easy to miss.
|
data/examples/json.rb
CHANGED
|
@@ -30,29 +30,45 @@ def show(title, relation, rows = nil)
|
|
|
30
30
|
puts
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# 1. Reading.
|
|
34
|
-
# into an object, an integer into an array. What comes back is the
|
|
35
|
-
# rather than the JSON around it, which is what a comparison wants.
|
|
36
|
-
show('
|
|
37
|
-
Document.select { [:name, :meta.
|
|
38
|
-
Document.select { [:name, :meta.
|
|
33
|
+
# 1. Reading. dig_text takes the path Hash#dig takes: a string or symbol
|
|
34
|
+
# steps into an object, an integer into an array. What comes back is the
|
|
35
|
+
# value rather than the JSON around it, which is what a comparison wants.
|
|
36
|
+
show('dig_text reads a value out of the document',
|
|
37
|
+
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] },
|
|
38
|
+
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] }.
|
|
39
39
|
map {|d| [d.name, d.author] })
|
|
40
40
|
|
|
41
41
|
show('an integer steps into an array',
|
|
42
|
-
Document.select { [:name, :meta.
|
|
43
|
-
Document.select { [:name, :meta.
|
|
42
|
+
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] },
|
|
43
|
+
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] }.
|
|
44
44
|
map {|d| [d.name, d.first_tag] })
|
|
45
45
|
|
|
46
46
|
# A dug value is an expression like any other, so it compares and orders.
|
|
47
|
-
show('
|
|
48
|
-
Document.where { :meta.
|
|
49
|
-
Document.where { :meta.
|
|
50
|
-
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Document.
|
|
47
|
+
show('dig_text in a condition',
|
|
48
|
+
Document.where { :meta.dig_text(:author, :country) == 'JP' },
|
|
49
|
+
Document.where { :meta.dig_text(:author, :country) == 'JP' }.pluck(:name))
|
|
50
|
+
|
|
51
|
+
# A dug value is text, so a number goes through a cast. Comparing it with
|
|
52
|
+
# one instead is refused rather than left to the adapters, which answer that
|
|
53
|
+
# three ways: true here, an error on PostgreSQL, true on MySQL.
|
|
54
|
+
show('a number wants a cast',
|
|
55
|
+
Document.where { cast(:meta.dig_text(:views), 'integer') > 100 },
|
|
56
|
+
Document.where { cast(:meta.dig_text(:views), 'integer') > 100 }.pluck(:name))
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
Document.where { :meta.dig_text(:views) > 100 }
|
|
60
|
+
rescue ArgumentError => e
|
|
61
|
+
puts '--- and without one it says so ---'
|
|
62
|
+
puts " #{e.message}"
|
|
63
|
+
puts
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# dig keeps the JSON, for a part of the document to be dug into further
|
|
67
|
+
# or asked the JSON questions. The quotes around the string are the sign of
|
|
68
|
+
# it -- and the reason a Ruby value is refused on this side too.
|
|
69
|
+
show('dig keeps the JSON',
|
|
70
|
+
Document.select { [:name, :meta.dig(:author).as(:author)] },
|
|
71
|
+
Document.select { [:name, :meta.dig(:author).as(:author)] }.
|
|
56
72
|
map {|d| [d.name, d.author] })
|
|
57
73
|
|
|
58
74
|
# 2. Asking whether a key is there at all, which is not the same as asking
|
|
@@ -92,3 +108,15 @@ rescue ArgumentError => e
|
|
|
92
108
|
puts " #{e.message}"
|
|
93
109
|
puts
|
|
94
110
|
end
|
|
111
|
+
|
|
112
|
+
# 4. Taking keys out again, as Hash#except does: keys of the document rather
|
|
113
|
+
# than a path, however many of them, and a key that is not there is no
|
|
114
|
+
# error. The document comes back changed, so it chains with bury.
|
|
115
|
+
show('the expression except builds',
|
|
116
|
+
Document.select { [:name, :meta.except(:views, :draft).as(:trimmed)] })
|
|
117
|
+
|
|
118
|
+
Document.where { :name == 'guide' }.
|
|
119
|
+
update_all { {meta: :meta.bury(:author, :country, 'JP').except(:tags, :views)} }
|
|
120
|
+
puts '--- buried and excepted in one statement ---'
|
|
121
|
+
puts " #{Document.find_by(name: 'guide').meta.inspect}"
|
|
122
|
+
puts
|
data/examples/postgresql.rb
CHANGED
|
@@ -153,7 +153,7 @@ show('rollup is the nested case of the same thing',
|
|
|
153
153
|
top = Article.select { :title }.
|
|
154
154
|
where { :articles[:writer_id] == :writers[:id] }.
|
|
155
155
|
order { :likes.desc }.limit(1)
|
|
156
|
-
lateral = Writer.left_outer_joins(top, as: :top
|
|
156
|
+
lateral = Writer.left_outer_joins(top.lateral, as: :top).
|
|
157
157
|
select { [:name, :top[:title].as(:top_article)] }
|
|
158
158
|
show('the top article beside each writer',
|
|
159
159
|
lateral,
|
data/examples/subqueries.rb
CHANGED
|
@@ -41,7 +41,7 @@ end
|
|
|
41
41
|
|
|
42
42
|
# 1. in? takes a relation as a subquery. With an explicit select list the
|
|
43
43
|
# subquery selects that column; without one it selects the primary key,
|
|
44
|
-
# the same way
|
|
44
|
+
# the same way Active Record's own where(id: relation) does.
|
|
45
45
|
show('in? with a subquery',
|
|
46
46
|
Author.where { :id.in?(Post.published.select(:author_id)) })
|
|
47
47
|
|
data/examples/windows.rb
CHANGED
|
@@ -88,7 +88,7 @@ end
|
|
|
88
88
|
numbered = Sale.select {
|
|
89
89
|
[:region, :day, :amount, row_number.over.partition(:region).order(:amount.desc).as(:place)]
|
|
90
90
|
}
|
|
91
|
-
# The subquery is named after the model's own table because
|
|
91
|
+
# The subquery is named after the model's own table because Active Record goes
|
|
92
92
|
# on qualifying columns with that name, so where has to find it there.
|
|
93
93
|
show('the biggest sale of each region',
|
|
94
94
|
Sale.from(numbered, :sales).where { :place == 1 },
|
data/examples/writes.rb
CHANGED
|
@@ -27,7 +27,7 @@ Page.create!(path: '/about', hits: 3, bonus: 0, title: 'about us')
|
|
|
27
27
|
Page.create!(path: '/faq', hits: 0, bonus: 5, title: 'faq')
|
|
28
28
|
|
|
29
29
|
# These statements run rather than being built, so unlike the other examples
|
|
30
|
-
# the SQL is taken from the notification
|
|
30
|
+
# the SQL is taken from the notification Active Record sends for each one.
|
|
31
31
|
def write(title)
|
|
32
32
|
statements = []
|
|
33
33
|
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
|
|
@@ -41,7 +41,7 @@ def write(title)
|
|
|
41
41
|
puts
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# 1. update_all.
|
|
44
|
+
# 1. update_all. Active Record reads its hash as literals -- update_all(hits:
|
|
45
45
|
# :hits) would set the column to the symbol itself -- and the block reads a
|
|
46
46
|
# symbol as the column it names, which is what lets the new value be worked
|
|
47
47
|
# out from the old.
|
|
@@ -70,14 +70,14 @@ write('the old value and the new one added together') do
|
|
|
70
70
|
Page.upsert_all(incoming, unique_by: :path) { {hits: :hits + excluded(:hits)} }
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Without a block, upsert_all overwrites -- that is
|
|
73
|
+
# Without a block, upsert_all overwrites -- that is Active Record's own
|
|
74
74
|
# behaviour, and the block is what makes the old value reachable.
|
|
75
75
|
write('and without a block it is a plain overwrite') do
|
|
76
76
|
Page.upsert_all([{path: '/index', hits: 1, bonus: 0, title: 'HOME'}],
|
|
77
77
|
unique_by: :path)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
# insert_all has no block:
|
|
80
|
+
# insert_all has no block: Active Record type-casts each value into the VALUES
|
|
81
81
|
# list, so an expression there would become nothing rather than SQL.
|
|
82
82
|
write('insert_all takes literals') do
|
|
83
83
|
Page.insert_all([{path: '/legal', hits: 0, bonus: 0, title: 'legal'}])
|