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.
data/examples/json.rb CHANGED
@@ -1,14 +1,16 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
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: 'guide',
20
- meta: {'author' => {'name' => 'alice', 'country' => 'JP'},
21
- 'tags' => %w[ruby sql], 'views' => 120, 'draft' => false})
22
- Document.create!(name: 'notes',
23
- meta: {'author' => {'name' => 'bob'}, 'tags' => ['ruby'], 'views' => 8})
24
- Document.create!(name: 'empty', meta: {})
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('dig_text reads a value out of the document',
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('an integer steps into an array',
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('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))
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('a number wants a cast',
55
- Document.where { cast(:meta.dig_text(:views), 'integer') > 100 },
56
- Document.where { cast(:meta.dig_text(:views), 'integer') > 100 }.pluck(:name))
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 '--- and without one it says so ---'
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('dig keeps the JSON',
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('key? asks whether the key is there',
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('and ! is its negation',
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('the expression bury builds',
89
- Document.select { [:name, :meta.bury(:author, :country, 'US').as(:updated)] })
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 == 'notes' }.update_all { {meta: :meta.bury(:author, :country, 'US')} }
93
- puts '--- what the update left behind ---'
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 == 'empty' }.update_all { {meta: :meta.bury(:author, {'name' => 'carol'})} }
99
- puts '--- a whole object at once ---'
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('US') }
107
+ Document.select { :meta.bury("US") }
106
108
  rescue ArgumentError => e
107
- puts '--- bury needs a path ---'
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('the expression except builds',
117
+ show("the expression except builds",
116
118
  Document.select { [:name, :meta.except(:views, :draft).as(:trimmed)] })
117
119
 
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 ---'
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] })
@@ -1,8 +1,10 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
5
- require 'etc'
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: 'postgresql',
15
- host: ENV.fetch('DB_HOST', '127.0.0.1'),
16
- username: ENV.fetch('DB_USERNAME') { Etc.getlogin },
17
- password: ENV['DB_PASSWORD'],
18
- database: ENV.fetch('DB_NAME', 'postgres'))
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: 'alice', country: 'JP')
52
- bob = Writer.create!(name: 'bob', country: 'JP')
53
- carol = Writer.create!(name: 'carol', country: 'US')
54
- Article.create!(title: 'Refinements', tags: %w[ruby lang], scores: [5, 4],
55
- writer: alice, likes: 120, flags: 5, meta: {'draft' => false, 'lang' => 'ja'})
56
- Article.create!(title: 'Rails 8', tags: %w[ruby rails], scores: [5],
57
- writer: alice, likes: 80, flags: 1, meta: {'draft' => false})
58
- Article.create!(title: 'Postgres CTEs', tags: %w[sql db], scores: [3],
59
- writer: bob, likes: 30, flags: 3, meta: {'draft' => true})
60
- Article.create!(title: '100% pure', tags: ['100%', 'a,b'], scores: [],
61
- writer: carol, likes: 5, flags: 4, meta: {'draft' => true, 'lang' => 'ja'})
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('member? tests one element',
74
- Article.where { :tags.member?('ruby') },
75
- Article.where { :tags.member?('ruby') }.pluck(:title))
75
+ show("member? tests one element",
76
+ Article.where { :tags.member?("ruby") },
77
+ Article.where { :tags.member?("ruby") }.pluck(:title))
76
78
 
