activerecord-refined 0.6.1 → 0.7.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: 9dec7847f9abff657ab248a17613b08464164fcef0780db94ab5f97de8727dd5
4
+ data.tar.gz: a544f3202bd459206ad8e94827b18f09f66d8da912c9ba1f130d75b5d1646e02
5
5
  SHA512:
6
- metadata.gz: 13103ed15da63fadedf60ec4f5f91ee0ba4c22440250aa9c7f48c9df2ccb0b72194225592ddf1dbca449d898fb33f5051fa30f906add9e533917c271b3b6283f
7
- data.tar.gz: 97c9c10eeb67c3dbbbdd7e896e13eb4c49be50dd2312d92ee4289ab8a9c96d5159c06ea940f19a9871f33e877f71de8eb96c7d2d43399eb9097fd19505d4d35f
6
+ metadata.gz: 821537f17ddd8cba223cb45b4e9b6f1bd1bc19cccb4c615686f0f16959cc615c8b3d10225b1a9e632dff291abb87291827bed1b62857648378f46b7d2e947463
7
+ data.tar.gz: ee7269d0bb16a09a7ec938557303e00133e96723c9341e8bbf58d890c2fd5dbffc6fa016a88bfd8383f78a32448a4522c982cefd68dde0e478e64de10c9b751a
@@ -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
@@ -717,6 +739,16 @@ Post.where { cast(:meta.dig(:n), 'integer') > 6 } # 'signed' on MySQL
717
739
 
718
740
  The type is the adapter's own name for it, here as everywhere `cast` is used.
719
741
 
742
+ Comparing a dug value with anything but a string raises `ArgumentError` rather
743
+ than being left to the adapters, which answer it three ways: `dig(:n) == 5` is
744
+ true on SQLite, an error on PostgreSQL and true on MySQL, and
745
+ `dig(:flag) == true` is true, an error and false. `cast` is what says which
746
+ type was meant, and then all three agree. `dig_json` is refused the other way
747
+ about — the JSON for a string carries its quotes, so `dig_json(:name) ==
748
+ 'alice'` is false, an error and true — and `dig` is the one that gives the
749
+ value. A column, a function or another dug value on the right goes through
750
+ untouched; only a Ruby literal is refused.
751
+
720
752
  `bury` sets what `dig` reads: the last argument is the value and the rest are
721
753
  the path to it. The document comes back changed rather than being written
722
754
  anywhere, so `update_all` is what makes it stick:
@@ -736,8 +768,47 @@ Ruby method; it is the name Ruby considered for the other end of `dig`, and
736
768
  SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
737
769
  the others say `JSON_SET`.
738
770
 
771
+ `except` takes keys out again, and takes them as `Hash#except` does — keys of
772
+ the document, however many, rather than a path, which is `bury`'s way of
773
+ reaching further in. It gives back the document changed, so it chains with
774
+ `bury` and goes where `bury` goes:
775
+
776
+ ```ruby
777
+ Post.update_all { { meta: :meta.except(:draft) } }
778
+ # SET "meta" = "meta" - CAST('{"draft"}' AS text[])
779
+ # ... JSON_REMOVE("meta", '$.draft') elsewhere
780
+
781
+ Post.update_all { { meta: :meta.bury(:author, :name, 'alice').except(:tmp) } }
782
+ ```
783
+
784
+ A key that is not there is not an error, as it is not to `Hash#except`. The
785
+ cast is not decoration: `jsonb` has three subtractions — a key, an array of
786
+ keys, an element by index — and an array literal written without a type is
787
+ read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
788
+ `{draft}`, which is nothing, and says nothing about it.
789
+
739
790
  `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`
791
+ whole so the JSON operations read what it gives, which is the same question
792
+ asked of a part of the document rather than of all of it:
793
+
794
+ ```ruby
795
+ Post.where { :meta.dig_json(:author).key?(:email) }
796
+ Post.where { :meta.dig_json(:author).dig(:name) == 'alice' }
797
+ Post.update_all { { meta: :meta.dig_json(:author).bury(:name, 'alice') } }
798
+ ```
799
+
800
+ Containment reads it too, on the adapters that have containment at all:
801
+
802
+ ```ruby
803
+ Post.where { :meta.dig_json(:tags).contains?(['ruby']) }
804
+ ```
805
+
806
+ Asking the same of `dig` raises `ArgumentError`: what it gives is text, and
807
+ reading text back as a document is where the adapters part company — SQLite
808
+ parses it, MySQL takes it as written, and PostgreSQL has no such function for
809
+ text at all.
810
+
811
+ `contains?` has no equivalent on SQLite and raises `NotImplementedError`
741
812
  there — later than the rest, since the adapter is only known when the SQL is
742
813
  built. On PostgreSQL, `contains?` and `key?` want a `jsonb` column; the
743
814
  `json` type carries neither operator.
@@ -783,7 +854,7 @@ Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
783
854
  ```
784
855
 
785
856
  `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
857
+ none. Named windows — `WINDOW w AS (...)` — have no clause in Active Record to
787
858
  live in, so they are not here.
788
859
 
789
860
  ### Aliases and ordering
@@ -817,7 +888,7 @@ Author.
817
888
 
818
889
  ### Writing
819
890
 
820
- `update_all` reads its hash the way ActiveRecord does — `update_all(likes: :likes)`
891
+ `update_all` reads its hash the way Active Record does — `update_all(likes: :likes)`
821
892
  sets the column to the symbol itself. The block reads a symbol as the column it
822
893
  names, as every other block here does, which is what lets the new value be
823
894
  worked out from the old:
@@ -839,23 +910,23 @@ Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
839
910
 
