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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: affe44abbced76ea15b8c057f44292e5aefb0c8be7bc0012805278b08be7881c
4
- data.tar.gz: 54b11073068080d169b005a4f2fdee825a8b03e0910459c5fef5555e24430edd
3
+ metadata.gz: aaf1859c70b9ca6e5c245343e664fffbd483d395266a4d442eaa0d5c4107595c
4
+ data.tar.gz: da1ddb190628a8a11ced1d105d0a73bf164892cf9e78dfd68db7fdf135047d95
5
5
  SHA512:
6
- metadata.gz: 13103ed15da63fadedf60ec4f5f91ee0ba4c22440250aa9c7f48c9df2ccb0b72194225592ddf1dbca449d898fb33f5051fa30f906add9e533917c271b3b6283f
7
- data.tar.gz: 97c9c10eeb67c3dbbbdd7e896e13eb4c49be50dd2312d92ee4289ab8a9c96d5159c06ea940f19a9871f33e877f71de8eb96c7d2d43399eb9097fd19505d4d35f
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 ActiveRecord's relation.rb; see the
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
  [![gem](https://img.shields.io/gem/v/activerecord-refined.svg)](https://rubygems.org/gems/activerecord-refined)
4
4
  [![test](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml/badge.svg)](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml)
5
5
 
6
- Adding clean and powerful query syntax on ActiveRecord using refinements.
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, ActiveRecord, SQLite and PostgreSQL run in the page, so the examples
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
- * ActiveRecord 7.0 or later
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 ActiveRecord's own
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 ActiveRecord's own `where(period: from...to)` does for those column types:
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: ActiveRecord goes on qualifying columns with that name, so `where` needs
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: 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:
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, lateral: true).
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
- ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
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 ActiveRecord's sake, not SQL's:
407
- written by hand that line would be `SELECT * FROM tree`. ActiveRecord goes on qualifying
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
- ActiveRecord, and so is a column alias. That is what makes the name asked for
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 ActiveRecord reads a string in `select` as SQL,
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.dig(:author, :name) == 'alice' }
691
- Post.select { :meta.dig(:tags, 0).as(:first_tag) }
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, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
702
- | `dig_json(:a)` | `#> '{a}'` | `-> '$.a'` | `JSON_EXTRACT(…, '$.a')` |
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
- `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:
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.dig(:n) == '5' }
715
- Post.where { cast(:meta.dig(:n), 'integer') > 6 } # 'signed' on MySQL
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.dig(:n)) } }
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
- `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`
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 ActiveRecord to
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 ActiveRecord does — `update_all(likes: :likes)`
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
- ActiveRecord's own `on_duplicate:` takes SQL text and nothing else, so this is
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
- ActiveRecord type-casts each one on the way into the `VALUES` list, so an
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 ActiveRecord's other argument styles. Only query
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, ActiveRecord 8.1.3, one machine —
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 AR using refinements'
12
- gem.summary = 'ActiveRecord + Ruby 4.1 Proc#refined'
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
@@ -1,5 +1,5 @@
1
1
  # Compares the cost of building the same queries through this gem's block
2
- # DSL and through ActiveRecord's other argument styles: hash conditions,
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.
@@ -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 ActiveRecord's requirement rather than SQL's: by hand the
38
- # last line would be `SELECT * FROM tree`. ActiveRecord keeps qualifying
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. dig takes the path Hash#dig takes: a string or symbol steps
34
- # into an object, an integer into an array. What comes back is the value
35
- # rather than the JSON around it, which is what a comparison wants.
36
- show('dig reads a value out of the document',
37
- Document.select { [:name, :meta.dig(:author, :name).as(:author)] },
38
- Document.select { [:name, :meta.dig(:author, :name).as(:author)] }.
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.dig(:tags, 0).as(:first_tag)] },
43
- Document.select { [:name, :meta.dig(:tags, 0).as(:first_tag)] }.
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('dig in a condition',
48
- Document.where { :meta.dig(:author, :country) == 'JP' },
49
- Document.where { :meta.dig(:author, :country) == 'JP' }.pluck(:name))
50
-
51
- # dig_json keeps the JSON, for a part of the document to be dug into further
52
- # or compared whole. The quotes around the string are the sign of it.
53
- show('dig_json keeps the JSON',
54
- Document.select { [:name, :meta.dig_json(:author).as(:author)] },
55
- Document.select { [:name, :meta.dig_json(:author).as(:author)] }.
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
@@ -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, lateral: true).
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,
@@ -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 ActiveRecord's own where(id: relation) does.
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 ActiveRecord goes
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 ActiveRecord sends for each one.
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. ActiveRecord reads its hash as literals -- update_all(hits:
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 ActiveRecord's own
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: ActiveRecord type-casts each value into the VALUES
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'}])