77
- show('superset? requires every element',
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('subset? and intersect?',
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('elements are matched literally',
91
- Article.where { :tags.member?('a,b') },
92
- Article.where { :tags.member?('a,b') }.pluck(:title))
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('a numeric array',
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('=~ matches a regular expression',
103
- Article.where { :title =~ '^R' },
104
- Article.where { :title =~ '^R' }.pluck(:title))
104
+ show("=~ matches a regular expression",
105
+ Article.where { :title =~ "^R" },
106
+ Article.where { :title =~ "^R" }.pluck(:title))
105
107
 
106
- show('a Regexp literal works too, and !~ negates',
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('like? stays case-sensitive; ilike? does not',
114
- Article.where { :title.ilike?('r%') },
115
+ show("like? stays case-sensitive; ilike? does not",
116
+ Article.where { :title.ilike?("r%") },
115
117
  [
116
- Article.where { :title.like?('r%') }.pluck(:title),
117
- Article.where { :title.ilike?('r%') }.pluck(:title),
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('null-safe comparison',
122
- Article.where { :title.not_distinct_from?('Rails 8') },
123
- Article.where { :title.not_distinct_from?('Rails 8') }.pluck(:title))
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('the most liked article of each writer',
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('by country, by writer, and both together',
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
- show('rollup is the nested case of the same thing',
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('the top article beside each writer',
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('more liked than some article of alice, and than all of them',
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('containment asks about a whole document',
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
- show('the bits every article has, and the bits any of them has',
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('and how many bits are set in each',
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] })
@@ -1,9 +1,11 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
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: 'alice', country: 'JP', age: 60, verified: true)
25
- Account.create!(login: 'bob', country: 'JP', age: 50, verified: false)
26
- Account.create!(login: 'carol', country: 'US', age: 45, verified: true)
27
- Account.create!(login: '100%_pure', country: nil, age: 30)
28
- Account.create!(login: '1002000', country: 'US', age: 25, verified: false)
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('in? with a Range becomes BETWEEN',
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('endless range and IN',
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('null? and its negation',
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('not_distinct_from? matches NULL to nil',
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('distinct_from? keeps NULLs, != drops them',
61
- Account.where { :country.distinct_from?('JP') },
62
+ show("distinct_from? keeps NULLs, != drops them",
63
+ Account.where { :country.distinct_from?("JP") },
62
64
  [
63
- Account.where { :country.distinct_from?('JP') }.pluck(:login),
64
- Account.where { :country != 'JP' }.pluck(:login),
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('the four truth tests',
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('not_true? keeps the NULLs that != TRUE drops',
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('like? takes a pattern',
93
- Account.where { :login.like?('%rol') },
94
- Account.where { :login.like?('%rol') }.pluck(:login))
94
+ show("like? takes a pattern",
95
+ Account.where { :login.like?("%rol") },
96
+ Account.where { :login.like?("%rol") }.pluck(:login))
95
97
 
96
- show('start_with? takes any number of literals, like String#start_with?',
97
- Account.where { :login.start_with?('al', 'bo') },
98
- Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
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('include? escapes wildcards; like? does not',
104
- Account.where { :login.include?('100%') },
105
+ show("include? escapes wildcards; like? does not",
106
+ Account.where { :login.include?("100%") },
105
107
  [
106
- Account.where { :login.include?('100%') }.pluck(:login),
107
- Account.where { :login.like?('%100%%') }.pluck(:login),
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('casecmp? is case-insensitive equality',
113
- Account.where { :login.casecmp?('AlIcE') },
114
- Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
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('compound conditions',
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))
@@ -1,15 +1,17 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
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 = Author.create!(name: 'alice')
28
- bob = Author.create!(name: 'bob')
29
- quiet = Author.create!(name: 'quiet')
29
+ alice = Author.create!(name: "alice")
30
+ bob = Author.create!(name: "bob")
31
+ Author.create!(name: "quiet")
30
32
 
31
- Post.create!(title: 'refinements', author_id: alice.id, likes: 100, published: true)
32
- Post.create!(title: 'parser', author_id: alice.id, likes: 40, published: true)
33
- Post.create!(title: 'draft', author_id: bob.id, likes: 5, published: false)
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 == 'authors' ? :name : :title).inspect
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('in? with a subquery',
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('exists?',
53
+ show("exists?",
52
54
  Author.where { exists?(Post.published.where { :posts[:author_id] == :authors[:id] }) })
53
55
 
54
- show('!exists? finds what has nothing to 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('a scalar subquery on the right of a comparison',
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('combined with the rest of the vocabulary',
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)) })