840
911
  PostgreSQL and SQLite name that row `excluded`; MySQL spells the same thing
841
912
  `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
913
+ Active Record's own `on_duplicate:` takes SQL text and nothing else, so this is
843
914
  the one place the DSL writes SQL out itself rather than handing Arel a tree —
844
915
  and the two cannot both be given.
845
916
 
846
917
  `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
918
+ Active Record type-casts each one on the way into the `VALUES` list, so an
848
919
  expression does not become SQL there — it becomes nothing, silently. Use
849
920
  `upsert_all` where a row's value has to be worked out.
850
921
 
851
922
  ## Performance
852
923
 
853
924
  `benchmark/query_building.rb` compares building the same queries through the
854
- block DSL and through ActiveRecord's other argument styles. Only query
925
+ block DSL and through Active Record's other argument styles. Only query
855
926
  construction (through `to_sql`) is measured — every style produces the same
856
927
  SQL, so execution costs the same regardless.
857
928
 
858
- Queries built per second (ruby 4.1.0dev, ActiveRecord 8.1.3, one machine —
929
+ Queries built per second (ruby 4.1.0dev, Active Record 8.1.3, one machine —
859
930
  treat the ratios, not the absolute numbers, as the result):
860
931
 
861
932
  | query | string | arel | block (this gem) | hash | relation and/or |
@@ -930,7 +1001,7 @@ and publishes it through RubyGems.org's trusted publishing, so no API key is
930
1001
  stored anywhere.
931
1002
 
932
1003
  ```sh
933
- bump patch --tag # or bump {major,minor} etc.
1004
+ bundle exec bump patch --tag # or bump {major,minor} etc.
934
1005
  git push --follow-tags
935
1006
  ```
936
1007
 
@@ -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
@@ -48,8 +48,24 @@ show('dig in a condition',
48
48
  Document.where { :meta.dig(:author, :country) == 'JP' },
49
49
  Document.where { :meta.dig(:author, :country) == 'JP' }.pluck(:name))
50
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(:views), 'integer') > 100 },
56
+ Document.where { cast(:meta.dig(:views), 'integer') > 100 }.pluck(:name))
57
+
58
+ begin
59
+ Document.where { :meta.dig(:views) > 100 }
60
+ rescue ArgumentError => e
61
+ puts '--- and without one it says so ---'
62
+ puts " #{e.message}"
63
+ puts
64
+ end
65
+
51
66
  # 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.
