activerecord-refined 0.5.0 → 0.6.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/ctes.rb CHANGED
@@ -31,23 +31,54 @@ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
31
31
 
32
32
  # 1. Recursive CTE: every category below 'electronics', itself included.
33
33
  # The recursive member joins the CTE by name, so its ON clause is a block
34
- # rather than the string join Rails' own documentation reaches for. `from`
35
- # then selects the CTE under the model's table name, which is what lets
36
- # Category's own columns resolve against it.
34
+ # rather than the string join Rails' own documentation reaches for.
35
+ # `from_cte` then selects the CTE under the model's table name.
36
+ #
37
+ # That alias is ActiveRecord's requirement rather than SQL's: by hand the
38
+ # last line would be `SELECT * FROM tree`. ActiveRecord keeps qualifying
39
+ # columns with the model's table name, so without it `where` and `find_by`
40
+ # look for a table the query does not have. `count`, `order` and `select`
41
+ # never qualify and would work either way, which makes it easy to miss.
37
42
  subtree =
38
43
  Category.with_recursive(
39
44
  tree: [
40
45
  Category.where { :id == electronics.id },
41
46
  Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
42
47
  ]
43
- ).from(:tree, as: :categories)
48
+ ).from_cte(:tree)
44
49
 
45
50
  puts '--- 1. Recursive CTE walking a category tree ---'
46
51
  puts subtree.to_sql
47
52
  puts subtree.order { :name }.pluck(:name).inspect
48
53
  puts
49
54
 
50
- # 2. The same CTE as a subquery: products anywhere under 'electronics'.
55
+ # 2. One walk over every tree, carrying down where each row started and how
56
+ # far it has come. Which tree is wanted is then an ordinary `where`, asked
57
+ # afterwards, so the CTE is not rebuilt for each root. 0 is a value rather
58
+ # than SQL: at the top of a select list a bare string would be SQL, so
59
+ # numbers say `.as` directly and anything else says `value(...).as`.
60
+ forest =
61
+ Category.with_recursive(
62
+ tree: [
63
+ Category.where { :parent_id.null? }.
64
+ select { [:id, :name, :parent_id, :id.as(:root_id), 0.as(:depth)] },
65
+ Category.joins(:tree) { :categories[:parent_id] == :tree[:id] }.
66
+ select { [:id, :name, :parent_id,
67
+ :tree[:root_id], (:tree[:depth] + 1).as(:depth)] },
68
+ ]
69
+ ).from_cte(:tree).order { [:depth, :id] }
70
+
71
+ puts '--- 2. Recursive CTE carrying the root and the depth down ---'
72
+ puts forest.to_sql
73
+ puts forest.map {|c| [c.name, c.root_id, c.depth] }.inspect
74
+
75
+ # The alias from_cte puts on the CTE is what lets this `where` qualify
76
+ # root_id; without it the column would be looked for in a table the query no
77
+ # longer has.
78
+ puts forest.where { :root_id == electronics.id }.map {|c| [c.name, c.depth] }.inspect
79
+ puts
80
+
81
+ # 3. The same CTE as a subquery: products anywhere under 'electronics'.
51
82
  # The outer query joins the CTE by name like any other table.
52
83
  products_below =
53
84
  Product.with_recursive(
@@ -57,26 +88,26 @@ products_below =
57
88
  ]
58
89
  ).joins(:tree) { :tree[:id] == :products[:category_id] }
59
90
 
60
- puts '--- 2. Recursive CTE joined from the outer query ---'
91
+ puts '--- 3. Recursive CTE joined from the outer query ---'
61
92
  puts products_below.to_sql
62
93
  puts products_below.order { :name }.pluck(:name).inspect
63
94
  puts
64
95
 
65
- # 3. A plain CTE, named once and used twice: categories that hold something
96
+ # 4. A plain CTE, named once and used twice: categories that hold something
66
97
  # expensive, and the count of products in each.
67
98
  expensive =
68
99
  Category.with(pricey: Product.where { :price >= 100 }).
69
100
  joins(:pricey) { :pricey[:category_id] == :categories[:id] }.
70
- group { :categories[:id] }.
101
+ group { :id }.
71
102
  select {
72
103
  [
73
- :categories[:name].as(:category),
104
+ :name.as(:category),
74
105
  count(:pricey[:id]).as(:pricey_count),
75
106
  max(:pricey[:price]).as(:top_price),
76
107
  ]
77
108
  }
