activerecord-refined 0.3.3 → 0.5.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 +158 -0
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +263 -17
- data/activerecord-refined.gemspec +3 -1
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +320 -33
- data/lib/active_record/refined.rb +180 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +597 -20
- data/test/test_helper.rb +12 -0
- metadata +8 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Compares the cost of building the same queries through this gem's block
|
|
2
|
+
# DSL and through ActiveRecord's other argument styles: hash conditions,
|
|
3
|
+
# string conditions, raw Arel, and relation and/or chains. Only query
|
|
4
|
+
# construction (through to_sql) is measured; every style produces the same
|
|
5
|
+
# SQL, which the script prints first as a sanity check.
|
|
6
|
+
#
|
|
7
|
+
# Run without bundler, so the profiling gems don't need to live in the
|
|
8
|
+
# Gemfile:
|
|
9
|
+
#
|
|
10
|
+
# gem install benchmark-ips memory_profiler
|
|
11
|
+
# ruby -Ilib benchmark/query_building.rb
|
|
12
|
+
|
|
13
|
+
require "benchmark/ips"
|
|
14
|
+
require "memory_profiler"
|
|
15
|
+
require "active_record"
|
|
16
|
+
require "activerecord-refined"
|
|
17
|
+
|
|
18
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
19
|
+
ActiveRecord::Schema.verbose = false
|
|
20
|
+
ActiveRecord::Schema.define do
|
|
21
|
+
create_table(:users) {|t| t.string :name; t.integer :age }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class User < ActiveRecord::Base; end
|
|
25
|
+
|
|
26
|
+
T = User.arel_table
|
|
27
|
+
|
|
28
|
+
# Each variant must generate equivalent SQL; sanity-print once.
|
|
29
|
+
VARIANTS = {
|
|
30
|
+
"simple equality" => {
|
|
31
|
+
"hash" => -> { User.where(name: "alice").to_sql },
|
|
32
|
+
"string" => -> { User.where("name = ?", "alice").to_sql },
|
|
33
|
+
"arel" => -> { User.where(T[:name].eq("alice")).to_sql },
|
|
34
|
+
"block" => -> { User.where { :name == "alice" }.to_sql },
|
|
35
|
+
},
|
|
36
|
+
"range (BETWEEN)" => {
|
|
37
|
+
"hash" => -> { User.where(age: 20..40).to_sql },
|
|
38
|
+
"arel" => -> { User.where(T[:age].between(20..40)).to_sql },
|
|
39
|
+
"block" => -> { User.where { :age.in?(20..40) }.to_sql },
|
|
40
|
+
},
|
|
41
|
+
"LIKE" => {
|
|
42
|
+
"string" => -> { User.where("name LIKE ?", "ma%").to_sql },
|
|
43
|
+
"arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
|
|
44
|
+
"block" => -> { User.where { :name.like?("al%") }.to_sql },
|
|
45
|
+
},
|
|
46
|
+
"compound AND/OR" => {
|
|
47
|
+
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "alice", "bob").to_sql },
|
|
48
|
+
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("alice").or(T[:name].eq("bob")))).to_sql },
|
|
49
|
+
"relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
|
|
50
|
+
"block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
puts "=== generated SQL (sanity) ==="
|
|
55
|
+
VARIANTS.each do |group, variants|
|
|
56
|
+
puts "--- #{group} ---"
|
|
57
|
+
variants.each {|name, thunk| puts " #{name.ljust(8)} #{thunk.call}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts
|
|
61
|
+
puts "=== speed (queries built per second) ==="
|
|
62
|
+
VARIANTS.each do |group, variants|
|
|
63
|
+
puts "--- #{group} ---"
|
|
64
|
+
Benchmark.ips do |x|
|
|
65
|
+
x.config(warmup: 0.5, time: 2)
|
|
66
|
+
variants.each {|name, thunk| x.report(name, &thunk) }
|
|
67
|
+
x.compare!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts
|
|
72
|
+
puts "=== memory (per single call) ==="
|
|
73
|
+
fmt = "%-18s %-9s %12s %12s"
|
|
74
|
+
puts format(fmt, "group", "variant", "allocated B", "objects")
|
|
75
|
+
VARIANTS.each do |group, variants|
|
|
76
|
+
variants.each do |name, thunk|
|
|
77
|
+
thunk.call # warm caches (schema, statement caches) outside the report
|
|
78
|
+
report = MemoryProfiler.report { thunk.call }
|
|
79
|
+
puts format(fmt, group, name, report.total_allocated_memsize, report.total_allocated)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts
|
|
84
|
+
puts "=== Proc#refined ISeq copy (memory) ==="
|
|
85
|
+
require "objspace"
|
|
86
|
+
|
|
87
|
+
# Proc#refined runs the block under the refinements by deep-copying its
|
|
88
|
+
# instruction sequence, nested blocks included. The copy is made lazily on
|
|
89
|
+
# the refined proc's first call and memoized per source iseq and refinement
|
|
90
|
+
# list for the life of the VM, so it is paid once per block call site, not
|
|
91
|
+
# per query.
|
|
92
|
+
iseq_count = -> {
|
|
93
|
+
counts = ObjectSpace.count_imemo_objects
|
|
94
|
+
counts[:imemo_iseq] || counts[:iseq]
|
|
95
|
+
}
|
|
96
|
+
context = ActiveRecord::Refined::BlockContext.new
|
|
97
|
+
syntax = ActiveRecord::Refined::BlockSyntax
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
"simple equality" => proc { :name == "alice" },
|
|
101
|
+
"compound AND/OR" => proc { (:age >= 18) & ((:name == "alice") | (:name == "bob")) },
|
|
102
|
+
}.each do |label, blk|
|
|
103
|
+
refined = blk.refined(syntax)
|
|
104
|
+
context.instance_exec(&refined) # the copy is made here, on the first call
|
|
105
|
+
original = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(blk))
|
|
106
|
+
copy = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(refined))
|
|
107
|
+
puts "#{label}: original iseq #{original} B, refined copy #{copy} B"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
make_proc = -> { proc { :age > 20 } }
|
|
111
|
+
context.instance_exec(&make_proc.call.refined(syntax))
|
|
112
|
+
before = iseq_count.call
|
|
113
|
+
1000.times { context.instance_exec(&make_proc.call.refined(syntax)) }
|
|
114
|
+
puts "1000 more calls from the same call site copied #{iseq_count.call - before} iseqs"
|
|
115
|
+
|
|
116
|
+
puts
|
|
117
|
+
puts "=== where the block path spends its time ==="
|
|
118
|
+
block = proc { :name == "alice" }
|
|
119
|
+
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
120
|
+
context = ActiveRecord::Refined::BlockContext.new
|
|
121
|
+
Benchmark.ips do |x|
|
|
122
|
+
x.config(warmup: 0.5, time: 2)
|
|
123
|
+
x.report("Proc#refined alone") { block.refined(ActiveRecord::Refined::BlockSyntax) }
|
|
124
|
+
x.report("instance_exec of pre-refined proc") { context.instance_exec(&refined_block) }
|
|
125
|
+
x.report("refined + instance_exec") {
|
|
126
|
+
ActiveRecord::Refined::BlockContext.new.instance_exec(&block.refined(ActiveRecord::Refined::BlockSyntax))
|
|
127
|
+
}
|
|
128
|
+
x.compare!
|
|
129
|
+
end
|
data/examples/complex_joins.rb
CHANGED
|
@@ -8,7 +8,7 @@ ActiveRecord::Migration.verbose = false
|
|
|
8
8
|
|
|
9
9
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
10
|
def up
|
|
11
|
-
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country }
|
|
11
|
+
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country; t.integer :mentor_id }
|
|
12
12
|
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
13
|
create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
|
|
14
14
|
end
|
|
@@ -67,3 +67,16 @@ query3 =
|
|
|
67
67
|
puts "--- 3. LEFT OUTER JOIN with negation ---"
|
|
68
68
|
puts query3.to_sql
|
|
69
69
|
puts
|
|
70
|
+
|
|
71
|
+
# 4. Self join. `as` names the table within the query, and the block's
|
|
72
|
+
# qualified columns go by that name, which is what makes a table joinable
|
|
73
|
+
# to itself.
|
|
74
|
+
query4 =
|
|
75
|
+
Author.
|
|
76
|
+
joins(:authors, as: :mentors) { :mentors[:id] == :authors[:mentor_id] }.
|
|
77
|
+
where { :mentors[:country] != :authors[:country] }.
|
|
78
|
+
select { [:authors[:name].as(:author), :mentors[:name].as(:mentor)] }
|
|
79
|
+
|
|
80
|
+
puts "--- 4. Self join through an alias ---"
|
|
81
|
+
puts query4.to_sql
|
|
82
|
+
puts
|
data/examples/ctes.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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(:categories) {|t| t.string :name; t.integer :parent_id }
|
|
12
|
+
create_table(:products) {|t| t.string :name; t.integer :category_id; t.integer :price }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Setup.new.up
|
|
16
|
+
|
|
17
|
+
class Category < ActiveRecord::Base
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Product < ActiveRecord::Base
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
electronics = Category.create!(name: 'electronics')
|
|
24
|
+
computers = Category.create!(name: 'computers', parent_id: electronics.id)
|
|
25
|
+
laptops = Category.create!(name: 'laptops', parent_id: computers.id)
|
|
26
|
+
groceries = Category.create!(name: 'groceries')
|
|
27
|
+
|
|
28
|
+
Product.create!(name: 'ultrabook', category_id: laptops.id, price: 1200)
|
|
29
|
+
Product.create!(name: 'keyboard', category_id: computers.id, price: 80)
|
|
30
|
+
Product.create!(name: 'apple', category_id: groceries.id, price: 2)
|
|
31
|
+
|
|
32
|
+
# 1. Recursive CTE: every category below 'electronics', itself included.
|
|
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.
|
|
37
|
+
subtree =
|
|
38
|
+
Category.with_recursive(
|
|
39
|
+
tree: [
|
|
40
|
+
Category.where { :id == electronics.id },
|
|
41
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
42
|
+
]
|
|
43
|
+
).from(:tree, as: :categories)
|
|
44
|
+
|
|
45
|
+
puts '--- 1. Recursive CTE walking a category tree ---'
|
|
46
|
+
puts subtree.to_sql
|
|
47
|
+
puts subtree.order { :name }.pluck(:name).inspect
|
|
48
|
+
puts
|
|
49
|
+
|
|
50
|
+
# 2. The same CTE as a subquery: products anywhere under 'electronics'.
|
|
51
|
+
# The outer query joins the CTE by name like any other table.
|
|
52
|
+
products_below =
|
|
53
|
+
Product.with_recursive(
|
|
54
|
+
tree: [
|
|
55
|
+
Category.where { :id == electronics.id },
|
|
56
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
57
|
+
]
|
|
58
|
+
).joins(:tree) { :tree[:id] == :products[:category_id] }
|
|
59
|
+
|
|
60
|
+
puts '--- 2. Recursive CTE joined from the outer query ---'
|
|
61
|
+
puts products_below.to_sql
|
|
62
|
+
puts products_below.order { :name }.pluck(:name).inspect
|
|
63
|
+
puts
|
|
64
|
+
|
|
65
|
+
# 3. A plain CTE, named once and used twice: categories that hold something
|
|
66
|
+
# expensive, and the count of products in each.
|
|
67
|
+
expensive =
|
|
68
|
+
Category.with(pricey: Product.where { :price >= 100 }).
|
|
69
|
+
joins(:pricey) { :pricey[:category_id] == :categories[:id] }.
|
|
70
|
+
group { :categories[:id] }.
|
|
71
|
+
select {
|
|
72
|
+
[
|
|
73
|
+
:categories[:name].as(:category),
|
|
74
|
+
count(:pricey[:id]).as(:pricey_count),
|
|
75
|
+
max(:pricey[:price]).as(:top_price),
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
puts '--- 3. Plain CTE joined and aggregated ---'
|
|
80
|
+
puts expensive.to_sql
|
|
81
|
+
puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
82
|
+
puts
|
|
@@ -0,0 +1,87 @@
|
|
|
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(:line_items) {|t| t.string :sku; t.string :category; t.integer :price; t.integer :quantity }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
Setup.new.up
|
|
15
|
+
|
|
16
|
+
class LineItem < ActiveRecord::Base
|
|
17
|
+
end
|
|
18
|
+
|
|
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)
|
|
23
|
+
|
|
24
|
+
def show(title, relation, rows = nil)
|
|
25
|
+
puts "--- #{title} ---"
|
|
26
|
+
puts relation.to_sql
|
|
27
|
+
puts rows.inspect unless rows.nil?
|
|
28
|
+
puts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 1. Arithmetic. Ruby puts + - * / above the comparison operators, so an
|
|
32
|
+
# expression groups the way it reads, without parentheses.
|
|
33
|
+
show('arithmetic in a condition',
|
|
34
|
+
LineItem.where { :price * :quantity > 1000 },
|
|
35
|
+
LineItem.where { :price * :quantity > 1000 }.pluck(:sku))
|
|
36
|
+
|
|
37
|
+
show('arithmetic in a select list, and aggregated',
|
|
38
|
+
LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] },
|
|
39
|
+
LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] }.
|
|
40
|
+
map {|i| [i.sku, i.subtotal] })
|
|
41
|
+
|
|
42
|
+
show('an aggregate over an expression',
|
|
43
|
+
LineItem.select { sum(:price * :quantity).as(:total) },
|
|
44
|
+
LineItem.select { sum(:price * :quantity).as(:total) }.first.total)
|
|
45
|
+
|
|
46
|
+
# 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
|
|
47
|
+
# COUNT(DISTINCT ...); the rest are sum, avg, min and max.
|
|
48
|
+
show('COUNT(*) and COUNT(DISTINCT ...)',
|
|
49
|
+
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] },
|
|
50
|
+
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
|
|
51
|
+
map {|i| [i.rows, i.categories] })
|
|
52
|
+
|
|
53
|
+
# 3. Functions. Seven scalar ones have methods of their own; fn reaches
|
|
54
|
+
# anything else, emitting the name as written.
|
|
55
|
+
show('built-in functions and the fn escape hatch',
|
|
56
|
+
LineItem.
|
|
57
|
+
where { length(:sku) == 3 }.
|
|
58
|
+
select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] },
|
|
59
|
+
LineItem.
|
|
60
|
+
where { length(:sku) == 3 }.
|
|
61
|
+
select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] }.
|
|
62
|
+
map {|i| [i.sku, i.category] })
|
|
63
|
+
|
|
64
|
+
show('fn, for a function without a method of its own',
|
|
65
|
+
LineItem.select { fn(:hex, :price).as(:hex_price) },
|
|
66
|
+
LineItem.select { fn(:hex, :price).as(:hex_price) }.map(&:hex_price))
|
|
67
|
+
|
|
68
|
+
# 4. Ordering. asc and desc take nulls_first / nulls_last. MySQL has no
|
|
69
|
+
# such syntax, but Arel emulates it there, so the order is the same
|
|
70
|
+
# everywhere.
|
|
71
|
+
show('NULLS LAST',
|
|
72
|
+
LineItem.order { [:category.asc.nulls_last, :sku.asc] },
|
|
73
|
+
LineItem.order { [:category.asc.nulls_last, :sku.asc] }.pluck(:category, :sku))
|
|
74
|
+
|
|
75
|
+
# Aggregates and expressions can be ordered by, too.
|
|
76
|
+
show('grouped, aggregated and ordered by the aggregate',
|
|
77
|
+
LineItem.
|
|
78
|
+
group { :category }.
|
|
79
|
+
having { count(:*) > 1 }.
|
|
80
|
+
order { sum(:price * :quantity).desc }.
|
|
81
|
+
select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] },
|
|
82
|
+
LineItem.
|
|
83
|
+
group { :category }.
|
|
84
|
+
having { count(:*) > 1 }.
|
|
85
|
+
order { sum(:price * :quantity).desc }.
|
|
86
|
+
select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] }.
|
|
87
|
+
map {|i| [i.category, i.total] })
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'activerecord-refined'
|
|
5
|
+
require 'etc'
|
|
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
|
|
9
|
+
# the current user with no password, overridable through DB_HOST, DB_USERNAME
|
|
10
|
+
# and DB_PASSWORD.
|
|
11
|
+
begin
|
|
12
|
+
ActiveRecord::Base.establish_connection(
|
|
13
|
+
adapter: 'postgresql',
|
|
14
|
+
host: ENV.fetch('DB_HOST', '127.0.0.1'),
|
|
15
|
+
username: ENV.fetch('DB_USERNAME') { Etc.getlogin },
|
|
16
|
+
password: ENV['DB_PASSWORD'],
|
|
17
|
+
database: ENV.fetch('DB_NAME', 'postgres'))
|
|
18
|
+
ActiveRecord::Base.lease_connection
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
abort "needs a PostgreSQL server: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ActiveRecord::Migration.verbose = false
|
|
24
|
+
|
|
25
|
+
class Setup < ActiveRecord::Migration[8.1]
|
|
26
|
+
def up
|
|
27
|
+
create_table(:articles, force: true) do |t|
|
|
28
|
+
t.string :title
|
|
29
|
+
t.string :tags, array: true, default: []
|
|
30
|
+
t.integer :scores, array: true, default: []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
Setup.new.up
|
|
35
|
+
|
|
36
|
+
class Article < ActiveRecord::Base
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
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: [])
|
|
44
|
+
|
|
45
|
+
def show(title, relation, rows = nil)
|
|
46
|
+
puts "--- #{title} ---"
|
|
47
|
+
puts relation.to_sql
|
|
48
|
+
puts rows.inspect unless rows.nil?
|
|
49
|
+
puts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 1. Array columns. Each name carries the meaning of its Ruby namesake:
|
|
53
|
+
# member? is Enumerable's element test, superset? and subset? are Set's
|
|
54
|
+
# containment, and intersect? is Array's "any element in common".
|
|
55
|
+
show('member? tests one element',
|
|
56
|
+
Article.where { :tags.member?('ruby') },
|
|
57
|
+
Article.where { :tags.member?('ruby') }.pluck(:title))
|
|
58
|
+
|
|
59
|
+
show('superset? requires every element',
|
|
60
|
+
Article.where { :tags.superset?(%w[ruby rails]) },
|
|
61
|
+
Article.where { :tags.superset?(%w[ruby rails]) }.pluck(:title))
|
|
62
|
+
|
|
63
|
+
show('subset? and intersect?',
|
|
64
|
+
Article.where { :tags.intersect?(%w[sql lang]) },
|
|
65
|
+
[
|
|
66
|
+
Article.where { :tags.subset?(%w[ruby lang extra]) }.pluck(:title),
|
|
67
|
+
Article.where { :tags.intersect?(%w[sql lang]) }.pluck(:title),
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
# The element is serialized into an array literal, so commas, quotes and %
|
|
71
|
+
# are matched literally rather than parsed or treated as wildcards.
|
|
72
|
+
show('elements are matched literally',
|
|
73
|
+
Article.where { :tags.member?('a,b') },
|
|
74
|
+
Article.where { :tags.member?('a,b') }.pluck(:title))
|
|
75
|
+
|
|
76
|
+
# member? works on any element type; the literal is coerced to the column's.
|
|
77
|
+
show('a numeric array',
|
|
78
|
+
Article.where { :scores.member?(4) },
|
|
79
|
+
Article.where { :scores.member?(4) }.pluck(:title))
|
|
80
|
+
|
|
81
|
+
# 2. Regular expressions. =~ and !~ become ~ and !~ here, REGEXP on MySQL.
|
|
82
|
+
# SQLite has no regexp operator, which is why this example is not one of
|
|
83
|
+
# the portable ones.
|
|
84
|
+
show('=~ matches a regular expression',
|
|
85
|
+
Article.where { :title =~ '^R' },
|
|
86
|
+
Article.where { :title =~ '^R' }.pluck(:title))
|
|
87
|
+
|
|
88
|
+
show('a Regexp literal works too, and !~ negates',
|
|
89
|
+
Article.where { :title !~ /s$/ },
|
|
90
|
+
Article.where { :title !~ /s$/ }.pluck(:title))
|
|
91
|
+
|
|
92
|
+
# 3. Case. like? is case-sensitive LIKE everywhere, including here, where
|
|
93
|
+
# Arel would otherwise reach for ILIKE. ilike? is the one that asks for
|
|
94
|
+
# it, and casecmp? is case-insensitive equality.
|
|
95
|
+
show('like? stays case-sensitive; ilike? does not',
|
|
96
|
+
Article.where { :title.ilike?('r%') },
|
|
97
|
+
[
|
|
98
|
+
Article.where { :title.like?('r%') }.pluck(:title),
|
|
99
|
+
Article.where { :title.ilike?('r%') }.pluck(:title),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
# 4. NULL as a value. PostgreSQL spells this IS [NOT] DISTINCT FROM.
|
|
103
|
+
show('null-safe comparison',
|
|
104
|
+
Article.where { :title.not_distinct_from?('Rails 8') },
|
|
105
|
+
Article.where { :title.not_distinct_from?('Rails 8') }.pluck(:title))
|
|
@@ -0,0 +1,93 @@
|
|
|
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(:accounts) {|t| t.string :login; t.string :country; t.integer :age }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
Setup.new.up
|
|
15
|
+
|
|
16
|
+
class Account < ActiveRecord::Base
|
|
17
|
+
end
|
|
18
|
+
|
|
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)
|
|
22
|
+
Account.create!(login: '100%_pure', country: nil, age: 30)
|
|
23
|
+
Account.create!(login: '1002000', country: 'US', age: 25)
|
|
24
|
+
|
|
25
|
+
def show(title, relation, rows)
|
|
26
|
+
puts "--- #{title} ---"
|
|
27
|
+
puts relation.to_sql
|
|
28
|
+
puts rows.inspect
|
|
29
|
+
puts
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 1. Ranges and sets. in? is one name for "belongs to this set": a Range
|
|
33
|
+
# becomes BETWEEN, an endless Range a bare comparison, a list an IN.
|
|
34
|
+
show('in? with a Range becomes BETWEEN',
|
|
35
|
+
Account.where { :age.in?(40..55) },
|
|
36
|
+
Account.where { :age.in?(40..55) }.pluck(:login))
|
|
37
|
+
|
|
38
|
+
show('endless range and IN',
|
|
39
|
+
Account.where { :age.in?(50..) | :country.in?(%w[US]) },
|
|
40
|
+
Account.where { :age.in?(50..) | :country.in?(%w[US]) }.pluck(:login))
|
|
41
|
+
|
|
42
|
+
# 2. NULL. = NULL is never true in SQL, so == nil raises and the test is
|
|
43
|
+
# spelled null?. not_distinct_from? is the null-safe equality, which is
|
|
44
|
+
# what to reach for when the value may or may not be nil.
|
|
45
|
+
show('null? and its negation',
|
|
46
|
+
Account.where { :country.null? },
|
|
47
|
+
Account.where { :country.null? }.pluck(:login))
|
|
48
|
+
|
|
49
|
+
wanted = nil
|
|
50
|
+
show('not_distinct_from? matches NULL to nil',
|
|
51
|
+
Account.where { :country.not_distinct_from?(wanted) },
|
|
52
|
+
Account.where { :country.not_distinct_from?(wanted) }.pluck(:login))
|
|
53
|
+
|
|
54
|
+
# Unlike !=, distinct_from? keeps the NULL row.
|
|
55
|
+
show('distinct_from? keeps NULLs, != drops them',
|
|
56
|
+
Account.where { :country.distinct_from?('JP') },
|
|
57
|
+
[
|
|
58
|
+
Account.where { :country.distinct_from?('JP') }.pluck(:login),
|
|
59
|
+
Account.where { :country != 'JP' }.pluck(:login),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
# 3. Text. like? takes a pattern; start_with?, end_with? and include? take
|
|
63
|
+
# literals, so % and _ in them are escaped rather than matched as
|
|
64
|
+
# wildcards. Note the last row matches only the literal-minded one.
|
|
65
|
+
show('like? takes a pattern',
|
|
66
|
+
Account.where { :login.like?('%rol') },
|
|
67
|
+
Account.where { :login.like?('%rol') }.pluck(:login))
|
|
68
|
+
|
|
69
|
+
show('start_with? takes any number of literals, like String#start_with?',
|
|
70
|
+
Account.where { :login.start_with?('al', 'bo') },
|
|
71
|
+
Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
|
|
72
|
+
|
|
73
|
+
# The % in the argument is escaped, so only the account whose login really
|
|
74
|
+
# contains "100%" matches; the same pattern spelled with like? treats it as a
|
|
75
|
+
# wildcard and catches 1002000 as well.
|
|
76
|
+
show('include? escapes wildcards; like? does not',
|
|
77
|
+
Account.where { :login.include?('100%') },
|
|
78
|
+
[
|
|
79
|
+
Account.where { :login.include?('100%') }.pluck(:login),
|
|
80
|
+
Account.where { :login.like?('%100%%') }.pluck(:login),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
# casecmp? folds both sides rather than trusting the collation, so it means
|
|
84
|
+
# the same thing on every adapter.
|
|
85
|
+
show('casecmp? is case-insensitive equality',
|
|
86
|
+
Account.where { :login.casecmp?('AlIcE') },
|
|
87
|
+
Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
|
|
88
|
+
|
|
89
|
+
# 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
|
|
90
|
+
# the comparison operators, hence the parentheses around each comparison.
|
|
91
|
+
show('compound conditions',
|
|
92
|
+
Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) },
|
|
93
|
+
Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) }.pluck(:login))
|
|
@@ -0,0 +1,67 @@
|
|
|
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(:authors) {|t| t.string :name }
|
|
12
|
+
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Setup.new.up
|
|
16
|
+
|
|
17
|
+
class Author < ActiveRecord::Base
|
|
18
|
+
has_many :posts
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Post < ActiveRecord::Base
|
|
22
|
+
belongs_to :author
|
|
23
|
+
|
|
24
|
+
def self.published = where { :published == true }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
alice = Author.create!(name: 'alice')
|
|
28
|
+
bob = Author.create!(name: 'bob')
|
|
29
|
+
quiet = Author.create!(name: 'quiet')
|
|
30
|
+
|
|
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)
|
|
34
|
+
|
|
35
|
+
def show(title, relation)
|
|
36
|
+
puts "--- #{title} ---"
|
|
37
|
+
puts relation.to_sql
|
|
38
|
+
puts relation.pluck(relation.model.table_name == 'authors' ? :name : :title).inspect
|
|
39
|
+
puts
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 1. in? takes a relation as a subquery. With an explicit select list the
|
|
43
|
+
# subquery selects that column; without one it selects the primary key,
|
|
44
|
+
# the same way ActiveRecord's own where(id: relation) does.
|
|
45
|
+
show('in? with a subquery',
|
|
46
|
+
Author.where { :id.in?(Post.published.select(:author_id)) })
|
|
47
|
+
|
|
48
|
+
# 2. exists? asks whether the subquery returns a row. Correlate it with the
|
|
49
|
+
# outer table through qualified columns; the inner where block is the same
|
|
50
|
+
# DSL as the outer one.
|
|
51
|
+
show('exists?',
|
|
52
|
+
Author.where { exists?(Post.published.where { :posts[:author_id] == :authors[:id] }) })
|
|
53
|
+
|
|
54
|
+
show('!exists? finds what has nothing to show',
|
|
55
|
+
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) })
|
|
56
|
+
|
|
57
|
+
# 3. A relation on the right of a comparison is a scalar subquery. It has to
|
|
58
|
+
# yield a single value, so unlike in? there is no default select list and
|
|
59
|
+
# one is required.
|
|
60
|
+
show('a scalar subquery on the right of a comparison',
|
|
61
|
+
Post.where { :likes >= Post.select { avg(:likes) } })
|
|
62
|
+
|
|
63
|
+
# The three compose like any other predicate.
|
|
64
|
+
show('combined with the rest of the vocabulary',
|
|
65
|
+
Author.
|
|
66
|
+
where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.
|
|
67
|
+
where { !:id.in?(Post.where { :published == false }.select(:author_id)) })
|