67
+ # or compared whole. The quotes around the string are the sign of it -- and
68
+ # the reason a Ruby value is refused on this side too.
53
69
  show('dig_json keeps the JSON',
54
70
  Document.select { [:name, :meta.dig_json(:author).as(:author)] },
55
71
  Document.select { [:name, :meta.dig_json(:author).as(:author)] }.
@@ -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'}])
@@ -228,6 +228,13 @@ module ActiveRecord
228
228
  JsonPath.new(self, path, as_json: true)
229
229
  end
230
230
 
231
+ # Keys taken out of a JSON document, by the name of what Hash does,
232
+ # and taking keys as Hash#except takes them. Like bury it gives back
233
+ # the document changed rather than writing it anywhere.
234
+ def except(*keys)
235
+ JsonExcept.new(self, keys)
236
+ end
237
+
231
238
  # What dig reads, bury sets: the last argument is the value and the
232
239
  # rest are the path to it. The document comes back changed rather
233
240
  # than being written anywhere, which update_all is for.
@@ -359,7 +366,7 @@ module ActiveRecord
359
366
  # A literal standing where an expression would: `select { value(0).as(:depth) }`.
360
367
  #
361
368
  # Values reach the SQL quoted wherever they appear as an operand, but the
362
- # top of a select list is ActiveRecord's, and a bare string there is SQL
369
+ # top of a select list is Active Record's, and a bare string there is SQL
363
370
  # rather than a string. Saying `value` is how you ask for the other
364
371
  # meaning, and it carries the predications with it, so a literal can be
365
372
  # compared and combined like anything else.
@@ -441,7 +448,7 @@ module ActiveRecord
441
448
  end
442
449
 
443
450
  # What a `when` is until its `then` arrives. A Node so that using it
444
- # as one says what is missing rather than reaching ActiveRecord as
451
+ # as one says what is missing rather than reaching Active Record as
445
452
  # something it cannot read.
446
453
  class Pending < Node
447
454
  def initialize(kase, condition)
@@ -474,20 +481,23 @@ module ActiveRecord
474
481
  end
475
482
 
476
483
  # PostgreSQL takes the steps as a text array, where every element is
477
- # quoted so that a comma or a brace in a key is part of it.
478
- def steps_array
479
- "{#{path.map {|step| %("#{escape_step(step)}") }.join(',')}}"
484
+ # quoted so that a comma or a brace in a key is part of it. except
485
+ # writes its keys the same way, which are steps of no one path.
486
+ def steps_array(steps = path)
487
+ "{#{steps.map {|step| %("#{escape_step(step)}") }.join(',')}}"
480
488
  end
481
489
 
482
490
  # MySQL and SQLite take a path expression instead, where an integer is
483
491
  # a subscript and a name that is not plain has to be quoted.
484
492
  def dollar_path
485
- path.inject(+'$') do |so_far, step|
486
- next so_far << "[#{step}]" if step.is_a?(::Integer)
487
- name = step.to_s
488
- so_far << '.' << (name.match?(/\A[[:alpha:]_][[:alnum:]_]*\z/) ?
489
- name : %("#{escape_step(step)}"))
490
- end
493
+ path.inject(+'$') {|so_far, step| so_far << dollar_step(step) }
494
+ end
495
+
496
+ def dollar_step(step)
497
+ return "[#{step}]" if step.is_a?(::Integer)
498
+ name = step.to_s
499
+ '.' + (name.match?(/\A[[:alpha:]_][[:alnum:]_]*\z/) ?
500
+ name : %("#{escape_step(step)}"))
491
501
  end
492
502
 
493
503
  def escape_step(step)
@@ -503,10 +513,85 @@ module ActiveRecord
503
513
  #
504
514
  # The path is turned into a string either way, so a key with a space or
505
515
  # a quote in it travels as itself rather than having to be refused.
516
+ # What a dug value may be compared with. dig gives text on every
517
+ # adapter, and what a text value compared with a number means is a
518
+ # question the three answer three ways: `dig(:n) == 5` is true on
519
+ # SQLite, an error on PostgreSQL and true on MySQL, while
520
+ # `dig(:flag) == true` is true, an error, and false. cast is what says
521
+ # which type was meant, and then all three agree.
522
+ #
523
+ # dig_json is refused the other way about: the JSON for a string carries
524
+ # its quotes, so `dig_json(:name) == 'alice'` is false on SQLite, an
525
+ # error on PostgreSQL and true on MySQL. dig is the one that gives the
526
+ # value.
527
+ #
528
+ # A string against dig, and anything the block itself built -- a column,
529
+ # a function, another dug value -- go through untouched.
530
+ module JsonComparable
531
+ %i[== != < <= > >=].each do |operator|
532
+ define_method(operator) do |other|
533
+ check_comparable(other)
534
+ super(other)
535
+ end
536
+ end
537
+
538
+ def in?(values) = super(check_each(values))
539
+ def not_in?(values) = super(check_each(values))
540
+ def between?(min, max) = super(*check_each([min, max]))
541
+ def not_between?(min, max) = super(*check_each([min, max]))
542
+
543
+ private
544
+
545
+ # nil is left to the comparison itself, which says to use null?, and
546
+ # so is anything the block built rather than wrote as a literal.
547
+ def check_comparable(other)
548
+ return if other.nil? || other.is_a?(Node) || other.is_a?(::Symbol) ||
549
+ other.is_a?(Arel::Nodes::Node) ||
550
+ other.is_a?(Arel::Attributes::Attribute) ||
551
+ other.is_a?(ActiveRecord::Relation)
552
+ return if other.is_a?(::String) && !as_json
553
+
554
+ raise ArgumentError, as_json ?
555
+ "dig_json gives JSON, and comparing it with #{other.inspect} " \
556
+ "means something different on every adapter; dig gives the value" :
557
+ "dig gives text, and comparing it with #{other.inspect} means " \
558
+ "something different on every adapter; cast it to the type meant"
559
+ end
560
+
561
+ def check_each(values)
562
+ case values
563
+ when ActiveRecord::Relation then values
564
+ when ::Range then [values.begin, values.end].each {|v| check_comparable(v) }
565
+ else values.each {|value| check_comparable(value) }
566
+ end
567
+ values
568
+ end
569
+ end
570
+
571
+ # The JSON operations read a document, and what dig_json gives is one:
572
+ # `dig_json(:author).key?(:email)` and `dig_json(:tags).contains?(...)`
573
+ # are the same question asked of a part of it, and the adapters answer
574
+ # them alike. What dig gives is text, and reading that as a document
575
+ # again is where they part company: SQLite parses it back and MySQL
576
+ # takes it as written, where PostgreSQL has no such function for text.
577
+ module JsonDocument
578
+ %i[dig dig_json key? contains? bury except].each do |name|
579
+ define_method(name) do |*args|
580
+ unless as_json
581
+ raise ArgumentError,
582
+ "dig gives text, and #{name} reads JSON; dig_json keeps it"
583
+ end
584
+ super(*args)
585
+ end
586
+ end
587
+ end
588
+
506
589
  class JsonPath < Node
507
590
  include Predications
508
591
  include Arithmetics
509
592
  include JsonSteps
593
+ include JsonComparable
594
+ include JsonDocument
510
595
 
511
596
  attr_reader :operand, :path, :as_json
512
597
 
@@ -595,6 +680,55 @@ module ActiveRecord
595
680
  end
596
681
  end
597
682
 
683
+ # Keys taken out of a JSON document. PostgreSQL subtracts them, the
684
+ # other two remove a path apiece.
685
+ class JsonExcept < Node
686
+ include Predications
687
+ include JsonSteps
688
+
689
+ attr_reader :operand, :keys
690
+
691
+ def initialize(operand, keys)
692
+ @operand = operand
693
+ @keys = check_keys(keys)
694
+ end
695
+
696
+ def to_arel(table, model)
697
+ document = to_arel_operand(operand, table, model)
698
+ return Arel::Nodes::InfixOperation.new(:-, document, key_array) if
699
+ AST.adapter_family(model) == :postgresql
700
+
701
+ Arel::Nodes::NamedFunction.new(
702
+ 'JSON_REMOVE',
703
+ [document, *keys.map {|key| Arel::Nodes.build_quoted("$#{dollar_step(key)}") }])
704
+ end
705
+
706
+ private
707
+
708
+ # jsonb has three subtractions -- a key, an array of keys, an element
709
+ # by index -- and an array literal written without a type is read as
710
+ # the first of them: `meta - '{draft}'` takes out the key spelled
711
+ # {draft}, which is nothing, and says nothing about it.
712
+ def key_array
713
+ Arel::Nodes::NamedFunction.new(
714
+ 'CAST',
715
+ [Arel::Nodes::As.new(Arel::Nodes.build_quoted(steps_array(keys)),
716
+ Arel::Nodes::SqlLiteral.new('text[]'))])
717
+ end
718
+
719
+ # Keys, as Hash#except takes them: an index into an array is not what
720
+ # the name says anywhere, and is bury's business through a path.
721
+ def check_keys(keys)
722
+ raise ArgumentError, 'except needs a key' if keys.empty?
723
+ keys.each do |key|
724
+ next if key.is_a?(::String) || key.is_a?(::Symbol)
725
+ raise ArgumentError,
726
+ "except takes keys of the document, not #{key.inspect}"
727
+ end
728
+ keys
729
+ end
730
+ end
731
+
598
732
  # JSON containment: whether the document holds what is given.
599
733
  class JsonContains < Predicate
600
734
  attr_reader :operand, :value
@@ -1128,7 +1262,7 @@ module ActiveRecord
1128
1262
 
1129
1263
  # A plain SQL comparison. The value is passed through as it is, so a Range
1130
1264
  # or an Array compares against a PostgreSQL range or array column, the way
1131
- # ActiveRecord's own force_equality? types do.
1265
+ # Active Record's own force_equality? types do.
1132
1266
  class Comparison < Predicate
1133
1267
  OPERATOR_MAP = {
1134
1268
  :== => :eq, :!= => :not_eq,
@@ -1187,7 +1321,7 @@ module ActiveRecord
1187
1321
  end
1188
1322
 
1189
1323
  # A relation standing for a set of values, which is what IN and the
1190
- # quantifiers each take. The treatment is ActiveRecord's own
1324
+ # quantifiers each take. The treatment is Active Record's own
1191
1325
  # RelationHandler's: without an explicit select list the subquery
1192
1326
  # selects the model's primary key.
1193
1327
  module SetSubquery
@@ -251,7 +251,7 @@ module ActiveRecord
251
251
  #
252
252
  # select { [:id, value(0).as(:depth)] }
253
253
  #
254
- # Needed because the top of a select list is ActiveRecord's, and a bare
254
+ # Needed because the top of a select list is Active Record's, and a bare
255
255
  # string there is SQL rather than a string. Numbers have a shorthand --
256
256
  # `0.as(:depth)` -- since nothing else could be meant by one.
257
257
  def value(literal)
@@ -367,7 +367,7 @@ module ActiveRecord
367
367
  end
368
368
  end
369
369
 
370
- # A symbol names a table, which ActiveRecord's own from only takes as a
370
+ # A symbol names a table, which Active Record's own from only takes as a
371
371
  # string. With `as` it is selected under another name; when that name
372
372
  # is the model's own, from_cte says the same thing without repeating it.
373
373
  def from(value, subquery_name = nil, as: nil)
@@ -383,7 +383,7 @@ module ActiveRecord
383
383
  end
384
384
 
385
385
  # Selects a CTE in place of the model's own table. The alias is not a
386
- # choice -- ActiveRecord keeps qualifying columns with the table name,
386
+ # choice -- Active Record keeps qualifying columns with the table name,
387
387
  # so the model's is the only name that works -- which is why it is
388
388
  # taken from the model rather than asked for:
389
389
  # with_recursive(tree: [...]).from_cte(:tree)
@@ -432,7 +432,7 @@ module ActiveRecord
432
432
  self
433
433
  end
434
434
 
435
- # ActiveRecord generates these for the values it knows about; this one
435
+ # Active Record generates these for the values it knows about; this one
436
436
  # is ours, and lives in the same place so that it survives a spawn.
437
437
  def distinct_on_values
438
438
  @values.fetch(:distinct_on, ActiveRecord::QueryMethods::FROZEN_EMPTY_ARRAY)
@@ -443,13 +443,32 @@ module ActiveRecord
443
443
  @values[:distinct_on] = columns
444
444
  end
445
445
 
446
+ # Marks the relation for a lateral join, which lets it see the row being
447
+ # joined to -- the top few rows of each group, and the like. In SQL the
448
+ # keyword modifies the subquery, not the join, so it is said on the
449
+ # relation: left_outer_joins(top_post.lateral, as: :top).
450
+ def lateral
451
+ spawn.lateral!
452
+ end
453
+
454
+ def lateral!
455
+ self.lateral_value = true
456
+ self
457
+ end
458
+
459
+ def lateral_value
460
+ @values[:lateral]
461
+ end
462
+
463
+ def lateral_value=(value)
464
+ assert_modifiable!
465
+ @values[:lateral] = value
466
+ end
467
+
446
468
  # `as` names the table within the query, which is what makes a self
447
469
  # join expressible: joins(:employees, as: :managers) { ... }.
448
- #
449
- # `lateral` joins a relation instead of a table, and lets it see the row
450
- # being joined to -- the top few rows of each group, and the like.
451
- def joins(*args, as: nil, lateral: false, &block)
452
- if lateral
470
+ def joins(*args, as: nil, &block)
471
+ if args.first.is_a?(ActiveRecord::Relation)
453
472
  super(build_lateral_join(args.first, Arel::Nodes::InnerJoin, as, &block))
454
473
  elsif block
455
474
  super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
@@ -459,8 +478,8 @@ module ActiveRecord
459
478
  end
460
479
  end
461
480
 
462
- def left_outer_joins(*args, as: nil, lateral: false, &block)
463
- if lateral
481
+ def left_outer_joins(*args, as: nil, &block)
482
+ if args.first.is_a?(ActiveRecord::Relation)
464
483
  joins(build_lateral_join(args.first, Arel::Nodes::OuterJoin, as, &block))
465
484
  elsif block
466
485
  joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
@@ -470,6 +489,36 @@ module ActiveRecord
470
489
  end
471
490
  end
472
491
 
492
+ # The other two outer joins, which Active Record has no method for and
493
+ # Arel has the nodes for. The rules are joins': the block is the ON,
494
+ # `as` names the table within the query, a relation marked `lateral`
495
+ # joins as one. An association name is not among them -- what Active
496
+ # Record reads out of one is an inner or a left join and nothing else.
497
+ def right_outer_joins(*args, as: nil, &block)
498
+ outer_joins(:right_outer_joins, Arel::Nodes::RightOuterJoin,
499
+ args, as, &block)
500
+ end
501
+
502
+ def full_outer_joins(*args, as: nil, &block)
503
+ check_full_outer_support
504
+ outer_joins(:full_outer_joins, Arel::Nodes::FullOuterJoin,
505
+ args, as, &block)
506
+ end
507
+
508
+ # CROSS JOIN: every row of one table against every row of the other, so
509
+ # unlike the joins above there is no condition to give and no block to
510
+ # write it in.
511
+ #
512
+ # Post.cross_joins(:authors)
513
+ # Post.cross_joins(:posts, as: :others)
514
+ def cross_joins(*args, as: nil, &block)
515
+ if block
516
+ raise ArgumentError,
517
+ 'a cross join has no condition; joins is the one that takes a block'
518
+ end
519
+ joins(build_cross_join(args.first, as))
520
+ end
521
+
473
522
  private
474
523
 
475
524
  def build_arel(...)
@@ -521,8 +570,9 @@ module ActiveRecord
521
570
  # which is the usual shape -- what the subquery is allowed to see is
522
571
  # what makes it lateral, and that is said inside it.
523
572
  def build_lateral_join(relation, join_class, alias_name, &block)
524
- unless relation.is_a?(ActiveRecord::Relation)
525
- raise ArgumentError, "a lateral join takes a relation to join against"
573
+ unless relation.lateral_value
574
+ raise ArgumentError,
575
+ "a relation joins laterally; mark it: joins(sub.lateral, as: :top)"
526
576
  end
527
577
  unless alias_name
528
578
  raise ArgumentError, "a lateral join needs a name: joins(..., as: :top)"
@@ -551,6 +601,37 @@ module ActiveRecord
551
601
  raise NotImplementedError, "a lateral join has no equivalent on #{database}"
552
602
  end
553
603
 
604
+ # MySQL has no FULL OUTER JOIN, and neither has MariaDB; SQLite has had
605
+ # one since 3.39 and PostgreSQL always.
606
+ def check_full_outer_support
607
+ return unless AST.adapter_family(klass) == :mysql
608
+ raise NotImplementedError, 'a full outer join has no equivalent on MySQL'
609
+ end
610
+
611
+ def outer_joins(called, join_class, args, alias_name, &block)
612
+ if args.first.is_a?(ActiveRecord::Relation)
613
+ return joins(build_lateral_join(args.first, join_class, alias_name, &block))
614
+ end
615
+ return joins(build_join_node(args.first, join_class, alias_name, &block)) if block
616
+
617
+ raise ArgumentError,
618
+ "#{called} takes a table and the block that joins it; an association " \
619
+ 'is what joins and left_outer_joins read'
620
+ end
621
+
622
+ # Arel has a node for every other join and none for this one, and INNER
623
+ # JOIN with no ON -- which is a cross join on SQLite and MySQL -- is a
624
+ # syntax error on PostgreSQL. So the SQL is written here, the second
625
+ # place in the gem that writes any: the keyword is fixed and the names
626
+ # are quoted by the adapter, so nothing of the caller's is in it.
627
+ def build_cross_join(target_table, alias_name)
628
+ joined = klass.with_connection do |connection|
629
+ name = connection.quote_table_name(target_table.to_s)
630
+ alias_name ? "#{name} #{connection.quote_table_name(alias_name.to_s)}" : name
631
+ end
632
+ Arel::Nodes::StringJoin.new(Arel.sql("CROSS JOIN #{joined}"))
633
+ end
634
+
554
635
  def build_join_node(target_table, join_class, alias_name, &block)
555
636
  ast = evaluate_block(&block)
556
637
  arel_table = Arel::Table.new(target_table)
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.6.1'
3
+ VERSION = '0.7.0'
4
4
  end
5
5
  end
@@ -9,6 +9,8 @@ ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
9
9
  # update_all and its kind are Relation's own rather than QueryMethods'.
10
10
  ActiveRecord::Relation.prepend ActiveRecord::Refined::Writes
11
11
 
12
- # The methods above are ActiveRecord's own, so a model already forwards them
13
- # to its relation. These two are new, and have to be added to that list.
14
- ActiveRecord::Base.singleton_class.delegate :from_cte, :distinct_on, to: :all
12
+ # The methods above are Active Record's own, so a model already forwards them
13
+ # to its relation. These are new, and have to be added to that list.
14
+ ActiveRecord::Base.singleton_class.delegate(
15
+ :from_cte, :distinct_on, :lateral,
16
+ :right_outer_joins, :full_outer_joins, :cross_joins, to: :all)
@@ -697,7 +697,7 @@ class TestBlockSyntax < Minitest::Test
697
697
  end
698
698
 
699
699
  # A relation without an explicit select list selects its primary key, the
700
- # same way ActiveRecord's own where(id: relation) does.
700
+ # same way Active Record's own where(id: relation) does.
701
701
  def test_in_subquery_selects_primary_key_by_default
702
702
  assert_sql(/WHERE "authors"."id" IN \(SELECT "posts"."id" FROM "posts"\)/,
703
703
  Author.where { :id.in?(Post.all) }.to_sql)
@@ -878,6 +878,80 @@ class TestBlockSyntax < Minitest::Test
878
878
  assert_raises(ArgumentError) { Author.left_outer_joins(:posts, as: :p) }
879
879
  end
880
880
 
881
+ # The other two outer joins, which Active Record has no method for. MySQL
882
+ # has no FULL OUTER JOIN either, and says so before the SQL is built.
883
+ def test_right_outer_joins_with_block
884
+ assert_sql(/RIGHT OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id"/,
885
+ Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }.to_sql)
886
+ end
887
+
888
+ def test_full_outer_joins_with_block
889
+ skip_without_full_outer_joins
890
+ assert_sql(/FULL OUTER JOIN "posts" ON "posts"."author_id" = "authors"."id"/,
891
+ Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }.to_sql)
892
+ end
893
+
894
+ def test_full_outer_joins_says_where_it_cannot_go
895
+ skip "#{ADAPTER} has FULL OUTER JOIN" unless ADAPTER == 'mysql2'
896
+ e = assert_raises(NotImplementedError) do
897
+ Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
898
+ end
899
+ assert_match(/no equivalent on MySQL/, e.message)
900
+ end
901
+
902
+ def test_right_outer_joins_with_alias
903
+ assert_sql(
904
+ /RIGHT OUTER JOIN "authors" (?:AS )?"mentors" ON "mentors"."id" = "authors"."id"/,
905
+ Author.right_outer_joins(:authors, as: :mentors) { :mentors[:id] == :authors[:id] }.to_sql)
906
+ end
907
+
908
+ # An association is what joins and left_outer_joins read; there is nothing
909
+ # for these two to read one as.
910
+ def test_the_other_outer_joins_need_a_block
911
+ e = assert_raises(ArgumentError) { Author.right_outer_joins(:posts) }
912
+ assert_match(/takes a table and the block/, e.message)
913
+ unless ADAPTER == 'mysql2'
914
+ assert_raises(ArgumentError) { Author.full_outer_joins(:posts) }
915
+ end
916
+ end
917
+
918
+ # CROSS JOIN: every row against every row, so there is no condition to give.
919
+ def test_cross_joins
920
+ assert_sql(/FROM "authors" CROSS JOIN "posts"/, Author.cross_joins(:posts).to_sql)
921
+ end
922
+
923
+ def test_cross_joins_with_alias
924
+ assert_sql(/CROSS JOIN "authors" "others"/,
925
+ Author.cross_joins(:authors, as: :others).to_sql)
926
+ end
927
+
928
+ def test_cross_joins_takes_no_block
929
+ e = assert_raises(ArgumentError) { Author.cross_joins(:posts) { :id == 1 } }
930
+ assert_match(/no condition/, e.message)
931
+ end
932
+
933
+ def test_cross_joins_execution
934
+ Author.delete_all
935
+ Post.delete_all
936
+ Author.create!(name: 'a')
937
+ Author.create!(name: 'b')
938
+ Post.create!(title: 'one')
939
+ Post.create!(title: 'two')
940
+ Post.create!(title: 'three')
941
+ assert_equal(6, Author.cross_joins(:posts).count)
942
+ end
943
+
944
+ def test_right_outer_joins_execution
945
+ Author.delete_all
946
+ Post.delete_all
947
+ author = Author.create!(name: 'a')
948
+ Post.create!(title: 'hers', author_id: author.id)
949
+ Post.create!(title: 'nobody\'s', author_id: nil)
950
+ assert_equal(['a', nil],
951
+ Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }.
952
+ order { :posts[:title] }.pluck(:'authors.name'))
953
+ end
954
+
881
955
  def test_joins_without_alias_still_delegates
