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.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +162 -26
- data/.github/workflows/test.yml +15 -2
- data/README.md +486 -52
- data/activerecord-refined.gemspec +3 -2
- data/examples/ctes.rb +41 -10
- data/examples/expressions.rb +71 -5
- data/examples/json.rb +94 -0
- data/examples/postgresql.rb +89 -6
- data/examples/predicates.rb +32 -5
- data/examples/windows.rb +96 -0
- data/examples/writes.rb +84 -0
- data/lib/active_record/refined/ast.rb +831 -94
- data/lib/active_record/refined.rb +351 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +7 -0
- data/test/test_block_syntax.rb +1100 -51
- data/test/test_helper.rb +82 -0
- metadata +4 -1
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.
|
|
35
|
-
# then selects the CTE under the model's table name
|
|
36
|
-
#
|
|
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
|
-
).
|
|
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.
|
|
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 '---
|
|
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
|
-
#
|
|
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 { :
|
|
101
|
+
group { :id }.
|
|
71
102
|
select {
|
|
72
103
|
[
|
|
73
|
-
:
|
|
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 '---
|
|
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
|
data/examples/expressions.rb
CHANGED
|
@@ -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)
|
|
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
|
data/examples/postgresql.rb
CHANGED
|
@@ -4,8 +4,9 @@ require 'active_record'
|
|
|
4
4
|
require 'activerecord-refined'
|
|
5
5
|
require 'etc'
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
# needs a server.
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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] })
|
data/examples/predicates.rb
CHANGED
|
@@ -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)
|
|
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.
|
data/examples/windows.rb
ADDED
|
@@ -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] })
|
data/examples/writes.rb
ADDED
|
@@ -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
|