78
109
 
79
- puts '--- 3. Plain CTE joined and aggregated ---'
110
+ puts '--- 4. Plain CTE joined and aggregated ---'
80
111
  puts expensive.to_sql
81
112
  puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
82
113
  puts
@@ -8,7 +8,13 @@ ActiveRecord::Migration.verbose = false
8
8
 
9
9
  class Setup < ActiveRecord::Migration[8.1]
10
10
  def up
11
- create_table(:line_items) {|t| t.string :sku; t.string :category; t.integer :price; t.integer :quantity }
11
+ create_table(:line_items) do |t|
12
+ t.string :sku
13
+ t.string :category
14
+ t.integer :price
15
+ t.integer :quantity
16
+ t.integer :flags
17
+ end
12
18
  end
13
19
  end
14
20
  Setup.new.up
@@ -16,10 +22,10 @@ Setup.new.up
16
22
  class LineItem < ActiveRecord::Base
17
23
  end
18
24
 
19
- LineItem.create!(sku: 'A-1', category: 'tools', price: 1200, quantity: 2)
20
- LineItem.create!(sku: 'A-2', category: 'tools', price: 300, quantity: 5)
21
- LineItem.create!(sku: 'B-1', category: 'paper', price: 80, quantity: 10)
22
- LineItem.create!(sku: 'C-1', category: nil, price: 50, quantity: 1)
25
+ LineItem.create!(sku: 'A-1', category: 'tools', price: 1200, quantity: 2, flags: 12)
26
+ LineItem.create!(sku: 'A-2', category: 'tools', price: 300, quantity: 5, flags: 10)
27
+ LineItem.create!(sku: 'B-1', category: 'paper', price: 80, quantity: 10, flags: 3)
28
+ LineItem.create!(sku: 'C-1', category: nil, price: 50, quantity: 1, flags: 4)
23
29
 
24
30
  def show(title, relation, rows = nil)
25
31
  puts "--- #{title} ---"
@@ -43,6 +49,30 @@ show('an aggregate over an expression',
43
49
  LineItem.select { sum(:price * :quantity).as(:total) },
44
50
  LineItem.select { sum(:price * :quantity).as(:total) }.first.total)
45
51
 
52
+ # The bitwise operators. & and | are AND and OR between conditions, which is
53
+ # what leaves them free here. Each expression parenthesises itself, so the
54
+ # grouping is Ruby's rather than the adapter's.
55
+ show('a bit test in a condition',
56
+ LineItem.where { :flags & 4 > 0 },
57
+ LineItem.where { :flags & 4 > 0 }.pluck(:sku))
58
+
59
+ # XOR is the one the three adapters do not share. SQLite has none, so it gets
60
+ # the two operations XOR is made of; PostgreSQL would say #, MySQL ^.
61
+ show('xor, spelled the way the adapter spells it',
62
+ LineItem.select { [:sku, (:flags ^ 10).as(:xored)] },
63
+ LineItem.select { [:sku, (:flags ^ 10).as(:xored)] }.map {|i| [i.sku, i.xored] })
64
+
65
+ # A condition cannot be an operand, and neither can a boolean column: two of
66
+ # the three adapters would quietly answer as AND does and the third has no
67
+ # such operator, so the block refuses instead.
68
+ begin
69
+ LineItem.where { :flags & (:price == 1) }
70
+ rescue ArgumentError => e
71
+ puts '--- a condition is not an operand of & ---'
72
+ puts " #{e.message}"
73
+ puts
74
+ end
75
+
46
76
  # 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
47
77
  # COUNT(DISTINCT ...); the rest are sum, avg, min and max.
