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 +4 -4
- data/.github/workflows/sandbox.yml +1 -1
- data/README.md +94 -23
- 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 +29 -1
- 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 +147 -13
- 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 +196 -11
- 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: 9dec7847f9abff657ab248a17613b08464164fcef0780db94ab5f97de8727dd5
|
|
4
|
+
data.tar.gz: a544f3202bd459206ad8e94827b18f09f66d8da912c9ba1f130d75b5d1646e02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
@@ -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
|
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'}])
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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(+'$')
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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 --
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
463
|
-
if
|
|
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.
|
|
525
|
-
raise ArgumentError,
|
|
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)
|
data/lib/activerecord-refined.rb
CHANGED
|
@@ -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
|
|
13
|
-
# to its relation. These
|
|
14
|
-
ActiveRecord::Base.singleton_class.delegate
|
|
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)
|
data/test/test_block_syntax.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2178
|
+
assert_sql(/ON TRUE/, Author.joins(top_post.lateral, as: :top).to_sql)
|
|
2038
2179
|
end
|
|
2039
2180
|
|
|
2040
|
-
def
|
|
2041
|
-
e = assert_raises(ArgumentError) { Author.joins(
|
|
2042
|
-
assert_match(/
|
|
2043
|
-
e = assert_raises(ArgumentError) { Author.joins(top_post
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|