activerecord-refined 0.7.0 → 0.8.1
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/README.md +53 -33
- data/examples/json.rb +20 -20
- data/lib/active_record/refined/ast.rb +98 -36
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +103 -53
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ea652837dabe0136a80b1603e2efd7a86a5ca18f966a39a112c10bb05b8f7e4
|
|
4
|
+
data.tar.gz: 791a680c4ed2d87ed61581c8c232b792ab493a9ef5bd30ddc0d1d0ca9c437ec7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68bcd884eb01ab5bbc72711996e2ed96f3299b00525295c8688734f3f7110cffd2d15775b2d71c9cb78b02eb52983ddd58675ba4d80a73f76aad9042f719c479
|
|
7
|
+
data.tar.gz: f47a5b3f3f43242399e3d964c6630d08c124acdff206a9cec0cd7919e88baf9b399fe2e505e9f2296da7ddd123f0be34404646e052abad1d6a963b189d2424dd
|
data/README.md
CHANGED
|
@@ -706,11 +706,14 @@ Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
|
706
706
|
### JSON
|
|
707
707
|
|
|
708
708
|
`dig` reads inside a JSON document, by the name of what `Hash` does. A string
|
|
709
|
-
or symbol steps into an object, an integer into an array
|
|
709
|
+
or symbol steps into an object, an integer into an array, and what comes back
|
|
710
|
+
is still JSON — the way `Hash#dig` hands back the structure itself — for a
|
|
711
|
+
document to be dug into further or asked the JSON questions. `dig_text` gives
|
|
712
|
+
the value as text instead, which is what a comparison wants:
|
|
710
713
|
|
|
711
714
|
```ruby
|
|
712
|
-
Post.where { :meta.
|
|
713
|
-
Post.select { :meta.dig(:
|
|
715
|
+
Post.where { :meta.dig_text(:author, :name) == 'alice' }
|
|
716
|
+
Post.select { :meta.dig(:author).as(:author) }
|
|
714
717
|
Post.where { :meta.key?(:draft) }
|
|
715
718
|
Post.where { :meta.contains?(status: 'open') }
|
|
716
719
|
```
|
|
@@ -720,34 +723,43 @@ three:
|
|
|
720
723
|
|
|
721
724
|
| | PostgreSQL | SQLite | MySQL |
|
|
722
725
|
| --- | --- | --- | --- |
|
|
723
|
-
| `dig(:a, :b)` |
|
|
724
|
-
| `
|
|
726
|
+
| `dig(:a, :b)` | `#> '{a,b}'` | `-> '$.a.b'` | `JSON_EXTRACT(…, '$.a.b')` |
|
|
727
|
+
| `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
725
728
|
| `key?(:a)` | `jsonb_exists(…, 'a')` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
726
729
|
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
727
730
|
|
|
728
731
|
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
729
732
|
MySQL family goes through the functions, which both have.
|
|
730
733
|
|
|
731
|
-
`
|
|
732
|
-
with its type, so a comparison that worked there would fail on the other
|
|
733
|
-
a number is compared through a `cast` on all three:
|
|
734
|
+
`dig_text` gives text everywhere. SQLite's `->>` would otherwise hand back the
|
|
735
|
+
value with its type, so a comparison that worked there would fail on the other
|
|
736
|
+
two; a number is compared through a `cast` on all three:
|
|
734
737
|
|
|
735
738
|
```ruby
|
|
736
|
-
Post.where { :meta.
|
|
737
|
-
Post.where { cast(:meta.
|
|
739
|
+
Post.where { :meta.dig_text(:n) == '5' }
|
|
740
|
+
Post.where { cast(:meta.dig_text(:n), 'integer') > 6 } # 'signed' on MySQL
|
|
738
741
|
```
|
|
739
742
|
|
|
740
743
|
The type is the adapter's own name for it, here as everywhere `cast` is used.
|
|
741
744
|
|
|
745
|
+
Strings and numbers are where the adapters agree. A JSON boolean comes back as
|
|
746
|
+
`"1"` on SQLite, which turns `true` into SQL's `1` before the text cast, and
|
|
747
|
+
as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
|
|
748
|
+
MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
|
|
749
|
+
three.
|
|
750
|
+
|
|
742
751
|
Comparing a dug value with anything but a string raises `ArgumentError` rather
|
|
743
|
-
than being left to the adapters, which answer it three ways: `
|
|
744
|
-
true on SQLite, an error on PostgreSQL and true on MySQL, and
|
|
745
|
-
`
|
|
746
|
-
type was meant, and then all three agree. `
|
|
747
|
-
about — the JSON for a string carries its quotes, so `
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
752
|
+
than being left to the adapters, which answer it three ways: `dig_text(:n) ==
|
|
753
|
+
5` is true on SQLite, an error on PostgreSQL and true on MySQL, and
|
|
754
|
+
`dig_text(:flag) == true` is true, an error and false. `cast` is what says
|
|
755
|
+
which type was meant, and then all three agree. `dig` is refused the other way
|
|
756
|
+
about — the JSON for a string carries its quotes, so `dig(:name) == 'alice'`
|
|
757
|
+
is false, an error and true — and `dig_text` is the one that gives the value.
|
|
758
|
+
What `bury` and `except` give back is JSON as `dig`'s is, and is refused the
|
|
759
|
+
same way. A column, a function or another dug value on the right goes through
|
|
760
|
+
untouched; only a Ruby literal is refused. Arithmetic and the bit operators
|
|
761
|
+
are refused outright on both sides — `dig_text(:n) + 1` is 6 on SQLite, an
|
|
762
|
+
error on PostgreSQL and 6.0 on MariaDB — and `cast` settles those too.
|
|
751
763
|
|
|
752
764
|
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
753
765
|
the path to it. The document comes back changed rather than being written
|
|
@@ -763,8 +775,10 @@ Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
|
|
|
763
775
|
```
|
|
764
776
|
|
|
765
777
|
A whole document goes in as one — an object or an array rather than the string
|
|
766
|
-
that spells it — which each adapter takes its own way round
|
|
767
|
-
|
|
778
|
+
that spells it — which each adapter takes its own way round, and a boolean
|
|
779
|
+
goes in as JSON too, which SQLite would otherwise write as its `1`. `bury` is
|
|
780
|
+
not a Ruby method; it is the name Ruby considered for the other end of `dig`,
|
|
781
|
+
and
|
|
768
782
|
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
769
783
|
the others say `JSON_SET`.
|
|
770
784
|
|
|
@@ -787,31 +801,37 @@ keys, an element by index — and an array literal written without a type is
|
|
|
787
801
|
read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
|
|
788
802
|
`{draft}`, which is nothing, and says nothing about it.
|
|
789
803
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
804
|
+
A key deeper in is reached through the chain: `dig` reads the part out,
|
|
805
|
+
`except` takes the key from it, and `bury` puts it back:
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
Post.update_all { { meta: :meta.bury(:author, :meta.dig(:author).except(:email)) } }
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
What `dig` gives is a document, so the JSON operations read it — the same
|
|
812
|
+
question asked of a part of the document rather than of all of it:
|
|
793
813
|
|
|
794
814
|
```ruby
|
|
795
|
-
Post.where { :meta.
|
|
796
|
-
Post.where { :meta.
|
|
797
|
-
Post.update_all { { meta: :meta.
|
|
815
|
+
Post.where { :meta.dig(:author).key?(:email) }
|
|
816
|
+
Post.where { :meta.dig(:author).dig_text(:name) == 'alice' }
|
|
817
|
+
Post.update_all { { meta: :meta.dig(:author).bury(:name, 'alice') } }
|
|
798
818
|
```
|
|
799
819
|
|
|
800
820
|
Containment reads it too, on the adapters that have containment at all:
|
|
801
821
|
|
|
802
822
|
```ruby
|
|
803
|
-
Post.where { :meta.
|
|
823
|
+
Post.where { :meta.dig(:tags).contains?(['ruby']) }
|
|
804
824
|
```
|
|
805
825
|
|
|
806
|
-
Asking the same of `
|
|
807
|
-
reading text back as a document is where the adapters part company —
|
|
808
|
-
parses it, MySQL takes it as written, and PostgreSQL has no such
|
|
809
|
-
text at all.
|
|
826
|
+
Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
|
|
827
|
+
and reading text back as a document is where the adapters part company —
|
|
828
|
+
SQLite parses it, MySQL takes it as written, and PostgreSQL has no such
|
|
829
|
+
function for text at all.
|
|
810
830
|
|
|
811
831
|
`contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
812
832
|
there — later than the rest, since the adapter is only known when the SQL is
|
|
813
|
-
built. On PostgreSQL, `
|
|
814
|
-
`
|
|
833
|
+
built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
|
|
834
|
+
`key?`, `contains?`, `bury` and `except` want a `jsonb` column.
|
|
815
835
|
|
|
816
836
|
A key that is not a plain name travels as itself rather than being refused:
|
|
817
837
|
`dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
|
data/examples/json.rb
CHANGED
|
@@ -30,45 +30,45 @@ def show(title, relation, rows = nil)
|
|
|
30
30
|
puts
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# 1. Reading.
|
|
34
|
-
# into an object, an integer into an array. What comes back is the
|
|
35
|
-
# rather than the JSON around it, which is what a comparison wants.
|
|
36
|
-
show('
|
|
37
|
-
Document.select { [:name, :meta.
|
|
38
|
-
Document.select { [:name, :meta.
|
|
33
|
+
# 1. Reading. dig_text takes the path Hash#dig takes: a string or symbol
|
|
34
|
+
# steps into an object, an integer into an array. What comes back is the
|
|
35
|
+
# value rather than the JSON around it, which is what a comparison wants.
|
|
36
|
+
show('dig_text reads a value out of the document',
|
|
37
|
+
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] },
|
|
38
|
+
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] }.
|
|
39
39
|
map {|d| [d.name, d.author] })
|
|
40
40
|
|
|
41
41
|
show('an integer steps into an array',
|
|
42
|
-
Document.select { [:name, :meta.
|
|
43
|
-
Document.select { [:name, :meta.
|
|
42
|
+
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] },
|
|
43
|
+
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] }.
|
|
44
44
|
map {|d| [d.name, d.first_tag] })
|
|
45
45
|
|
|
46
46
|
# A dug value is an expression like any other, so it compares and orders.
|
|
47
|
-
show('
|
|
48
|
-
Document.where { :meta.
|
|
49
|
-
Document.where { :meta.
|
|
47
|
+
show('dig_text in a condition',
|
|
48
|
+
Document.where { :meta.dig_text(:author, :country) == 'JP' },
|
|
49
|
+
Document.where { :meta.dig_text(:author, :country) == 'JP' }.pluck(:name))
|
|
50
50
|
|
|
51
51
|
# A dug value is text, so a number goes through a cast. Comparing it with
|
|
52
52
|
# one instead is refused rather than left to the adapters, which answer that
|
|
53
53
|
# three ways: true here, an error on PostgreSQL, true on MySQL.
|
|
54
54
|
show('a number wants a cast',
|
|
55
|
-
Document.where { cast(:meta.
|
|
56
|
-
Document.where { cast(:meta.
|
|
55
|
+
Document.where { cast(:meta.dig_text(:views), 'integer') > 100 },
|
|
56
|
+
Document.where { cast(:meta.dig_text(:views), 'integer') > 100 }.pluck(:name))
|
|
57
57
|
|
|
58
58
|
begin
|
|
59
|
-
Document.where { :meta.
|
|
59
|
+
Document.where { :meta.dig_text(:views) > 100 }
|
|
60
60
|
rescue ArgumentError => e
|
|
61
61
|
puts '--- and without one it says so ---'
|
|
62
62
|
puts " #{e.message}"
|
|
63
63
|
puts
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
#
|
|
67
|
-
# or
|
|
68
|
-
# the reason a Ruby value is refused on this side too.
|
|
69
|
-
show('
|
|
70
|
-
Document.select { [:name, :meta.
|
|
71
|
-
Document.select { [:name, :meta.
|
|
66
|
+
# dig keeps the JSON, for a part of the document to be dug into further
|
|
67
|
+
# or asked the JSON questions. The quotes around the string are the sign of
|
|
68
|
+
# it -- and the reason a Ruby value is refused on this side too.
|
|
69
|
+
show('dig keeps the JSON',
|
|
70
|
+
Document.select { [:name, :meta.dig(:author).as(:author)] },
|
|
71
|
+
Document.select { [:name, :meta.dig(:author).as(:author)] }.
|
|
72
72
|
map {|d| [d.name, d.author] })
|
|
73
73
|
|
|
74
74
|
# 2. Asking whether a key is there at all, which is not the same as asking
|
|
@@ -217,15 +217,16 @@ module ActiveRecord
|
|
|
217
217
|
|
|
218
218
|
# Reading inside a JSON document, by the name of what Hash does. A
|
|
219
219
|
# string or symbol steps into an object, an integer into an array, and
|
|
220
|
-
# what comes back is
|
|
221
|
-
#
|
|
222
|
-
#
|
|
220
|
+
# what comes back is still JSON, the way Hash#dig hands back the
|
|
221
|
+
# structure itself -- for a document to be dug into further or asked
|
|
222
|
+
# the JSON questions. dig_text gives the value as text instead,
|
|
223
|
+
# which is what a comparison wants.
|
|
223
224
|
def dig(*path)
|
|
224
|
-
JsonPath.new(self, path)
|
|
225
|
+
JsonPath.new(self, path, as_json: true)
|
|
225
226
|
end
|
|
226
227
|
|
|
227
|
-
def
|
|
228
|
-
JsonPath.new(self, path
|
|
228
|
+
def dig_text(*path)
|
|
229
|
+
JsonPath.new(self, path)
|
|
229
230
|
end
|
|
230
231
|
|
|
231
232
|
# Keys taken out of a JSON document, by the name of what Hash does,
|
|
@@ -513,20 +514,26 @@ module ActiveRecord
|
|
|
513
514
|
#
|
|
514
515
|
# The path is turned into a string either way, so a key with a space or
|
|
515
516
|
# a quote in it travels as itself rather than having to be refused.
|
|
516
|
-
# What a dug value may be compared with.
|
|
517
|
+
# What a dug value may be compared with. dig_text gives text on every
|
|
517
518
|
# adapter, and what a text value compared with a number means is a
|
|
518
|
-
# question the three answer three ways: `
|
|
519
|
+
# question the three answer three ways: `dig_text(:n) == 5` is true on
|
|
519
520
|
# SQLite, an error on PostgreSQL and true on MySQL, while
|
|
520
|
-
# `
|
|
521
|
-
# which type was meant, and then all three agree.
|
|
521
|
+
# `dig_text(:flag) == true` is true, an error, and false. cast is what
|
|
522
|
+
# says which type was meant, and then all three agree.
|
|
523
|
+
#
|
|
524
|
+
# dig is refused the other way about: the JSON for a string carries
|
|
525
|
+
# its quotes, so `dig(:name) == 'alice'` is false on SQLite, an
|
|
526
|
+
# error on PostgreSQL and true on MySQL. dig_text is the one that
|
|
527
|
+
# gives the value. What bury and except give back is JSON as dig's
|
|
528
|
+
# is, and is refused the same way.
|
|
522
529
|
#
|
|
523
|
-
#
|
|
524
|
-
#
|
|
525
|
-
# error on PostgreSQL and true on MySQL. dig is the one that gives the
|
|
526
|
-
# value.
|
|
530
|
+
# A string against dig_text, and anything the block itself built -- a
|
|
531
|
+
# column, a function, another dug value -- go through untouched.
|
|
527
532
|
#
|
|
528
|
-
#
|
|
529
|
-
#
|
|
533
|
+
# Arithmetic and the bit operators are refused outright on both sides:
|
|
534
|
+
# `dig_text(:n) + 1` is 6 on SQLite, an error on PostgreSQL and 6.0 on
|
|
535
|
+
# MariaDB, and an expression on the right does not change what the
|
|
536
|
+
# dug side is.
|
|
530
537
|
module JsonComparable
|
|
531
538
|
%i[== != < <= > >=].each do |operator|
|
|
532
539
|
define_method(operator) do |other|
|
|
@@ -540,6 +547,16 @@ module ActiveRecord
|
|
|
540
547
|
def between?(min, max) = super(*check_each([min, max]))
|
|
541
548
|
def not_between?(min, max) = super(*check_each([min, max]))
|
|
542
549
|
|
|
550
|
+
%i[+ - * / & | ^ << >>].each do |operator|
|
|
551
|
+
define_method(operator) do |_other|
|
|
552
|
+
raise ArgumentError, arithmetic_refusal(operator)
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def ~
|
|
557
|
+
raise ArgumentError, arithmetic_refusal(:~)
|
|
558
|
+
end
|
|
559
|
+
|
|
543
560
|
private
|
|
544
561
|
|
|
545
562
|
# nil is left to the comparison itself, which says to use null?, and
|
|
@@ -552,9 +569,9 @@ module ActiveRecord
|
|
|
552
569
|
return if other.is_a?(::String) && !as_json
|
|
553
570
|
|
|
554
571
|
raise ArgumentError, as_json ?
|
|
555
|
-
"
|
|
556
|
-
"means something different on every adapter;
|
|
557
|
-
"
|
|
572
|
+
"#{json_source} gives JSON, and comparing it with #{other.inspect} " \
|
|
573
|
+
"means something different on every adapter; dig_text gives the value" :
|
|
574
|
+
"dig_text gives text, and comparing it with #{other.inspect} means " \
|
|
558
575
|
"something different on every adapter; cast it to the type meant"
|
|
559
576
|
end
|
|
560
577
|
|
|
@@ -566,20 +583,28 @@ module ActiveRecord
|
|
|
566
583
|
end
|
|
567
584
|
values
|
|
568
585
|
end
|
|
586
|
+
|
|
587
|
+
def arithmetic_refusal(operator)
|
|
588
|
+
as_json ?
|
|
589
|
+
"#{json_source} gives JSON, and #{operator} on it means something " \
|
|
590
|
+
"different on every adapter; cast dig_text to the type meant" :
|
|
591
|
+
"dig_text gives text, and #{operator} on it means something " \
|
|
592
|
+
"different on every adapter; cast it to the type meant"
|
|
593
|
+
end
|
|
569
594
|
end
|
|
570
595
|
|
|
571
|
-
# The JSON operations read a document, and what
|
|
572
|
-
# `
|
|
573
|
-
#
|
|
574
|
-
#
|
|
596
|
+
# The JSON operations read a document, and what dig gives is one:
|
|
597
|
+
# `dig(:author).key?(:email)` and `dig(:tags).contains?(...)` are the
|
|
598
|
+
# same question asked of a part of it, and the adapters answer them
|
|
599
|
+
# alike. What dig_text gives is text, and reading that as a document
|
|
575
600
|
# again is where they part company: SQLite parses it back and MySQL
|
|
576
601
|
# takes it as written, where PostgreSQL has no such function for text.
|
|
577
602
|
module JsonDocument
|
|
578
|
-
%i[dig
|
|
603
|
+
%i[dig dig_text key? contains? bury except].each do |name|
|
|
579
604
|
define_method(name) do |*args|
|
|
580
605
|
unless as_json
|
|
581
606
|
raise ArgumentError,
|
|
582
|
-
"
|
|
607
|
+
"dig_text gives text, and #{name} reads JSON; dig keeps it"
|
|
583
608
|
end
|
|
584
609
|
super(*args)
|
|
585
610
|
end
|
|
@@ -615,13 +640,18 @@ module ActiveRecord
|
|
|
615
640
|
extracted = Arel::Nodes::InfixOperation.new(
|
|
616
641
|
as_json ? :"->" : :"->>", document, Arel::Nodes.build_quoted(dollar_path))
|
|
617
642
|
# SQLite's ->> gives back the value with its type, where the other
|
|
618
|
-
# two give text. Cast so that `
|
|
619
|
-
# thing everywhere, and a number wants a cast everywhere too.
|
|
643
|
+
# two give text. Cast so that `dig_text(:n) == '5'` means the
|
|
644
|
+
# same thing everywhere, and a number wants a cast everywhere too.
|
|
620
645
|
as_json ? extracted : Arel::Nodes::NamedFunction.new(
|
|
621
646
|
'CAST', [Arel::Nodes::As.new(extracted, Arel::Nodes::SqlLiteral.new('text'))])
|
|
622
647
|
end
|
|
623
648
|
end
|
|
624
649
|
|
|
650
|
+
private
|
|
651
|
+
|
|
652
|
+
def json_source
|
|
653
|
+
'dig'
|
|
654
|
+
end
|
|
625
655
|
end
|
|
626
656
|
|
|
627
657
|
# Setting a value inside a JSON document, which is what bury does to what
|
|
@@ -630,6 +660,7 @@ module ActiveRecord
|
|
|
630
660
|
class JsonSet < Node
|
|
631
661
|
include Predications
|
|
632
662
|
include JsonSteps
|
|
663
|
+
include JsonComparable
|
|
633
664
|
|
|
634
665
|
attr_reader :operand, :path, :value
|
|
635
666
|
|
|
@@ -639,6 +670,11 @@ module ActiveRecord
|
|
|
639
670
|
@value = value
|
|
640
671
|
end
|
|
641
672
|
|
|
673
|
+
# Always JSON, which is what the comparison guard asks.
|
|
674
|
+
def as_json
|
|
675
|
+
true
|
|
676
|
+
end
|
|
677
|
+
|
|
642
678
|
def to_arel(table, model)
|
|
643
679
|
document = to_arel_operand(operand, table, model)
|
|
644
680
|
if AST.adapter_family(model) == :postgresql
|
|
@@ -663,21 +699,34 @@ module ActiveRecord
|
|
|
663
699
|
Arel::Nodes.build_quoted(JSON.generate(value))
|
|
664
700
|
end
|
|
665
701
|
|
|
666
|
-
# The others take the value as it is, except a whole document
|
|
667
|
-
#
|
|
668
|
-
#
|
|
702
|
+
# The others take the value as it is, except a whole document or a
|
|
703
|
+
# boolean, which go in as JSON: taken as they are, a document would
|
|
704
|
+
# be the string that spells it, and a boolean SQLite's own 1.
|
|
705
|
+
# SQLite's json() marks the literal for JSON_SET; the MySQL family,
|
|
706
|
+
# which has no json(), reads it with JSON_EXTRACT.
|
|
669
707
|
def other_value(table, model)
|
|
670
708
|
return to_arel_operand(value, table, model) if expression?
|
|
671
|
-
|
|
709
|
+
unless value.is_a?(::Hash) || value.is_a?(::Array) ||
|
|
710
|
+
value == true || value == false
|
|
711
|
+
return Arel::Nodes.build_quoted(value)
|
|
712
|
+
end
|
|
672
713
|
|
|
673
|
-
Arel::Nodes
|
|
674
|
-
|
|
675
|
-
|
|
714
|
+
json = Arel::Nodes.build_quoted(JSON.generate(value))
|
|
715
|
+
if AST.adapter_family(model) == :sqlite
|
|
716
|
+
Arel::Nodes::NamedFunction.new('json', [json])
|
|
717
|
+
else
|
|
718
|
+
Arel::Nodes::NamedFunction.new(
|
|
719
|
+
'JSON_EXTRACT', [json, Arel::Nodes.build_quoted('$')])
|
|
720
|
+
end
|
|
676
721
|
end
|
|
677
722
|
|
|
678
723
|
def expression?
|
|
679
724
|
value.is_a?(Node) || value.is_a?(::Symbol)
|
|
680
725
|
end
|
|
726
|
+
|
|
727
|
+
def json_source
|
|
728
|
+
'bury'
|
|
729
|
+
end
|
|
681
730
|
end
|
|
682
731
|
|
|
683
732
|
# Keys taken out of a JSON document. PostgreSQL subtracts them, the
|
|
@@ -685,6 +734,7 @@ module ActiveRecord
|
|
|
685
734
|
class JsonExcept < Node
|
|
686
735
|
include Predications
|
|
687
736
|
include JsonSteps
|
|
737
|
+
include JsonComparable
|
|
688
738
|
|
|
689
739
|
attr_reader :operand, :keys
|
|
690
740
|
|
|
@@ -693,10 +743,18 @@ module ActiveRecord
|
|
|
693
743
|
@keys = check_keys(keys)
|
|
694
744
|
end
|
|
695
745
|
|
|
746
|
+
def as_json
|
|
747
|
+
true
|
|
748
|
+
end
|
|
749
|
+
|
|
696
750
|
def to_arel(table, model)
|
|
697
751
|
document = to_arel_operand(operand, table, model)
|
|
698
|
-
|
|
699
|
-
|
|
752
|
+
if AST.adapter_family(model) == :postgresql
|
|
753
|
+
# Grouped because - binds tighter than #>: dug out of a document,
|
|
754
|
+
# the subtraction would otherwise take the path literal first.
|
|
755
|
+
return Arel::Nodes::InfixOperation.new(
|
|
756
|
+
:-, Arel::Nodes::Grouping.new(document), key_array)
|
|
757
|
+
end
|
|
700
758
|
|
|
701
759
|
Arel::Nodes::NamedFunction.new(
|
|
702
760
|
'JSON_REMOVE',
|
|
@@ -727,6 +785,10 @@ module ActiveRecord
|
|
|
727
785
|
end
|
|
728
786
|
keys
|
|
729
787
|
end
|
|
788
|
+
|
|
789
|
+
def json_source
|
|
790
|
+
'except'
|
|
791
|
+
end
|
|
730
792
|
end
|
|
731
793
|
|
|
732
794
|
# JSON containment: whether the document holds what is given.
|
data/test/test_block_syntax.rb
CHANGED
|
@@ -1891,115 +1891,144 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1891
1891
|
Doc.create!(name: 'two', meta: json_document({ 'n' => 9 }))
|
|
1892
1892
|
end
|
|
1893
1893
|
|
|
1894
|
-
def
|
|
1894
|
+
def test_dig_text_a_key
|
|
1895
1895
|
seed_docs
|
|
1896
|
-
assert_equal(%w[5 9], Doc.order(:name).select { :meta.
|
|
1896
|
+
assert_equal(%w[5 9], Doc.order(:name).select { :meta.dig_text(:n).as(:v) }.map(&:v))
|
|
1897
1897
|
end
|
|
1898
1898
|
|
|
1899
|
-
def
|
|
1899
|
+
def test_dig_text_a_path
|
|
1900
1900
|
seed_docs
|
|
1901
1901
|
assert_equal(['deep', nil],
|
|
1902
|
-
Doc.order(:name).select { :meta.
|
|
1902
|
+
Doc.order(:name).select { :meta.dig_text(:a, :b).as(:v) }.map(&:v))
|
|
1903
1903
|
end
|
|
1904
1904
|
|
|
1905
|
-
def
|
|
1905
|
+
def test_dig_text_an_array_index
|
|
1906
1906
|
seed_docs
|
|
1907
1907
|
assert_equal(['x', nil],
|
|
1908
|
-
Doc.order(:name).select { :meta.
|
|
1908
|
+
Doc.order(:name).select { :meta.dig_text(:tags, 0).as(:v) }.map(&:v))
|
|
1909
1909
|
end
|
|
1910
1910
|
|
|
1911
1911
|
# A key that is not a plain name travels as itself rather than being refused.
|
|
1912
|
-
def
|
|
1912
|
+
def test_dig_text_a_key_that_needs_quoting
|
|
1913
1913
|
seed_docs
|
|
1914
1914
|
assert_equal(['1', nil],
|
|
1915
|
-
Doc.order(:name).select { :meta.
|
|
1915
|
+
Doc.order(:name).select { :meta.dig_text(:'odd key').as(:v) }.map(&:v))
|
|
1916
1916
|
end
|
|
1917
1917
|
|
|
1918
|
-
#
|
|
1918
|
+
# dig_text gives text on every adapter -- SQLite's ->> would otherwise give the
|
|
1919
1919
|
# value with its type -- so a number is compared through a cast.
|
|
1920
|
-
def
|
|
1920
|
+
def test_dig_text_is_text_everywhere
|
|
1921
1921
|
seed_docs
|
|
1922
|
-
assert_equal(['one'], Doc.where { :meta.
|
|
1922
|
+
assert_equal(['one'], Doc.where { :meta.dig_text(:n) == '5' }.pluck(:name))
|
|
1923
1923
|
type = integer_type
|
|
1924
|
-
assert_equal(['two'], Doc.where { cast(:meta.
|
|
1924
|
+
assert_equal(['two'], Doc.where { cast(:meta.dig_text(:n), type) > 6 }.pluck(:name))
|
|
1925
1925
|
end
|
|
1926
1926
|
|
|
1927
|
-
def
|
|
1927
|
+
def test_dig_keeps_the_json
|
|
1928
1928
|
seed_docs
|
|
1929
|
-
value = Doc.where { :name == 'one' }.select { :meta.
|
|
1929
|
+
value = Doc.where { :name == 'one' }.select { :meta.dig(:tags).as(:v) }.first.v
|
|
1930
1930
|
assert_equal(%w[x y], value.is_a?(String) ? JSON.parse(value) : value)
|
|
1931
1931
|
end
|
|
1932
1932
|
|
|
1933
1933
|
# What text compared with a number means is a question the three adapters
|
|
1934
|
-
# answer three ways -- `
|
|
1935
|
-
# PostgreSQL and true on MySQL, and `
|
|
1934
|
+
# answer three ways -- `dig_text(:n) == 5` is true on SQLite, an error on
|
|
1935
|
+
# PostgreSQL and true on MySQL, and `dig_text(:flag) == true` is true, an error
|
|
1936
1936
|
# and false -- so the comparison is refused rather than left to them.
|
|
1937
|
-
def
|
|
1938
|
-
e = assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1937
|
+
def test_dig_text_refuses_a_comparison_with_anything_but_text
|
|
1938
|
+
e = assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:n) == 5 } }
|
|
1939
1939
|
assert_match(/cast/, e.message)
|
|
1940
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1941
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1942
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1943
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1944
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1940
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:n) != 5 } }
|
|
1941
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:n) > 6 } }
|
|
1942
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:flag) == true } }
|
|
1943
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:n).in?([1, 2]) } }
|
|
1944
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:n).between?(1, 9) } }
|
|
1945
1945
|
end
|
|
1946
1946
|
|
|
1947
1947
|
# A string is what a dug value compares to; so is anything the block built
|
|
1948
1948
|
# rather than wrote as a literal, since that is nobody's guess to make.
|
|
1949
|
-
def
|
|
1949
|
+
def test_dig_text_compares_with_text_and_with_expressions
|
|
1950
1950
|
seed_docs
|
|
1951
|
-
assert_equal(['one'], Doc.where { :meta.
|
|
1952
|
-
assert_equal([], Doc.where { :meta.
|
|
1953
|
-
assert_equal(['one'], Doc.where { :meta.
|
|
1951
|
+
assert_equal(['one'], Doc.where { :meta.dig_text(:n) == '5' }.pluck(:name))
|
|
1952
|
+
assert_equal([], Doc.where { :meta.dig_text(:n) == :name }.pluck(:name))
|
|
1953
|
+
assert_equal(['one'], Doc.where { :meta.dig_text(:n) == upper('5') }.pluck(:name))
|
|
1954
1954
|
type = integer_type
|
|
1955
|
-
assert_equal(['one'], Doc.where { cast(:meta.
|
|
1955
|
+
assert_equal(['one'], Doc.where { cast(:meta.dig_text(:n), type) == 5 }.pluck(:name))
|
|
1956
1956
|
end
|
|
1957
1957
|
|
|
1958
1958
|
# The JSON for a string carries its quotes, so the same comparison is
|
|
1959
1959
|
# refused the other way about: false on SQLite, an error on PostgreSQL and
|
|
1960
1960
|
# true on MySQL.
|
|
1961
|
-
def
|
|
1962
|
-
e = assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1963
|
-
assert_match(/
|
|
1964
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1961
|
+
def test_dig_refuses_a_comparison_with_a_ruby_value
|
|
1962
|
+
e = assert_raises(ArgumentError) { Doc.where { :meta.dig(:a) == 'deep' } }
|
|
1963
|
+
assert_match(/dig_text gives the value/, e.message)
|
|
1964
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig(:n) == 5 } }
|
|
1965
1965
|
end
|
|
1966
1966
|
|
|
1967
|
-
# What
|
|
1967
|
+
# What dig gives is a document, so the JSON operations read it: the
|
|
1968
1968
|
# same question asked of a part rather than of the whole.
|
|
1969
|
-
def
|
|
1969
|
+
def test_the_json_operations_read_what_dig_kept
|
|
1970
1970
|
seed_docs
|
|
1971
|
-
assert_equal(['one'], Doc.where { :meta.
|
|
1972
|
-
assert_equal(%w[one two], Doc.where { :meta.
|
|
1971
|
+
assert_equal(['one'], Doc.where { :meta.dig(:a).key?(:b) }.pluck(:name))
|
|
1972
|
+
assert_equal(%w[one two], Doc.where { :meta.dig(:n).not_null? }.order(:name).pluck(:name))
|
|
1973
1973
|
assert_equal(['one'],
|
|
1974
|
-
Doc.where { :meta.
|
|
1974
|
+
Doc.where { :meta.dig(:a).dig_text(:b) == 'deep' }.pluck(:name))
|
|
1975
1975
|
value = Doc.where { :name == 'one' }.
|
|
1976
|
-
select { :meta.
|
|
1976
|
+
select { :meta.dig(:a).bury(:b, 'x').as(:v) }.first.v
|
|
1977
1977
|
assert_equal('x', (value.is_a?(String) ? JSON.parse(value) : value)['b'])
|
|
1978
1978
|
end
|
|
1979
1979
|
|
|
1980
|
-
def
|
|
1980
|
+
def test_containment_reads_what_dig_kept
|
|
1981
1981
|
skip_without_json_containment
|
|
1982
1982
|
seed_docs
|
|
1983
|
-
assert_equal(['one'], Doc.where { :meta.
|
|
1984
|
-
assert_equal([], Doc.where { :meta.
|
|
1983
|
+
assert_equal(['one'], Doc.where { :meta.dig(:tags).contains?(['x']) }.pluck(:name))
|
|
1984
|
+
assert_equal([], Doc.where { :meta.dig(:tags).contains?(['z']) }.pluck(:name))
|
|
1985
1985
|
end
|
|
1986
1986
|
|
|
1987
1987
|
# Reading text back as a document is where the adapters part company:
|
|
1988
1988
|
# SQLite parses it, MySQL takes it as written, PostgreSQL has no such
|
|
1989
1989
|
# function for text at all.
|
|
1990
1990
|
def test_the_json_operations_are_refused_on_a_dug_value
|
|
1991
|
-
e = assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1992
|
-
assert_match(/
|
|
1993
|
-
assert_raises(ArgumentError) { Doc.where { :meta.
|
|
1994
|
-
assert_raises(ArgumentError) { Doc.select { :meta.
|
|
1995
|
-
assert_raises(ArgumentError) { Doc.select { :meta.
|
|
1996
|
-
assert_raises(ArgumentError) { Doc.select { :meta.
|
|
1997
|
-
assert_raises(ArgumentError) { Doc.select { :meta.
|
|
1991
|
+
e = assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:a).key?(:b) } }
|
|
1992
|
+
assert_match(/dig keeps it/, e.message)
|
|
1993
|
+
assert_raises(ArgumentError) { Doc.where { :meta.dig_text(:a).contains?(b: 1) } }
|
|
1994
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:a).dig_text(:b) } }
|
|
1995
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:a).dig(:b) } }
|
|
1996
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:a).bury(:b, 'x') } }
|
|
1997
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:a).except(:b) } }
|
|
1998
|
+
end
|
|
1999
|
+
|
|
2000
|
+
# What bury and except give back is JSON as dig's is, so the same guard
|
|
2001
|
+
# covers them; an expression on the right still goes through.
|
|
2002
|
+
def test_bury_and_except_refuse_a_comparison_with_a_ruby_value
|
|
2003
|
+
e = assert_raises(ArgumentError) { Doc.where { :meta.bury(:a, 1) == '{"a": 1}' } }
|
|
2004
|
+
assert_match(/bury gives JSON/, e.message)
|
|
2005
|
+
e = assert_raises(ArgumentError) { Doc.where { :meta.except(:a) == '{"b": 2}' } }
|
|
2006
|
+
assert_match(/except gives JSON/, e.message)
|
|
2007
|
+
assert_raises(ArgumentError) { Doc.where { :meta.except(:a).in?(['{}']) } }
|
|
2008
|
+
assert_sql(/ = /, Doc.where { :meta.except(:a) == :meta.except(:b) })
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
# Arithmetic is refused like a literal comparison is: text plus one is 6
|
|
2012
|
+
# on SQLite, an error on PostgreSQL and 6.0 on MariaDB. cast settles it,
|
|
2013
|
+
# and an expression on the right changes nothing about the dug side.
|
|
2014
|
+
def test_arithmetic_is_refused_on_a_dug_value
|
|
2015
|
+
e = assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:n) + 1 } }
|
|
2016
|
+
assert_match(/cast it to the type meant/, e.message)
|
|
2017
|
+
e = assert_raises(ArgumentError) { Doc.select { :meta.dig(:n) * 2 } }
|
|
2018
|
+
assert_match(/dig gives JSON/, e.message)
|
|
2019
|
+
assert_raises(ArgumentError) { Doc.select { :meta.dig_text(:n) + :name } }
|
|
2020
|
+
assert_raises(ArgumentError) { Doc.select { :meta.bury(:a, 1) - 1 } }
|
|
2021
|
+
assert_raises(ArgumentError) { Doc.select { ~:meta.dig(:n) } }
|
|
2022
|
+
seed_docs
|
|
2023
|
+
type = integer_type
|
|
2024
|
+
assert_equal(6,
|
|
2025
|
+
Doc.where { :name == 'one' }.
|
|
2026
|
+
select { (cast(:meta.dig_text(:n), type) + 1).as(:v) }.first.v.to_i)
|
|
1998
2027
|
end
|
|
1999
2028
|
|
|
2000
|
-
def
|
|
2029
|
+
def test_dig_text_from_a_qualified_column
|
|
2001
2030
|
seed_docs
|
|
2002
|
-
assert_equal(['one'], Doc.where { :docs[:meta].
|
|
2031
|
+
assert_equal(['one'], Doc.where { :docs[:meta].dig_text(:a, :b) == 'deep' }.pluck(:name))
|
|
2003
2032
|
end
|
|
2004
2033
|
|
|
2005
2034
|
def test_key
|
|
@@ -2022,7 +2051,7 @@ class TestBlockSyntax < Minitest::Test
|
|
|
2022
2051
|
|
|
2023
2052
|
def test_dig_needs_a_path
|
|
2024
2053
|
assert_raises(ArgumentError) { Doc.select { :meta.dig } }
|
|
2025
|
-
e = assert_raises(ArgumentError) { Doc.select { :meta.
|
|
2054
|
+
e = assert_raises(ArgumentError) { Doc.select { :meta.dig_text(1.5) } }
|
|
2026
2055
|
assert_match(/key or an array index/, e.message)
|
|
2027
2056
|
end
|
|
2028
2057
|
|
|
@@ -2277,13 +2306,27 @@ class TestBlockSyntax < Minitest::Test
|
|
|
2277
2306
|
assert_equal([1, 2], buried { { meta: :meta.bury(:arr, [1, 2]) } }['arr'])
|
|
2278
2307
|
end
|
|
2279
2308
|
|
|
2309
|
+
# A boolean goes in as JSON too: taken as it is, SQLite would write its 1.
|
|
2310
|
+
def test_bury_a_boolean
|
|
2311
|
+
assert_equal(true, buried { { meta: :meta.bury(:flag, true) } }['flag'])
|
|
2312
|
+
assert_equal(false, buried { { meta: :meta.bury(:flag, false) } }['flag'])
|
|
2313
|
+
end
|
|
2314
|
+
|
|
2315
|
+
def test_bury_a_null
|
|
2316
|
+
document = buried { { meta: :meta.bury(:gone, nil) } }
|
|
2317
|
+
assert(document.key?('gone'))
|
|
2318
|
+
assert_nil(document['gone'])
|
|
2319
|
+
end
|
|
2320
|
+
|
|
2280
2321
|
def test_bury_an_array_index
|
|
2281
2322
|
assert_equal(%w[7 y], buried { { meta: :meta.bury(:tags, 0, '7') } }['tags'])
|
|
2282
2323
|
end
|
|
2283
2324
|
|
|
2284
|
-
# The value can be read out of the document it is going into
|
|
2325
|
+
# The value can be read out of the document it is going into: dig keeps
|
|
2326
|
+
# the number a number, dig_text makes it the text of one.
|
|
2285
2327
|
def test_bury_an_expression
|
|
2286
|
-
assert_equal(
|
|
2328
|
+
assert_equal(5, buried { { meta: :meta.bury(:copy, :meta.dig(:n)) } }['copy'])
|
|
2329
|
+
assert_equal('5', buried { { meta: :meta.bury(:copy, :meta.dig_text(:n)) } }['copy'])
|
|
2287
2330
|
end
|
|
2288
2331
|
|
|
2289
2332
|
# It is an expression, so it does not have to be written anywhere.
|
|
@@ -2306,6 +2349,13 @@ class TestBlockSyntax < Minitest::Test
|
|
|
2306
2349
|
buried { { meta: :meta.except(:n) } })
|
|
2307
2350
|
end
|
|
2308
2351
|
|
|
2352
|
+
# A key deeper in is reached through the chain: dig reads the part out,
|
|
2353
|
+
# except takes the key from it, and bury puts it back. The dug document
|
|
2354
|
+
# needs its parentheses on PostgreSQL, where - binds tighter than #>.
|
|
2355
|
+
def test_except_a_nested_key_through_the_chain
|
|
2356
|
+
assert_equal({}, buried { { meta: :meta.bury(:a, :meta.dig(:a).except(:b)) } }['a'])
|
|
2357
|
+
end
|
|
2358
|
+
|
|
2309
2359
|
def test_except_several_keys
|
|
2310
2360
|
assert_equal({ 'a' => { 'b' => 'deep' } },
|
|
2311
2361
|
buried { { meta: :meta.except(:n, :tags, :'odd key') } })
|