48
78
  show('COUNT(*) and COUNT(DISTINCT ...)',
@@ -50,6 +80,42 @@ show('COUNT(*) and COUNT(DISTINCT ...)',
50
80
  LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
51
81
  map {|i| [i.rows, i.categories] })
52
82
 
83
+ # filter takes the aggregate over the rows a condition holds for. SQLite and
84
+ # PostgreSQL have the FILTER clause; MySQL gets the CASE that means the same,
85
+ # since an aggregate passes over the NULL a missed row leaves.
86
+ show('two aggregates over different rows of the same query',
87
+ LineItem.select {
88
+ [count(:*).as(:all), sum(:price).filter { :category == 'tools' }.as(:tools)]
89
+ },
90
+ LineItem.select {
91
+ [count(:*).as(:all), sum(:price).filter { :category == 'tools' }.as(:tools)]
92
+ }.map {|i| [i.all, i.tools] })
93
+
94
+ # CASE has two shapes: an operand to compare each when against, or a condition
95
+ # on every when. case is a Ruby keyword, so the method behind both is only
96
+ # reachable through the receiver -- self.case -- and each shape has a shorthand
97
+ # that does not need it.
98
+ show('a CASE with an operand, through the shorthand',
99
+ LineItem.select { [:sku, :category.when('tools').then('hardware').else('other').as(:kind)] },
100
+ LineItem.select { [:sku, :category.when('tools').then('hardware').else('other').as(:kind)] }.
101
+ map {|i| [i.sku, i.kind] })
102
+
103
+ show('a CASE where each when carries its own condition',
104
+ LineItem.select {
105
+ [:sku, case_when { :price >= 1000 }.then('dear').when { :price >= 100 }.
106
+ then('middling').else('cheap').as(:band)]
107
+ },
108
+ LineItem.select {
109
+ [:sku, case_when { :price >= 1000 }.then('dear').when { :price >= 100 }.
110
+ then('middling').else('cheap').as(:band)]
111
+ }.map {|i| [i.sku, i.band] })
112
+
113
+ # It is an expression like any other, so it goes inside an aggregate too.
114
+ show('counting with a CASE',
115
+ LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) },
116
+ LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) }.
117
+ map {|i| i.dear_ones })
118
+
53
119
  # 3. Functions. Seven scalar ones have methods of their own; fn reaches
54
120
  # anything else, emitting the name as written.
55
121
  show('built-in functions and the fn escape hatch',
data/examples/json.rb ADDED
@@ -0,0 +1,94 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:documents) {|t| t.string :name; t.json :meta }
12
+ end
13
+ end
14
+ Setup.new.up
15
+
16
+ class Document < ActiveRecord::Base
17
+ end
18
+
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: {})
25
+
26
+ def show(title, relation, rows = nil)
27
+ puts "--- #{title} ---"
28
+ puts relation.to_sql
29
+ puts rows.inspect unless rows.nil?
30
+ puts
31
+ end
32
+
33
+ # 1. Reading. dig takes the path Hash#dig takes: a string or symbol steps
34
+ # into an object, an integer into an array. What comes back is the value
35
+ # rather than the JSON around it, which is what a comparison wants.
36
+ show('dig reads a value out of the document',
37
+ Document.select { [:name, :meta.dig(:author, :name).as(:author)] },
38
+ Document.select { [:name, :meta.dig(:author, :name).as(:author)] }.
39
+ map {|d| [d.name, d.author] })
40
+
41
+ show('an integer steps into an array',
42
+ Document.select { [:name, :meta.dig(:tags, 0).as(:first_tag)] },
43
+ Document.select { [:name, :meta.dig(:tags, 0).as(:first_tag)] }.
44
+ map {|d| [d.name, d.first_tag] })
45
+
46
+ # A dug value is an expression like any other, so it compares and orders.
47
+ show('dig in a condition',
48
+ Document.where { :meta.dig(:author, :country) == 'JP' },
49
+ Document.where { :meta.dig(:author, :country) == 'JP' }.pluck(:name))
50
+
51
+ # dig_json keeps the JSON, for a part of the document to be dug into further
52
+ # or compared whole. The quotes around the string are the sign of it.
53
+ show('dig_json keeps the JSON',
54
+ Document.select { [:name, :meta.dig_json(:author).as(:author)] },
55
+ Document.select { [:name, :meta.dig_json(:author).as(:author)] }.
56
+ map {|d| [d.name, d.author] })
57
+
58
+ # 2. Asking whether a key is there at all, which is not the same as asking
59
+ # whether its value is null -- and is spelled key? for the reason Ruby's
60
+ # Hash spells it that way.
61
+ show('key? asks whether the key is there',
62
+ Document.where { :meta.key?(:draft) },
63
+ Document.where { :meta.key?(:draft) }.pluck(:name))
64
+
65
+ # key? takes the one key Hash#key? takes; a path is what dig is for.
66
+ show('and ! is its negation',
67
+ Document.where { !:meta.key?(:draft) },
68
+ Document.where { !:meta.key?(:draft) }.pluck(:name))
69
+
70
+ # 3. Writing. bury sets what dig reads, at the path given: JSON_SET on SQLite
71
+ # and MySQL, jsonb_set on PostgreSQL.
72
+ show('the expression bury builds',
73
+ Document.select { [:name, :meta.bury(:author, :country, 'US').as(:updated)] })
74
+
75
+ # It is an expression, so it is what update_all sets the column to.
76
+ Document.where { :name == 'notes' }.update_all { {meta: :meta.bury(:author, :country, 'US')} }
77
+ puts '--- what the update left behind ---'
78
+ puts " #{Document.find_by(name: 'notes').meta.inspect}"
79
+ puts
80
+
81
+ # A whole object or array goes in at once.
82
+ Document.where { :name == 'empty' }.update_all { {meta: :meta.bury(:author, {'name' => 'carol'})} }
83
+ puts '--- a whole object at once ---'
84
+ puts " #{Document.find_by(name: 'empty').meta.inspect}"
85
+ puts
86
+
87
+ # The path is what makes the SQL, so it cannot be empty.
88
+ begin
89
+ Document.select { :meta.bury('US') }
90
+ rescue ArgumentError => e
91
+ puts '--- bury needs a path ---'
92
+ puts " #{e.message}"
93
+ puts
94
+ end
@@ -4,8 +4,9 @@ require 'active_record'
4
4
  require 'activerecord-refined'
