activerecord-refined 0.8.0 → 0.9.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/test.yml +14 -0
- data/.rubocop.yml +393 -0
- data/Gemfile +5 -3
- data/README.md +254 -74
- data/Rakefile +31 -3
- data/activerecord-refined.gemspec +29 -15
- data/benchmark/query_building.rb +11 -11
- data/examples/aggregations.rb +13 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +79 -44
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +862 -272
- data/lib/active_record/refined.rb +260 -180
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +8 -5
- data/test/test_block_syntax.rb +879 -323
- data/test/test_helper.rb +56 -39
- metadata +130 -1
data/examples/json.rb
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
12
|
def up
|
|
11
|
-
create_table(:documents) {|t| t.string :name; t.json :meta }
|
|
13
|
+
create_table(:documents) { |t| t.string :name; t.json :meta }
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
Setup.new.up
|
|
@@ -16,12 +18,12 @@ Setup.new.up
|
|
|
16
18
|
class Document < ActiveRecord::Base
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
Document.create!(name:
|
|
20
|
-
meta: {
|
|
21
|
-
|
|
22
|
-
Document.create!(name:
|
|
23
|
-
meta: {
|
|
24
|
-
Document.create!(name:
|
|
21
|
+
Document.create!(name: "guide",
|
|
22
|
+
meta: { "author" => { "name" => "alice", "country" => "JP" },
|
|
23
|
+
"tags" => %w[ruby sql], "views" => 120, "draft" => false })
|
|
24
|
+
Document.create!(name: "notes",
|
|
25
|
+
meta: { "author" => { "name" => "bob" }, "tags" => ["ruby"], "views" => 8 })
|
|
26
|
+
Document.create!(name: "empty", meta: {})
|
|
25
27
|
|
|
26
28
|
def show(title, relation, rows = nil)
|
|
27
29
|
puts "--- #{title} ---"
|
|
@@ -33,32 +35,32 @@ end
|
|
|
33
35
|
# 1. Reading. dig_text takes the path Hash#dig takes: a string or symbol
|
|
34
36
|
# steps into an object, an integer into an array. What comes back is the
|
|
35
37
|
# value rather than the JSON around it, which is what a comparison wants.
|
|
36
|
-
show(
|
|
38
|
+
show("dig_text reads a value out of the document",
|
|
37
39
|
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] },
|
|
38
40
|
Document.select { [:name, :meta.dig_text(:author, :name).as(:author)] }.
|
|
39
|
-
map {|d| [d.name, d.author] })
|
|
41
|
+
map { |d| [d.name, d.author] })
|
|
40
42
|
|
|
41
|
-
show(
|
|
43
|
+
show("an integer steps into an array",
|
|
42
44
|
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] },
|
|
43
45
|
Document.select { [:name, :meta.dig_text(:tags, 0).as(:first_tag)] }.
|
|
44
|
-
map {|d| [d.name, d.first_tag] })
|
|
46
|
+
map { |d| [d.name, d.first_tag] })
|
|
45
47
|
|
|
46
48
|
# A dug value is an expression like any other, so it compares and orders.
|
|
47
|
-
show(
|
|
48
|
-
Document.where { :meta.dig_text(:author, :country) ==
|
|
49
|
-
Document.where { :meta.dig_text(:author, :country) ==
|
|
49
|
+
show("dig_text in a condition",
|
|
50
|
+
Document.where { :meta.dig_text(:author, :country) == "JP" },
|
|
51
|
+
Document.where { :meta.dig_text(:author, :country) == "JP" }.pluck(:name))
|
|
50
52
|
|
|
51
53
|
# A dug value is text, so a number goes through a cast. Comparing it with
|
|
52
54
|
# one instead is refused rather than left to the adapters, which answer that
|
|
53
55
|
# three ways: true here, an error on PostgreSQL, true on MySQL.
|
|
54
|
-
show(
|
|
55
|
-
Document.where { cast(:meta.dig_text(:views),
|
|
56
|
-
Document.where { cast(:meta.dig_text(:views),
|
|
56
|
+
show("a number wants a cast",
|
|
57
|
+
Document.where { cast(:meta.dig_text(:views), "integer") > 100 },
|
|
58
|
+
Document.where { cast(:meta.dig_text(:views), "integer") > 100 }.pluck(:name))
|
|
57
59
|
|
|
58
60
|
begin
|
|
59
61
|
Document.where { :meta.dig_text(:views) > 100 }
|
|
60
62
|
rescue ArgumentError => e
|
|
61
|
-
puts
|
|
63
|
+
puts "--- and without one it says so ---"
|
|
62
64
|
puts " #{e.message}"
|
|
63
65
|
puts
|
|
64
66
|
end
|
|
@@ -66,45 +68,45 @@ end
|
|
|
66
68
|
# dig keeps the JSON, for a part of the document to be dug into further
|
|
67
69
|
# or asked the JSON questions. The quotes around the string are the sign of
|
|
68
70
|
# it -- and the reason a Ruby value is refused on this side too.
|
|
69
|
-
show(
|
|
71
|
+
show("dig keeps the JSON",
|
|
70
72
|
Document.select { [:name, :meta.dig(:author).as(:author)] },
|
|
71
73
|
Document.select { [:name, :meta.dig(:author).as(:author)] }.
|
|
72
|
-
map {|d| [d.name, d.author] })
|
|
74
|
+
map { |d| [d.name, d.author] })
|
|
73
75
|
|
|
74
76
|
# 2. Asking whether a key is there at all, which is not the same as asking
|
|
75
77
|
# whether its value is null -- and is spelled key? for the reason Ruby's
|
|
76
78
|
# Hash spells it that way.
|
|
77
|
-
show(
|
|
79
|
+
show("key? asks whether the key is there",
|
|
78
80
|
Document.where { :meta.key?(:draft) },
|
|
79
81
|
Document.where { :meta.key?(:draft) }.pluck(:name))
|
|
80
82
|
|
|
81
83
|
# key? takes the one key Hash#key? takes; a path is what dig is for.
|
|
82
|
-
show(
|
|
84
|
+
show("and ! is its negation",
|
|
83
85
|
Document.where { !:meta.key?(:draft) },
|
|
84
86
|
Document.where { !:meta.key?(:draft) }.pluck(:name))
|
|
85
87
|
|
|
86
88
|
# 3. Writing. bury sets what dig reads, at the path given: JSON_SET on SQLite
|
|
87
89
|
# and MySQL, jsonb_set on PostgreSQL.
|
|
88
|
-
show(
|
|
89
|
-
Document.select { [:name, :meta.bury(:author, :country,
|
|
90
|
+
show("the expression bury builds",
|
|
91
|
+
Document.select { [:name, :meta.bury(:author, :country, "US").as(:updated)] })
|
|
90
92
|
|
|
91
93
|
# It is an expression, so it is what update_all sets the column to.
|
|
92
|
-
Document.where { :name ==
|
|
93
|
-
puts
|
|
94
|
+
Document.where { :name == "notes" }.update_all { { meta: :meta.bury(:author, :country, "US") } }
|
|
95
|
+
puts "--- what the update left behind ---"
|
|
94
96
|
puts " #{Document.find_by(name: 'notes').meta.inspect}"
|
|
95
97
|
puts
|
|
96
98
|
|
|
97
99
|
# A whole object or array goes in at once.
|
|
98
|
-
Document.where { :name ==
|
|
99
|
-
puts
|
|
100
|
+
Document.where { :name == "empty" }.update_all { { meta: :meta.bury(:author, { "name" => "carol" }) } }
|
|
101
|
+
puts "--- a whole object at once ---"
|
|
100
102
|
puts " #{Document.find_by(name: 'empty').meta.inspect}"
|
|
101
103
|
puts
|
|
102
104
|
|
|
103
105
|
# The path is what makes the SQL, so it cannot be empty.
|
|
104
106
|
begin
|
|
105
|
-
Document.select { :meta.bury(
|
|
107
|
+
Document.select { :meta.bury("US") }
|
|
106
108
|
rescue ArgumentError => e
|
|
107
|
-
puts
|
|
109
|
+
puts "--- bury needs a path ---"
|
|
108
110
|
puts " #{e.message}"
|
|
109
111
|
puts
|
|
110
112
|
end
|
|
@@ -112,11 +114,48 @@ end
|
|
|
112
114
|
# 4. Taking keys out again, as Hash#except does: keys of the document rather
|
|
113
115
|
# than a path, however many of them, and a key that is not there is no
|
|
114
116
|
# error. The document comes back changed, so it chains with bury.
|
|
115
|
-
show(
|
|
117
|
+
show("the expression except builds",
|
|
116
118
|
Document.select { [:name, :meta.except(:views, :draft).as(:trimmed)] })
|
|
117
119
|
|
|
118
|
-
Document.where { :name ==
|
|
119
|
-
update_all { {meta: :meta.bury(:author, :country,
|
|
120
|
-
puts
|
|
120
|
+
Document.where { :name == "guide" }.
|
|
121
|
+
update_all { { meta: :meta.bury(:author, :country, "JP").except(:tags, :views) } }
|
|
122
|
+
puts "--- buried and excepted in one statement ---"
|
|
121
123
|
puts " #{Document.find_by(name: 'guide').meta.inspect}"
|
|
122
124
|
puts
|
|
125
|
+
|
|
126
|
+
# 5. Gathering rows into one JSON document. json_arrayagg collects a value
|
|
127
|
+
# from each row into an array, json_objectagg a key and a value into an
|
|
128
|
+
# object; SQLite spells them json_group_array and json_group_object.
|
|
129
|
+
show("json_arrayagg collects the names",
|
|
130
|
+
Document.select { json_arrayagg(:name).as(:names) },
|
|
131
|
+
Document.select { json_arrayagg(:name).as(:names) }.take.names)
|
|
132
|
+
|
|
133
|
+
# A dug value goes in as JSON, so a document nests rather than landing as
|
|
134
|
+
# the string that spells it.
|
|
135
|
+
show("json_objectagg pairs each name with its author",
|
|
136
|
+
Document.select { json_objectagg(:name, :meta.dig(:author)).as(:authors) },
|
|
137
|
+
Document.select { json_objectagg(:name, :meta.dig(:author)).as(:authors) }.take.authors)
|
|
138
|
+
|
|
139
|
+
# 6. Building a document in the row. json_array takes values, json_object
|
|
140
|
+
# a Ruby hash whose values are expressions -- the keys are Ruby's, so a
|
|
141
|
+
# bare symbol stays free to mean a column on the value side.
|
|
142
|
+
show("json_object builds a document from columns",
|
|
143
|
+
Document.select { json_object(name: :name, author: :meta.dig(:author, :name)).as(:summary) },
|
|
144
|
+
Document.select { json_object(name: :name, author: :meta.dig(:author, :name)).as(:summary) }.
|
|
145
|
+
map(&:summary))
|
|
146
|
+
|
|
147
|
+
# Built and gathered: one document per row, collected into one per query.
|
|
148
|
+
show("json_arrayagg collects built documents",
|
|
149
|
+
Document.select { json_arrayagg(json_object(name: :name)).as(:docs) },
|
|
150
|
+
Document.select { json_arrayagg(json_object(name: :name)).as(:docs) }.take.docs)
|
|
151
|
+
|
|
152
|
+
# 7. The keys of the document, as Hash#keys gives them: a JSON array.
|
|
153
|
+
# Where the document is not an object -- a value, an array, a missing
|
|
154
|
+
# key -- the answer is NULL on every adapter.
|
|
155
|
+
show("keys lists what the document holds",
|
|
156
|
+
Document.select { [:name, :meta.keys.as(:fields)] },
|
|
157
|
+
Document.select { [:name, :meta.keys.as(:fields)] }.map { |d| [d.name, d.fields] })
|
|
158
|
+
|
|
159
|
+
show("keys of a part, through dig",
|
|
160
|
+
Document.select { [:name, :meta.dig(:author).keys.as(:fields)] },
|
|
161
|
+
Document.select { [:name, :meta.dig(:author).keys.as(:fields)] }.map { |d| [d.name, d.fields] })
|
data/examples/postgresql.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
4
|
+
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
require "etc"
|
|
6
8
|
|
|
7
9
|
# What is here is what SQLite cannot run, so unlike the other examples this
|
|
8
10
|
# one needs a server. Most of it is PostgreSQL's alone; ANY and ALL, JSON
|
|
@@ -11,11 +13,11 @@ require 'etc'
|
|
|
11
13
|
# and DB_PASSWORD.
|
|
12
14
|
begin
|
|
13
15
|
ActiveRecord::Base.establish_connection(
|
|
14
|
-
adapter:
|
|
15
|
-
host: ENV.fetch(
|
|
16
|
-
username: ENV.fetch(
|
|
17
|
-
password: ENV[
|
|
18
|
-
database: ENV.fetch(
|
|
16
|
+
adapter: "postgresql",
|
|
17
|
+
host: ENV.fetch("DB_HOST", "127.0.0.1"),
|
|
18
|
+
username: ENV.fetch("DB_USERNAME") { Etc.getlogin },
|
|
19
|
+
password: ENV["DB_PASSWORD"],
|
|
20
|
+
database: ENV.fetch("DB_NAME", "postgres"))
|
|
19
21
|
ActiveRecord::Base.lease_connection
|
|
20
22
|
rescue StandardError => e
|
|
21
23
|
abort "needs a PostgreSQL server: #{e.message}"
|
|
@@ -25,7 +27,7 @@ ActiveRecord::Migration.verbose = false
|
|
|
25
27
|
|
|
26
28
|
class Setup < ActiveRecord::Migration[8.1]
|
|
27
29
|
def up
|
|
28
|
-
create_table(:writers, force: true) {|t| t.string :name; t.string :country }
|
|
30
|
+
create_table(:writers, force: true) { |t| t.string :name; t.string :country }
|
|
29
31
|
create_table(:articles, force: true) do |t|
|
|
30
32
|
t.string :title
|
|
31
33
|
t.string :tags, array: true, default: []
|
|
@@ -48,17 +50,17 @@ end
|
|
|
48
50
|
|
|
49
51
|
Article.delete_all
|
|
50
52
|
Writer.delete_all
|
|
51
|
-
alice = Writer.create!(name:
|
|
52
|
-
bob = Writer.create!(name:
|
|
53
|
-
carol = Writer.create!(name:
|
|
54
|
-
Article.create!(title:
|
|
55
|
-
writer: alice, likes: 120, flags: 5, meta: {
|
|
56
|
-
Article.create!(title:
|
|
57
|
-
writer: alice, likes: 80, flags: 1, meta: {
|
|
58
|
-
Article.create!(title:
|
|
59
|
-
writer: bob, likes: 30, flags: 3, meta: {
|
|
60
|
-
Article.create!(title:
|
|
61
|
-
writer: carol, likes: 5, flags: 4, meta: {
|
|
53
|
+
alice = Writer.create!(name: "alice", country: "JP")
|
|
54
|
+
bob = Writer.create!(name: "bob", country: "JP")
|
|
55
|
+
carol = Writer.create!(name: "carol", country: "US")
|
|
56
|
+
Article.create!(title: "Refinements", tags: %w[ruby lang], scores: [5, 4],
|
|
57
|
+
writer: alice, likes: 120, flags: 5, meta: { "draft" => false, "lang" => "ja" })
|
|
58
|
+
Article.create!(title: "Rails 8", tags: %w[ruby rails], scores: [5],
|
|
59
|
+
writer: alice, likes: 80, flags: 1, meta: { "draft" => false })
|
|
60
|
+
Article.create!(title: "Postgres CTEs", tags: %w[sql db], scores: [3],
|
|
61
|
+
writer: bob, likes: 30, flags: 3, meta: { "draft" => true })
|
|
62
|
+
Article.create!(title: "100% pure", tags: ["100%", "a,b"], scores: [],
|
|
63
|
+
writer: carol, likes: 5, flags: 4, meta: { "draft" => true, "lang" => "ja" })
|
|
62
64
|
|
|
63
65
|
def show(title, relation, rows = nil)
|
|
64
66
|
puts "--- #{title} ---"
|
|
@@ -70,15 +72,15 @@ end
|
|
|
70
72
|
# 1. Array columns. Each name carries the meaning of its Ruby namesake:
|
|
71
73
|
# member? is Enumerable's element test, superset? and subset? are Set's
|
|
72
74
|
# containment, and intersect? is Array's "any element in common".
|
|
73
|
-
show(
|
|
74
|
-
Article.where { :tags.member?(
|
|
75
|
-
Article.where { :tags.member?(
|
|
75
|
+
show("member? tests one element",
|
|
76
|
+
Article.where { :tags.member?("ruby") },
|
|
77
|
+
Article.where { :tags.member?("ruby") }.pluck(:title))
|
|
76
78
|
|
|
77
|
-
show(
|
|
79
|
+
show("superset? requires every element",
|
|
78
80
|
Article.where { :tags.superset?(%w[ruby rails]) },
|
|
79
81
|
Article.where { :tags.superset?(%w[ruby rails]) }.pluck(:title))
|
|
80
82
|
|
|
81
|
-
show(
|
|
83
|
+
show("subset? and intersect?",
|
|
82
84
|
Article.where { :tags.intersect?(%w[sql lang]) },
|
|
83
85
|
[
|
|
84
86
|
Article.where { :tags.subset?(%w[ruby lang extra]) }.pluck(:title),
|
|
@@ -87,46 +89,46 @@ show('subset? and intersect?',
|
|
|
87
89
|
|
|
88
90
|
# The element is serialized into an array literal, so commas, quotes and %
|
|
89
91
|
# are matched literally rather than parsed or treated as wildcards.
|
|
90
|
-
show(
|
|
91
|
-
Article.where { :tags.member?(
|
|
92
|
-
Article.where { :tags.member?(
|
|
92
|
+
show("elements are matched literally",
|
|
93
|
+
Article.where { :tags.member?("a,b") },
|
|
94
|
+
Article.where { :tags.member?("a,b") }.pluck(:title))
|
|
93
95
|
|
|
94
96
|
# member? works on any element type; the literal is coerced to the column's.
|
|
95
|
-
show(
|
|
97
|
+
show("a numeric array",
|
|
96
98
|
Article.where { :scores.member?(4) },
|
|
97
99
|
Article.where { :scores.member?(4) }.pluck(:title))
|
|
98
100
|
|
|
99
101
|
# 2. Regular expressions. =~ and !~ become ~ and !~ here, REGEXP on MySQL.
|
|
100
102
|
# SQLite has no regexp operator, which is why this example is not one of
|
|
101
103
|
# the portable ones.
|
|
102
|
-
show(
|
|
103
|
-
Article.where { :title =~
|
|
104
|
-
Article.where { :title =~
|
|
104
|
+
show("=~ matches a regular expression",
|
|
105
|
+
Article.where { :title =~ "^R" },
|
|
106
|
+
Article.where { :title =~ "^R" }.pluck(:title))
|
|
105
107
|
|
|
106
|
-
show(
|
|
108
|
+
show("a Regexp literal works too, and !~ negates",
|
|
107
109
|
Article.where { :title !~ /s$/ },
|
|
108
110
|
Article.where { :title !~ /s$/ }.pluck(:title))
|
|
109
111
|
|
|
110
112
|
# 3. Case. like? is case-sensitive LIKE everywhere, including here, where
|
|
111
113
|
# Arel would otherwise reach for ILIKE. ilike? is the one that asks for
|
|
112
114
|
# it, and casecmp? is case-insensitive equality.
|
|
113
|
-
show(
|
|
114
|
-
Article.where { :title.ilike?(
|
|
115
|
+
show("like? stays case-sensitive; ilike? does not",
|
|
116
|
+
Article.where { :title.ilike?("r%") },
|
|
115
117
|
[
|
|
116
|
-
Article.where { :title.like?(
|
|
117
|
-
Article.where { :title.ilike?(
|
|
118
|
+
Article.where { :title.like?("r%") }.pluck(:title),
|
|
119
|
+
Article.where { :title.ilike?("r%") }.pluck(:title),
|
|
118
120
|
])
|
|
119
121
|
|
|
120
122
|
# 4. NULL as a value. PostgreSQL spells this IS [NOT] DISTINCT FROM.
|
|
121
|
-
show(
|
|
122
|
-
Article.where { :title.not_distinct_from?(
|
|
123
|
-
Article.where { :title.not_distinct_from?(
|
|
123
|
+
show("null-safe comparison",
|
|
124
|
+
Article.where { :title.not_distinct_from?("Rails 8") },
|
|
125
|
+
Article.where { :title.not_distinct_from?("Rails 8") }.pluck(:title))
|
|
124
126
|
|
|
125
127
|
# 5. Keeping one row per group. DISTINCT ON is PostgreSQL's: the first row of
|
|
126
128
|
# each group the order brings up, which is why the order has to start with
|
|
127
129
|
# what the distinct is on. Arel refuses to write it for the others, so the
|
|
128
130
|
# portable shape is a row_number window in a subquery.
|
|
129
|
-
show(
|
|
131
|
+
show("the most liked article of each writer",
|
|
130
132
|
Article.distinct_on { :writer_id }.order { [:writer_id, :likes.desc] },
|
|
131
133
|
Article.distinct_on { :writer_id }.order { [:writer_id, :likes.desc] }.
|
|
132
134
|
pluck(:title))
|
|
@@ -139,11 +141,13 @@ sets = Article.
|
|
|
139
141
|
joins(:writers) { :writers[:id] == :articles[:writer_id] }.
|
|
140
142
|
group { grouping_sets([:writers[:country]], [:articles[:writer_id]], []) }.
|
|
141
143
|
select { [:writers[:country], :articles[:writer_id], sum(:likes).as(:likes)] }
|
|
142
|
-
show(
|
|
144
|
+
show("by country, by writer, and both together",
|
|
143
145
|
sets,
|
|
144
|
-
sets.map {|a| [a.country, a.writer_id, a.likes] })
|
|
146
|
+
sets.map { |a| [a.country, a.writer_id, a.likes] })
|
|
145
147
|
|
|
146
|
-
|
|
148
|
+
# rollup runs on MySQL and MariaDB too, as their WITH ROLLUP; the other two
|
|
149
|
+
# groupings are PostgreSQL's alone.
|
|
150
|
+
show("rollup is the nested case of the same thing",
|
|
147
151
|
Article.group { rollup(:writer_id, :flags) }.select { [:writer_id, :flags, count(:*).as(:n)] })
|
|
148
152
|
|
|
149
153
|
# 7. Lateral joins. A lateral join lets the relation joined see the row being
|
|
@@ -155,14 +159,14 @@ top = Article.select { :title }.
|
|
|
155
159
|
order { :likes.desc }.limit(1)
|
|
156
160
|
lateral = Writer.left_outer_joins(top.lateral, as: :top).
|
|
157
161
|
select { [:name, :top[:title].as(:top_article)] }
|
|
158
|
-
show(
|
|
162
|
+
show("the top article beside each writer",
|
|
159
163
|
lateral,
|
|
160
|
-
lateral.map {|w| [w.name, w.top_article] })
|
|
164
|
+
lateral.map { |w| [w.name, w.top_article] })
|
|
161
165
|
|
|
162
166
|
# 8. ANY and ALL, which quantify a comparison over a subquery where a scalar
|
|
163
167
|
# subquery would have to return the one row. MySQL has them too; SQLite
|
|
164
168
|
# has neither, and says so rather than leaving its parser to.
|
|
165
|
-
show(
|
|
169
|
+
show("more liked than some article of alice, and than all of them",
|
|
166
170
|
Article.where { :likes > any(Article.where { :writer_id == alice.id }.select(:likes)) },
|
|
167
171
|
[
|
|
168
172
|
Article.where { :likes > any(Article.where { :writer_id == alice.id }.select(:likes)) }.
|
|
@@ -174,15 +178,22 @@ show('more liked than some article of alice, and than all of them',
|
|
|
174
178
|
# 9. JSON containment and the bit aggregates, which PostgreSQL and MySQL have
|
|
175
179
|
# and SQLite does not. contains? asks whether the document holds the one
|
|
176
180
|
# given, which is @> here and JSON_CONTAINS on MySQL.
|
|
177
|
-
show(
|
|
181
|
+
show("containment asks about a whole document",
|
|
178
182
|
Article.where { :meta.contains?(draft: true) },
|
|
179
183
|
Article.where { :meta.contains?(draft: true) }.pluck(:title))
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
# A JSON comparison belongs to the JSON types -- jsonb here, MySQL's JSON
|
|
186
|
+
# too: numbers compare as numbers, documents structurally. SQLite and
|
|
187
|
+
# MariaDB have only the text of each and raise.
|
|
188
|
+
show("a dug value compares with a Ruby one directly",
|
|
189
|
+
Article.where { :meta.dig(:draft) == true },
|
|
190
|
+
Article.where { :meta.dig(:draft) == true }.pluck(:title))
|
|
191
|
+
|
|
192
|
+
show("the bits every article has, and the bits any of them has",
|
|
182
193
|
Article.select { [bit_and(:flags).as(:common), bit_or(:flags).as(:any)] },
|
|
183
194
|
Article.select { [bit_and(:flags).as(:common), bit_or(:flags).as(:any)] }.
|
|
184
|
-
map {|a| [a.common, a.any] })
|
|
195
|
+
map { |a| [a.common, a.any] })
|
|
185
196
|
|
|
186
|
-
show(
|
|
197
|
+
show("and how many bits are set in each",
|
|
187
198
|
Article.select { [:title, bit_count(:flags).as(:bits)] },
|
|
188
|
-
Article.select { [:title, bit_count(:flags).as(:bits)] }.map {|a| [a.title, a.bits] })
|
|
199
|
+
Article.select { [:title, bit_count(:flags).as(:bits)] }.map { |a| [a.title, a.bits] })
|
data/examples/predicates.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
@@ -21,11 +23,11 @@ Setup.new.up
|
|
|
21
23
|
class Account < ActiveRecord::Base
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
Account.create!(login:
|
|
25
|
-
Account.create!(login:
|
|
26
|
-
Account.create!(login:
|
|
27
|
-
Account.create!(login:
|
|
28
|
-
Account.create!(login:
|
|
26
|
+
Account.create!(login: "alice", country: "JP", age: 60, verified: true)
|
|
27
|
+
Account.create!(login: "bob", country: "JP", age: 50, verified: false)
|
|
28
|
+
Account.create!(login: "carol", country: "US", age: 45, verified: true)
|
|
29
|
+
Account.create!(login: "100%_pure", country: nil, age: 30)
|
|
30
|
+
Account.create!(login: "1002000", country: "US", age: 25, verified: false)
|
|
29
31
|
|
|
30
32
|
def show(title, relation, rows)
|
|
31
33
|
puts "--- #{title} ---"
|
|
@@ -36,38 +38,38 @@ end
|
|
|
36
38
|
|
|
37
39
|
# 1. Ranges and sets. in? is one name for "belongs to this set": a Range
|
|
38
40
|
# becomes BETWEEN, an endless Range a bare comparison, a list an IN.
|
|
39
|
-
show(
|
|
41
|
+
show("in? with a Range becomes BETWEEN",
|
|
40
42
|
Account.where { :age.in?(40..55) },
|
|
41
43
|
Account.where { :age.in?(40..55) }.pluck(:login))
|
|
42
44
|
|
|
43
|
-
show(
|
|
45
|
+
show("endless range and IN",
|
|
44
46
|
Account.where { :age.in?(50..) | :country.in?(%w[US]) },
|
|
45
47
|
Account.where { :age.in?(50..) | :country.in?(%w[US]) }.pluck(:login))
|
|
46
48
|
|
|
47
49
|
# 2. NULL. = NULL is never true in SQL, so == nil raises and the test is
|
|
48
50
|
# spelled null?. not_distinct_from? is the null-safe equality, which is
|
|
49
51
|
# what to reach for when the value may or may not be nil.
|
|
50
|
-
show(
|
|
52
|
+
show("null? and its negation",
|
|
51
53
|
Account.where { :country.null? },
|
|
52
54
|
Account.where { :country.null? }.pluck(:login))
|
|
53
55
|
|
|
54
56
|
wanted = nil
|
|
55
|
-
show(
|
|
57
|
+
show("not_distinct_from? matches NULL to nil",
|
|
56
58
|
Account.where { :country.not_distinct_from?(wanted) },
|
|
57
59
|
Account.where { :country.not_distinct_from?(wanted) }.pluck(:login))
|
|
58
60
|
|
|
59
61
|
# Unlike !=, distinct_from? keeps the NULL row.
|
|
60
|
-
show(
|
|
61
|
-
Account.where { :country.distinct_from?(
|
|
62
|
+
show("distinct_from? keeps NULLs, != drops them",
|
|
63
|
+
Account.where { :country.distinct_from?("JP") },
|
|
62
64
|
[
|
|
63
|
-
Account.where { :country.distinct_from?(
|
|
64
|
-
Account.where { :country !=
|
|
65
|
+
Account.where { :country.distinct_from?("JP") }.pluck(:login),
|
|
66
|
+
Account.where { :country != "JP" }.pluck(:login),
|
|
65
67
|
])
|
|
66
68
|
|
|
67
69
|
# A boolean column has true? and false?, which become IS TRUE and IS FALSE,
|
|
68
70
|
# and the negation of each. All four are spelled and answered the same way by
|
|
69
71
|
# every adapter, NULL included.
|
|
70
|
-
show(
|
|
72
|
+
show("the four truth tests",
|
|
71
73
|
Account.where { :verified.false? },
|
|
72
74
|
[
|
|
73
75
|
Account.where { :verified.true? }.pluck(:login),
|
|
@@ -79,7 +81,7 @@ show('the four truth tests',
|
|
|
79
81
|
# They answer for a NULL row where a comparison against the literal does not,
|
|
80
82
|
# so it is negating them that tells the two apart: not_true? has the
|
|
81
83
|
# unverified accounts and the one never asked, !(== true) only the former.
|
|
82
|
-
show(
|
|
84
|
+
show("not_true? keeps the NULLs that != TRUE drops",
|
|
83
85
|
Account.where { :verified.not_true? },
|
|
84
86
|
[
|
|
85
87
|
Account.where { :verified.not_true? }.pluck(:login),
|
|
@@ -89,32 +91,32 @@ show('not_true? keeps the NULLs that != TRUE drops',
|
|
|
89
91
|
# 3. Text. like? takes a pattern; start_with?, end_with? and include? take
|
|
90
92
|
# literals, so % and _ in them are escaped rather than matched as
|
|
91
93
|
# wildcards. Note the last row matches only the literal-minded one.
|
|
92
|
-
show(
|
|
93
|
-
Account.where { :login.like?(
|
|
94
|
-
Account.where { :login.like?(
|
|
94
|
+
show("like? takes a pattern",
|
|
95
|
+
Account.where { :login.like?("%rol") },
|
|
96
|
+
Account.where { :login.like?("%rol") }.pluck(:login))
|
|
95
97
|
|
|
96
|
-
show(
|
|
97
|
-
Account.where { :login.start_with?(
|
|
98
|
-
Account.where { :login.start_with?(
|
|
98
|
+
show("start_with? takes any number of literals, like String#start_with?",
|
|
99
|
+
Account.where { :login.start_with?("al", "bo") },
|
|
100
|
+
Account.where { :login.start_with?("al", "bo") }.pluck(:login))
|
|
99
101
|
|
|
100
102
|
# The % in the argument is escaped, so only the account whose login really
|
|
101
103
|
# contains "100%" matches; the same pattern spelled with like? treats it as a
|
|
102
104
|
# wildcard and catches 1002000 as well.
|
|
103
|
-
show(
|
|
104
|
-
Account.where { :login.include?(
|
|
105
|
+
show("include? escapes wildcards; like? does not",
|
|
106
|
+
Account.where { :login.include?("100%") },
|
|
105
107
|
[
|
|
106
|
-
Account.where { :login.include?(
|
|
107
|
-
Account.where { :login.like?(
|
|
108
|
+
Account.where { :login.include?("100%") }.pluck(:login),
|
|
109
|
+
Account.where { :login.like?("%100%%") }.pluck(:login),
|
|
108
110
|
])
|
|
109
111
|
|
|
110
112
|
# casecmp? folds both sides rather than trusting the collation, so it means
|
|
111
113
|
# the same thing on every adapter.
|
|
112
|
-
show(
|
|
113
|
-
Account.where { :login.casecmp?(
|
|
114
|
-
Account.where { :login.casecmp?(
|
|
114
|
+
show("casecmp? is case-insensitive equality",
|
|
115
|
+
Account.where { :login.casecmp?("AlIcE") },
|
|
116
|
+
Account.where { :login.casecmp?("AlIcE") }.pluck(:login))
|
|
115
117
|
|
|
116
118
|
# 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
|
|
117
119
|
# the comparison operators, hence the parentheses around each comparison.
|
|
118
|
-
show(
|
|
120
|
+
show("compound conditions",
|
|
119
121
|
Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) },
|
|
120
122
|
Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) }.pluck(:login))
|
data/examples/subqueries.rb
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
12
|
def up
|
|
11
|
-
create_table(:authors) {|t| t.string :name }
|
|
12
|
-
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
|
+
create_table(:authors) { |t| t.string :name }
|
|
14
|
+
create_table(:posts) { |t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
Setup.new.up
|
|
@@ -24,44 +26,44 @@ class Post < ActiveRecord::Base
|
|
|
24
26
|
def self.published = where { :published == true }
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
alice
|
|
28
|
-
bob
|
|
29
|
-
|
|
29
|
+
alice = Author.create!(name: "alice")
|
|
30
|
+
bob = Author.create!(name: "bob")
|
|
31
|
+
Author.create!(name: "quiet")
|
|
30
32
|
|
|
31
|
-
Post.create!(title:
|
|
32
|
-
Post.create!(title:
|
|
33
|
-
Post.create!(title:
|
|
33
|
+
Post.create!(title: "refinements", author_id: alice.id, likes: 100, published: true)
|
|
34
|
+
Post.create!(title: "parser", author_id: alice.id, likes: 40, published: true)
|
|
35
|
+
Post.create!(title: "draft", author_id: bob.id, likes: 5, published: false)
|
|
34
36
|
|
|
35
37
|
def show(title, relation)
|
|
36
38
|
puts "--- #{title} ---"
|
|
37
39
|
puts relation.to_sql
|
|
38
|
-
puts relation.pluck(relation.model.table_name ==
|
|
40
|
+
puts relation.pluck(relation.model.table_name == "authors" ? :name : :title).inspect
|
|
39
41
|
puts
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
# 1. in? takes a relation as a subquery. With an explicit select list the
|
|
43
45
|
# subquery selects that column; without one it selects the primary key,
|
|
44
46
|
# the same way Active Record's own where(id: relation) does.
|
|
45
|
-
show(
|
|
47
|
+
show("in? with a subquery",
|
|
46
48
|
Author.where { :id.in?(Post.published.select(:author_id)) })
|
|
47
49
|
|
|
48
50
|
# 2. exists? asks whether the subquery returns a row. Correlate it with the
|
|
49
51
|
# outer table through qualified columns; the inner where block is the same
|
|
50
52
|
# DSL as the outer one.
|
|
51
|
-
show(
|
|
53
|
+
show("exists?",
|
|
52
54
|
Author.where { exists?(Post.published.where { :posts[:author_id] == :authors[:id] }) })
|
|
53
55
|
|
|
54
|
-
show(
|
|
56
|
+
show("!exists? finds what has nothing to show",
|
|
55
57
|
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) })
|
|
56
58
|
|
|
57
59
|
# 3. A relation on the right of a comparison is a scalar subquery. It has to
|
|
58
60
|
# yield a single value, so unlike in? there is no default select list and
|
|
59
61
|
# one is required.
|
|
60
|
-
show(
|
|
62
|
+
show("a scalar subquery on the right of a comparison",
|
|
61
63
|
Post.where { :likes >= Post.select { avg(:likes) } })
|
|
62
64
|
|
|
63
65
|
# The three compose like any other predicate.
|
|
64
|
-
show(
|
|
66
|
+
show("combined with the rest of the vocabulary",
|
|
65
67
|
Author.
|
|
66
68
|
where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.
|
|
67
69
|
where { !:id.in?(Post.where { :published == false }.select(:author_id)) })
|