882
956
  assert_sql(/INNER JOIN "posts" ON "posts"."author_id" = "authors"."id"/,
883
957
  Author.joins(:posts).to_sql)
@@ -892,7 +966,7 @@ class TestBlockSyntax < Minitest::Test
892
966
  pluck(:name).sort)
893
967
  end
894
968
 
895
- # ActiveRecord's from only takes a table name as a string.
969
+ # Active Record's from only takes a table name as a string.
896
970
  def test_from_symbol
897
971
  assert_sql(/FROM "tree"/, Node.from(:tree).to_sql)
898
972
  end
@@ -1856,6 +1930,73 @@ class TestBlockSyntax < Minitest::Test
1856
1930
  assert_equal(%w[x y], value.is_a?(String) ? JSON.parse(value) : value)
1857
1931
  end
1858
1932
 
1933
+ # What text compared with a number means is a question the three adapters
1934
+ # answer three ways -- `dig(:n) == 5` is true on SQLite, an error on
1935
+ # PostgreSQL and true on MySQL, and `dig(:flag) == true` is true, an error
1936
+ # and false -- so the comparison is refused rather than left to them.
1937
+ def test_dig_refuses_a_comparison_with_anything_but_text
1938
+ e = assert_raises(ArgumentError) { Doc.where { :meta.dig(:n) == 5 } }
1939
+ assert_match(/cast/, e.message)
1940
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:n) != 5 } }
1941
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:n) > 6 } }
1942
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:flag) == true } }
1943
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:n).in?([1, 2]) } }
1944
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:n).between?(1, 9) } }
1945
+ end
1946
+
1947
+ # A string is what a dug value compares to; so is anything the block built
1948
+ # rather than wrote as a literal, since that is nobody's guess to make.
1949
+ def test_dig_compares_with_text_and_with_expressions
1950
+ seed_docs
1951
+ assert_equal(['one'], Doc.where { :meta.dig(:n) == '5' }.pluck(:name))
1952
+ assert_equal([], Doc.where { :meta.dig(:n) == :name }.pluck(:name))
1953
+ assert_equal(['one'], Doc.where { :meta.dig(:n) == upper('5') }.pluck(:name))
1954
+ type = integer_type
1955
+ assert_equal(['one'], Doc.where { cast(:meta.dig(:n), type) == 5 }.pluck(:name))
1956
+ end
1957
+
1958
+ # The JSON for a string carries its quotes, so the same comparison is
1959
+ # refused the other way about: false on SQLite, an error on PostgreSQL and
1960
+ # true on MySQL.
1961
+ def test_dig_json_refuses_a_comparison_with_a_ruby_value
1962
+ e = assert_raises(ArgumentError) { Doc.where { :meta.dig_json(:a) == 'deep' } }
1963
+ assert_match(/dig gives the value/, e.message)
1964
+ assert_raises(ArgumentError) { Doc.where { :meta.dig_json(:n) == 5 } }
1965
+ end
1966
+
1967
+ # What dig_json gives is a document, so the JSON operations read it: the
1968
+ # same question asked of a part rather than of the whole.
1969
+ def test_the_json_operations_read_what_dig_json_kept
1970
+ seed_docs
1971
+ assert_equal(['one'], Doc.where { :meta.dig_json(:a).key?(:b) }.pluck(:name))
1972
+ assert_equal(%w[one two], Doc.where { :meta.dig_json(:n).not_null? }.order(:name).pluck(:name))
1973
+ assert_equal(['one'],
1974
+ Doc.where { :meta.dig_json(:a).dig(:b) == 'deep' }.pluck(:name))
1975
+ value = Doc.where { :name == 'one' }.
1976
+ select { :meta.dig_json(:a).bury(:b, 'x').as(:v) }.first.v
1977
+ assert_equal('x', (value.is_a?(String) ? JSON.parse(value) : value)['b'])
1978
+ end
1979
+
1980
+ def test_containment_reads_what_dig_json_kept
1981
+ skip_without_json_containment
1982
+ seed_docs
1983
+ assert_equal(['one'], Doc.where { :meta.dig_json(:tags).contains?(['x']) }.pluck(:name))
1984
+ assert_equal([], Doc.where { :meta.dig_json(:tags).contains?(['z']) }.pluck(:name))
1985
+ end
1986
+
1987
+ # Reading text back as a document is where the adapters part company:
1988
+ # SQLite parses it, MySQL takes it as written, PostgreSQL has no such
1989
+ # function for text at all.
1990
+ def test_the_json_operations_are_refused_on_a_dug_value
1991
+ e = assert_raises(ArgumentError) { Doc.where { :meta.dig(:a).key?(:b) } }
1992
+ assert_match(/dig_json keeps it/, e.message)
1993
+ assert_raises(ArgumentError) { Doc.where { :meta.dig(:a).contains?(b: 1) } }
1994
+ assert_raises(ArgumentError) { Doc.select { :meta.dig(:a).dig(:b) } }
1995
+ assert_raises(ArgumentError) { Doc.select { :meta.dig(:a).dig_json(:b) } }
1996
+ assert_raises(ArgumentError) { Doc.select { :meta.dig(:a).bury(:b, 'x') } }
1997
+ assert_raises(ArgumentError) { Doc.select { :meta.dig(:a).except(:b) } }
1998
+ end
1999
+
1859
2000
  def test_dig_from_a_qualified_column