5
5
  require 'etc'
6
6
 
7
- # The features here are PostgreSQL's, so unlike the other examples this one
8
- # needs a server. It connects the way the test suite does: on 127.0.0.1 as
7
+ # What is here is what SQLite cannot run, so unlike the other examples this
8
+ # one needs a server. Most of it is PostgreSQL's alone; ANY and ALL, JSON
9
+ # containment, the bit aggregates and LATERAL are MySQL's too. It connects the way the test suite does: on 127.0.0.1 as
9
10
  # the current user with no password, overridable through DB_HOST, DB_USERNAME
10
11
  # and DB_PASSWORD.
11
12
  begin
@@ -24,23 +25,40 @@ ActiveRecord::Migration.verbose = false
24
25
 
25
26
  class Setup < ActiveRecord::Migration[8.1]
26
27
  def up
28
+ create_table(:writers, force: true) {|t| t.string :name; t.string :country }
27
29
  create_table(:articles, force: true) do |t|
28
30
  t.string :title
29
31
  t.string :tags, array: true, default: []
30
32
  t.integer :scores, array: true, default: []
33
+ t.integer :writer_id
34
+ t.integer :likes
35
+ t.integer :flags
36
+ t.jsonb :meta
31
37
  end
32
38
  end
33
39
  end
34
40
  Setup.new.up
35
41
 
42
+ class Writer < ActiveRecord::Base
43
+ end
44
+
36
45
  class Article < ActiveRecord::Base
46
+ belongs_to :writer
37
47
  end
38
48
 
39
49
  Article.delete_all
40
- Article.create!(title: 'Refinements', tags: %w[ruby lang], scores: [5, 4])
41
- Article.create!(title: 'Rails 8', tags: %w[ruby rails], scores: [5])
42
- Article.create!(title: 'Postgres CTEs', tags: %w[sql db], scores: [3])
43
- Article.create!(title: '100% pure', tags: ['100%', 'a,b'], scores: [])
50
+ 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'})
44
62
 
45
63
  def show(title, relation, rows = nil)
46
64
  puts "--- #{title} ---"
@@ -103,3 +121,68 @@ show('like? stays case-sensitive; ilike? does not',
103
121
  show('null-safe comparison',
104
122
  Article.where { :title.not_distinct_from?('Rails 8') },
105
123
  Article.where { :title.not_distinct_from?('Rails 8') }.pluck(:title))
124
+
125
+ # 5. Keeping one row per group. DISTINCT ON is PostgreSQL's: the first row of
126
+ # each group the order brings up, which is why the order has to start with
127
+ # what the distinct is on. Arel refuses to write it for the others, so the
128
+ # portable shape is a row_number window in a subquery.
129
+ show('the most liked article of each writer',
130
+ Article.distinct_on { :writer_id }.order { [:writer_id, :likes.desc] },
131
+ Article.distinct_on { :writer_id }.order { [:writer_id, :likes.desc] }.
132
+ pluck(:title))
133
+
134
+ # 6. Grouping several ways at once. Each grouping set produces its own rows,
135
+ # and an empty one is the grand total; rollup and cube are the two shapes
136
+ # that come up often enough to have names of their own. MySQL has only
137
+ # WITH ROLLUP, which is a different clause, and SQLite has none of it.
138
+ sets = Article.
139
+ joins(:writers) { :writers[:id] == :articles[:writer_id] }.
140
+ group { grouping_sets([:writers[:country]], [:articles[:writer_id]], []) }.
141
+ select { [:writers[:country], :articles[:writer_id], sum(:likes).as(:likes)] }
142
+ show('by country, by writer, and both together',
143
+ sets,
144
+ sets.map {|a| [a.country, a.writer_id, a.likes] })
145
+
146
+ show('rollup is the nested case of the same thing',
147
+ Article.group { rollup(:writer_id, :flags) }.select { [:writer_id, :flags, count(:*).as(:n)] })
148
+
149
+ # 7. Lateral joins. A lateral join lets the relation joined see the row being
150
+ # joined to, which is what makes the top row of each group reachable in one
151
+ # query. Without a block the join is ON TRUE, the usual shape: what the
152
+ # subquery may see is said in its own where.
153
+ top = Article.select { :title }.
154
+ where { :articles[:writer_id] == :writers[:id] }.
155
+ order { :likes.desc }.limit(1)
156
+ lateral = Writer.left_outer_joins(top, as: :top, lateral: true).
157
+ select { [:name, :top[:title].as(:top_article)] }
158
+ show('the top article beside each writer',
159
+ lateral,
160
+ lateral.map {|w| [w.name, w.top_article] })
161
+
162
+ # 8. ANY and ALL, which quantify a comparison over a subquery where a scalar
163
+ # subquery would have to return the one row. MySQL has them too; SQLite
164
+ # 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',
166
+ Article.where { :likes > any(Article.where { :writer_id == alice.id }.select(:likes)) },
167
+ [
168
+ Article.where { :likes > any(Article.where { :writer_id == alice.id }.select(:likes)) }.
169
+ pluck(:title),
170
+ Article.where { :likes > all(Article.where { :writer_id == alice.id }.select(:likes)) }.
171
+ pluck(:title),
172
+ ])
173
+
174
+ # 9. JSON containment and the bit aggregates, which PostgreSQL and MySQL have
175
+ # and SQLite does not. contains? asks whether the document holds the one
176
+ # given, which is @> here and JSON_CONTAINS on MySQL.
177
+ show('containment asks about a whole document',
178
+ Article.where { :meta.contains?(draft: true) },
179
+ Article.where { :meta.contains?(draft: true) }.pluck(:title))
180
+
181
+ show('the bits every article has, and the bits any of them has',
182
+ Article.select { [bit_and(:flags).as(:common), bit_or(:flags).as(:any)] },
183
+ Article.select { [bit_and(:flags).as(:common), bit_or(:flags).as(:any)] }.
184
+ map {|a| [a.common, a.any] })
185
+
186
+ show('and how many bits are set in each',
187
+ Article.select { [:title, bit_count(:flags).as(:bits)] },
188
+ Article.select { [:title, bit_count(:flags).as(:bits)] }.map {|a| [a.title, a.bits] })
@@ -8,7 +8,12 @@ ActiveRecord::Migration.verbose = false
8
8
 
9
9
  class Setup < ActiveRecord::Migration[8.1]
10
10
  def up
11
- create_table(:accounts) {|t| t.string :login; t.string :country; t.integer :age }
11
+ create_table(:accounts) do |t|
12
+ t.string :login
13
+ t.string :country
14
+ t.integer :age
15
+ t.boolean :verified
16
+ end
12
17
  end
13
18
  end
14
19
  Setup.new.up
@@ -16,11 +21,11 @@ Setup.new.up
16
21
  class Account < ActiveRecord::Base
17
22
  end
18
23
 
19
- Account.create!(login: 'alice', country: 'JP', age: 60)
20
- Account.create!(login: 'bob', country: 'JP', age: 50)
21
- Account.create!(login: 'carol', country: 'US', age: 45)
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)
22
27
  Account.create!(login: '100%_pure', country: nil, age: 30)
23
- Account.create!(login: '1002000', country: 'US', age: 25)
28
+ Account.create!(login: '1002000', country: 'US', age: 25, verified: false)
24
29
 
25
30
  def show(title, relation, rows)
26
31
  puts "--- #{title} ---"
@@ -59,6 +64,28 @@ show('distinct_from? keeps NULLs, != drops them',
59
64
  Account.where { :country != 'JP' }.pluck(:login),
60
65
  ])