1860
2001
  seed_docs
1861
2002
  assert_equal(['one'], Doc.where { :docs[:meta].dig(:a, :b) == 'deep' }.pluck(:name))
@@ -2012,7 +2153,7 @@ class TestBlockSyntax < Minitest::Test
2012
2153
  def test_lateral_join
2013
2154
  skip_without_lateral
2014
2155
  seed_for_lateral
2015
- rows = Author.joins(top_post, as: :top, lateral: true).
2156
+ rows = Author.joins(top_post.lateral, as: :top).
2016
2157
  select { [:name, :top[:title].as(:v)] }.map {|r| [r.name, r.v] }
2017
2158
  assert_equal([['writes', 'b']], rows)
2018
2159
  end
@@ -2021,7 +2162,7 @@ class TestBlockSyntax < Minitest::Test
2021
2162
  def test_left_outer_lateral_join
2022
2163
  skip_without_lateral
2023
2164
  seed_for_lateral
2024
- rows = Author.left_outer_joins(top_post, as: :top, lateral: true).
2165
+ rows = Author.left_outer_joins(top_post.lateral, as: :top).
2025
2166
  select { [:name, :top[:title].as(:v)] }.order { :name }.map {|r| [r.name, r.v] }
2026
2167
  assert_equal([['does not', nil], ['writes', 'b']], rows)