61
66
 
67
+ # A boolean column has true? and false?, which become IS TRUE and IS FALSE,
68
+ # and the negation of each. All four are spelled and answered the same way by
69
+ # every adapter, NULL included.
70
+ show('the four truth tests',
71
+ Account.where { :verified.false? },
72
+ [
73
+ Account.where { :verified.true? }.pluck(:login),
74
+ Account.where { :verified.false? }.pluck(:login),
75
+ Account.where { :verified.not_true? }.pluck(:login),
76
+ Account.where { :verified.not_false? }.pluck(:login),
77
+ ])
78
+
79
+ # They answer for a NULL row where a comparison against the literal does not,
80
+ # so it is negating them that tells the two apart: not_true? has the
81
+ # unverified accounts and the one never asked, !(== true) only the former.
82
+ show('not_true? keeps the NULLs that != TRUE drops',
83
+ Account.where { :verified.not_true? },
84
+ [
85
+ Account.where { :verified.not_true? }.pluck(:login),
86
+ Account.where { !(:verified == true) }.pluck(:login),
87
+ ])
88
+
62
89
  # 3. Text. like? takes a pattern; start_with?, end_with? and include? take
63
90
  # literals, so % and _ in them are escaped rather than matched as
64
91
  # wildcards. Note the last row matches only the literal-minded one.
@@ -0,0 +1,96 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:sales) {|t| t.string :region; t.string :day; t.integer :amount }
12
+ end
13
+ end
14
+ Setup.new.up
15
+
16
+ class Sale < ActiveRecord::Base
17
+ end
18
+
19
+ Sale.create!(region: 'east', day: '2026-01-01', amount: 100)
20
+ Sale.create!(region: 'east', day: '2026-01-02', amount: 40)
21
+ Sale.create!(region: 'east', day: '2026-01-03', amount: 60)
22
+ Sale.create!(region: 'west', day: '2026-01-01', amount: 20)
23
+ Sale.create!(region: 'west', day: '2026-01-02', amount: 90)
24
+
25
+ def show(title, relation, rows = nil)
26
+ puts "--- #{title} ---"
27
+ puts relation.to_sql
28
+ puts rows.inspect unless rows.nil?
29
+ puts
30
+ end
31
+
32
+ # 1. A window on an aggregate. over turns an aggregate into a window
33
+ # function: the rows stay, and the aggregate is worked out over the window
34
+ # beside each one. Without partition or order the window is every row.
35
+ show('the total beside every row',
36
+ Sale.select { [:region, :amount, sum(:amount).over.as(:total)] },
37
+ Sale.select { [:region, :amount, sum(:amount).over.as(:total)] }.
38
+ map {|s| [s.region, s.amount, s.total] })
39
+
40
+ # partition divides the rows into groups the window is worked out within.
41
+ totals = -> {
42
+ Sale.select { [:region, :amount, sum(:amount).over.partition(:region).as(:region_total)] }
43
+ }
44
+ show('a total per region, still row by row',
45
+ totals.call,
46
+ totals.call.map {|s| [s.region, s.amount, s.region_total] })
47
+
48
+ # 2. Frames. With an order, the window can be cut down to a range of rows
49
+ # around the current one. A Range of integers is what says which: 0 is the
50
+ # current row, a beginless range reaches back to the start of the
51
+ # partition, an endless one forward to its end.
52
+ running = -> {
53
+ Sale.select {
54
+ [:region, :day, :amount,
55
+ sum(:amount).over.partition(:region).order(:day).rows(..0).as(:running)]
56
+ }
57
+ }
58
+ show('a running total within each region',
59
+ running.call,
60
+ running.call.map {|s| [s.region, s.day, s.amount, s.running] })
61
+
62
+ # 3. The functions that say nothing without a window. row_number, rank and
63
+ # dense_rank number the rows of each partition; lag and lead reach the row
64
+ # before and after. Each raises rather than reaching the database if over
65
+ # never arrives.
66
+ ranked = -> {
67
+ Sale.select {
68
+ [:region, :amount,
69
+ row_number.over.partition(:region).order(:amount.desc).as(:place),
70
+ lag(:amount).over.partition(:region).order(:day).as(:previous)]
71
+ }
72
+ }
73
+ show('the place within the region, and the day before',
74
+ ranked.call,
75
+ ranked.call.map {|s| [s.region, s.amount, s.place, s.previous] })
76
+
77
+ begin
78
+ Sale.select { row_number }
79
+ rescue ArgumentError => e
80
+ puts '--- a window function needs a window ---'
81
+ puts " #{e.message}"
82
+ puts
83
+ end
84
+
85
+ # 4. The top row of each group, which is what a window is most often for: the
86
+ # numbering goes in a subquery, since a window function cannot be used in
87
+ # the WHERE of the query that computes it.
88
+ numbered = Sale.select {
89
+ [:region, :day, :amount, row_number.over.partition(:region).order(:amount.desc).as(:place)]
90
+ }
91
+ # The subquery is named after the model's own table because ActiveRecord goes
92
+ # on qualifying columns with that name, so where has to find it there.
93
+ show('the biggest sale of each region',
94
+ Sale.from(numbered, :sales).where { :place == 1 },
95
+ Sale.from(numbered, :sales).where { :place == 1 }.
96
+ map {|s| [s.region, s.day, s.amount] })
@@ -0,0 +1,84 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:pages) do |t|
12
+ t.string :path
13
+ t.integer :hits
14
+ t.integer :bonus
15
+ t.string :title
16
+ t.index :path, unique: true
17
+ end
18
+ end
19
+ end
20
+ Setup.new.up
21
+
22
+ class Page < ActiveRecord::Base
23
+ end
24
+
25
+ Page.create!(path: '/index', hits: 10, bonus: 1, title: 'home')
26
+ Page.create!(path: '/about', hits: 3, bonus: 0, title: 'about us')
27
+ Page.create!(path: '/faq', hits: 0, bonus: 5, title: 'faq')
28
+
29
+ # These statements run rather than being built, so unlike the other examples
30
+ # the SQL is taken from the notification ActiveRecord sends for each one.
31
+ def write(title)
32
+ statements = []
33
+ subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
34
+ statements << payload[:sql] unless %w[SCHEMA TRANSACTION].include?(payload[:name])
35
+ end
36
+ yield
37
+ ActiveSupport::Notifications.unsubscribe(subscriber)
38
+ puts "--- #{title} ---"
39
+ statements.each {|sql| puts sql }
40
+ Page.order(:path).each {|p| puts " #{p.path} hits=#{p.hits} title=#{p.title}" }
41
+ puts
42
+ end
43
+
44
+ # 1. update_all. ActiveRecord reads its hash as literals -- update_all(hits:
45
+ # :hits) would set the column to the symbol itself -- and the block reads a
46
+ # symbol as the column it names, which is what lets the new value be worked
47
+ # out from the old.
48
+ write('hits made from the old hits') do
49
+ Page.where { :hits > 0 }.update_all { {hits: :hits + :bonus} }
50
+ end
51
+
52
+ write('a function over the column') do
53
+ Page.update_all { {title: upper(:title)} }
54
+ end
55
+
56
+ # An expression as involved as any other block builds.
57
+ write('CASE in an update') do
58
+ Page.update_all { {hits: case_when { :hits > 10 }.then(10).else(:hits)} }
59
+ end
60
+
61
+ # 2. upsert_all. The block is the part that decides what happens to a row
62
+ # that is already there, and excluded is the row that could not be
63
+ # inserted. PostgreSQL and SQLite name it that; MySQL says VALUES(column),
64
+ # and the block comes out as whichever the adapter reads.
65
+ incoming = [
66
+ {path: '/index', hits: 100, bonus: 0, title: 'HOME'},
67
+ {path: '/new', hits: 7, bonus: 0, title: 'NEW'},
68
+ ]
69
+ write('the old value and the new one added together') do
70
+ Page.upsert_all(incoming, unique_by: :path) { {hits: :hits + excluded(:hits)} }
71
+ end
72
+
73
+ # Without a block, upsert_all overwrites -- that is ActiveRecord's own
74
+ # behaviour, and the block is what makes the old value reachable.
75
+ write('and without a block it is a plain overwrite') do
76
+ Page.upsert_all([{path: '/index', hits: 1, bonus: 0, title: 'HOME'}],
77
+ unique_by: :path)
78
+ end
79
+
80
+ # insert_all has no block: ActiveRecord type-casts each value into the VALUES
81
+ # list, so an expression there would become nothing rather than SQL.
82
+ write('insert_all takes literals') do
83
+ Page.insert_all([{path: '/legal', hits: 0, bonus: 0, title: 'legal'}])
84
+ end