2027
2168
  end
@@ -2031,22 +2172,28 @@ class TestBlockSyntax < Minitest::Test
2031
2172
  def test_lateral_join_takes_an_on_clause
2032
2173
  skip_without_lateral
2033
2174
  seed_for_lateral
2034
- assert_equal(0, Author.joins(top_post, as: :top, lateral: true) {
2175
+ assert_equal(0, Author.joins(top_post.lateral, as: :top) {
2035
2176
  :top[:title] == 'nothing'
2036
2177
  }.count)
2037
- assert_sql(/ON TRUE/, Author.joins(top_post, as: :top, lateral: true).to_sql)
2178
+ assert_sql(/ON TRUE/, Author.joins(top_post.lateral, as: :top).to_sql)
2038
2179
  end
2039
2180
 
2040
- def test_lateral_join_needs_a_relation_and_a_name
2041
- e = assert_raises(ArgumentError) { Author.joins(:posts, as: :top, lateral: true) }
2042
- assert_match(/takes a relation/, e.message)
2043
- e = assert_raises(ArgumentError) { Author.joins(top_post, lateral: true) }
2181
+ def test_lateral_join_needs_the_mark_and_a_name
2182
+ e = assert_raises(ArgumentError) { Author.joins(top_post, as: :top) }
2183
+ assert_match(/mark it/, e.message)
2184
+ e = assert_raises(ArgumentError) { Author.joins(top_post.lateral) }
2044
2185
  assert_match(/needs a name/, e.message)
2045
2186
  end
2046
2187
 
2188
+ def test_lateral_spawns
2189
+ relation = top_post
2190
+ assert(relation.lateral.lateral_value)
2191
+ refute(relation.lateral_value)
2192
+ end
2193
+
2047
2194
  def test_lateral_join_says_where_it_cannot_go
2048
2195
  skip 'this one has LATERAL' if ADAPTER == 'postgresql' || (ADAPTER == 'mysql2' && !mariadb?)
2049
- e = assert_raises(NotImplementedError) { Author.joins(top_post, as: :top, lateral: true) }
2196
+ e = assert_raises(NotImplementedError) { Author.joins(top_post.lateral, as: :top) }
2050
2197
  assert_match(/lateral join has no equivalent/, e.message)
2051
2198
  end
2052
2199
 
@@ -2151,6 +2298,44 @@ class TestBlockSyntax < Minitest::Test
2151
2298
  assert_raises(ArgumentError) { Doc.select { :meta.bury(1.5, 'v') } }
2152
2299
  end
2153
2300
 
2301
+ # except takes keys out, by the name of what Hash does. PostgreSQL
2302
+ # subtracts them where the others remove a path apiece, and what comes back
2303
+ # is the same document on all three.
2304
+ def test_except_a_key
2305
+ assert_equal({ 'a' => { 'b' => 'deep' }, 'tags' => %w[x y], 'odd key' => 1 },
2306
+ buried { { meta: :meta.except(:n) } })
2307
+ end
2308
+
2309
+ def test_except_several_keys
2310
+ assert_equal({ 'a' => { 'b' => 'deep' } },
2311
+ buried { { meta: :meta.except(:n, :tags, :'odd key') } })
2312
+ end
2313
+
2314
+ # A key that is not there is not an error, as Hash#except has none for it.
2315
+ def test_except_a_key_that_is_not_there
2316
+ assert_equal(5, buried { { meta: :meta.except(:nothing) } }['n'])
2317
+ end
2318
+
2319
+ # The document a bury gives back is one to take keys out of.
2320
+ def test_except_after_bury
2321
+ document = buried { { meta: :meta.bury(:fresh, 9).except(:n) } }
2322
+ assert_equal(9, document['fresh'])
2323
+ assert_nil(document['n'])
2324
+ end
2325
+
2326
+ def test_except_in_a_select
2327
+ seed_docs
2328
+ value = Doc.where { :name == 'one' }.select { :meta.except(:n).as(:v) }.first.v
2329
+ assert_nil((value.is_a?(String) ? JSON.parse(value) : value)['n'])
2330
+ end
2331
+
2332
+ # An index is not what the name says anywhere, and a path is bury's.
2333
+ def test_except_takes_keys
2334
+ assert_raises(ArgumentError) { Doc.select { :meta.except } }
2335
+ e = assert_raises(ArgumentError) { Doc.select { :meta.except(0) } }
2336
+ assert_match(/keys of the document/, e.message)
2337
+ end
2338
+
2154
2339
  def test_default_where_syntax
2155
2340
  assert_sql(/WHERE "users"."name" = 'Ruby' AND "users"."age" = 19/,
2156
2341
  User.where(name: 'Ruby', age: 19).to_sql)
data/test/test_helper.rb CHANGED
@@ -74,7 +74,12 @@ module SqlAssertions
74
74
  skip "#{ADAPTER} has no JSON containment" if ADAPTER == 'sqlite3'
75
75
  end
76
76
 
77
- # MariaDB's json is a checked longtext, which ActiveRecord sees as a string
77
+ # MySQL is the one without a FULL OUTER JOIN, and so is MariaDB.
78
+ def skip_without_full_outer_joins
79
+ skip "#{ADAPTER} has no full outer join" if ADAPTER == 'mysql2'
80
+ end
81
+
82
+ # MariaDB's json is a checked longtext, which Active Record sees as a string
78
83
  # and does not serialise a hash into -- what would go in is Ruby's inspect,
79
84
  # which the check refuses. MySQL's json is a type of its own and takes the
80
85
  # hash; handing that one a string would store the document as a JSON string
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-refined
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugo Maeda
@@ -73,7 +73,23 @@ dependencies:
73
73
  - ">="
74
74
  - !ruby/object:Gem::Version
75
75
  version: "0"
76
- description: Adding clean and powerful query syntax on AR using refinements
76
+ - !ruby/object:Gem::Dependency
77
+ name: bump
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ -
81
+ - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: "0"
84
+ type: :development
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ -
89
+ - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: "0"
92
+ description: Adding clean and powerful query syntax on Active Record using refinements
77
93
  email:
78
94
  - shugo@ruby-lang.org
79
95
  executables: []
@@ -127,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
127
143
  requirements: []
128
144
  rubygems_version: 4.1.0.dev
129
145
  specification_version: 4
130
- summary: "ActiveRecord + Ruby 4.1 Proc#refined"
146
+ summary: Write Active Record queries as Ruby expressions
131
147
  test_files:
132
148
  - test/test_block_syntax.rb
133
149
  - test/test_helper.rb