activerecord-refined 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +14 -0
- data/.rubocop.yml +393 -0
- data/Gemfile +5 -3
- data/README.md +254 -74
- data/Rakefile +31 -3
- data/activerecord-refined.gemspec +29 -15
- data/benchmark/query_building.rb +11 -11
- data/examples/aggregations.rb +13 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +79 -44
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +862 -272
- data/lib/active_record/refined.rb +260 -180
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +8 -5
- data/test/test_block_syntax.rb +879 -323
- data/test/test_helper.rb +56 -39
- metadata +130 -1
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
lib = File.expand_path("../lib", __FILE__)
|
|
3
5
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
-
require
|
|
6
|
+
require "activerecord-refined/version"
|
|
5
7
|
|
|
6
8
|
Gem::Specification.new do |gem|
|
|
7
9
|
gem.name = "activerecord-refined"
|
|
8
10
|
gem.version = Activerecord::Refined::VERSION
|
|
9
11
|
gem.authors = ["Shugo Maeda"]
|
|
10
12
|
gem.email = ["shugo@ruby-lang.org"]
|
|
11
|
-
gem.description =
|
|
12
|
-
gem.summary =
|
|
13
|
-
gem.homepage =
|
|
13
|
+
gem.description = "Adding clean and powerful query syntax on Active Record using refinements"
|
|
14
|
+
gem.summary = "Write Active Record queries as Ruby expressions"
|
|
15
|
+
gem.homepage = "https://github.com/shugo/activerecord-refined"
|
|
14
16
|
|
|
15
17
|
# sandbox/ is a site, not part of the library: its Gemfile.lock and
|
|
16
|
-
# package-lock.json have no business in anyone's bundle. CLAUDE.md
|
|
17
|
-
# addressed to whoever is working on the repository, not to
|
|
18
|
-
|
|
19
|
-
gem.
|
|
18
|
+
# package-lock.json have no business in anyone's bundle. CLAUDE.md and
|
|
19
|
+
# .claude/ are addressed to whoever is working on the repository, not to
|
|
20
|
+
# anyone using it.
|
|
21
|
+
gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$|^\.claude/})
|
|
22
|
+
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
|
20
23
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
21
24
|
gem.require_paths = ["lib"]
|
|
22
25
|
|
|
23
26
|
# Proc#refined is available since Ruby 4.1. 4.1.0.dev is required to allow
|
|
24
27
|
# ruby-master builds, which sort before the 4.1.0 release.
|
|
25
|
-
gem.required_ruby_version =
|
|
28
|
+
gem.required_ruby_version = ">= 4.1.0.dev"
|
|
26
29
|
|
|
27
|
-
gem.add_dependency
|
|
28
|
-
gem.add_development_dependency
|
|
29
|
-
gem.add_development_dependency
|
|
30
|
-
gem.add_development_dependency
|
|
30
|
+
gem.add_dependency "activerecord", [">= 7.0"]
|
|
31
|
+
gem.add_development_dependency "sqlite3", [">= 0"]
|
|
32
|
+
gem.add_development_dependency "minitest", [">= 0"]
|
|
33
|
+
gem.add_development_dependency "rake", [">= 0"]
|
|
31
34
|
# What cuts a release: `bump patch --tag`, then push with --follow-tags.
|
|
32
|
-
gem.add_development_dependency
|
|
35
|
+
gem.add_development_dependency "bump", [">= 0"]
|
|
36
|
+
# What benchmark/query_building.rb measures with.
|
|
37
|
+
gem.add_development_dependency "benchmark-ips", [">= 0"]
|
|
38
|
+
gem.add_development_dependency "memory_profiler", [">= 0"]
|
|
39
|
+
# The style is Rails' own: .rubocop.yml is theirs with this repository's
|
|
40
|
+
# paths, and these are the plugins it loads.
|
|
41
|
+
gem.add_development_dependency "rubocop", [">= 0"]
|
|
42
|
+
gem.add_development_dependency "rubocop-minitest", [">= 0"]
|
|
43
|
+
gem.add_development_dependency "rubocop-packaging", [">= 0"]
|
|
44
|
+
gem.add_development_dependency "rubocop-performance", [">= 0"]
|
|
45
|
+
gem.add_development_dependency "rubocop-rails", [">= 0"]
|
|
46
|
+
gem.add_development_dependency "rubocop-md", [">= 0"]
|
|
33
47
|
end
|
data/benchmark/query_building.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# Compares the cost of building the same queries through this gem's block
|
|
2
4
|
# DSL and through Active Record's other argument styles: hash conditions,
|
|
3
5
|
# string conditions, raw Arel, and relation and/or chains. Only query
|
|
4
6
|
# construction (through to_sql) is measured; every style produces the same
|
|
5
7
|
# SQL, which the script prints first as a sanity check.
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
# Gemfile:
|
|
9
|
+
# The profiling gems are development dependencies, so:
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
# ruby -Ilib benchmark/query_building.rb
|
|
11
|
+
# bundle exec ruby benchmark/query_building.rb
|
|
12
12
|
|
|
13
13
|
require "benchmark/ips"
|
|
14
14
|
require "memory_profiler"
|
|
@@ -18,7 +18,7 @@ require "activerecord-refined"
|
|
|
18
18
|
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
19
19
|
ActiveRecord::Schema.verbose = false
|
|
20
20
|
ActiveRecord::Schema.define do
|
|
21
|
-
create_table(:users) {|t| t.string :name; t.integer :age }
|
|
21
|
+
create_table(:users) { |t| t.string :name; t.integer :age }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
class User < ActiveRecord::Base; end
|
|
@@ -49,12 +49,12 @@ VARIANTS = {
|
|
|
49
49
|
"relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
|
|
50
50
|
"block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
|
|
51
51
|
},
|
|
52
|
-
}
|
|
52
|
+
}.freeze
|
|
53
53
|
|
|
54
54
|
puts "=== generated SQL (sanity) ==="
|
|
55
55
|
VARIANTS.each do |group, variants|
|
|
56
56
|
puts "--- #{group} ---"
|
|
57
|
-
variants.each {|name, thunk| puts " #{name.ljust(8)} #{thunk.call}" }
|
|
57
|
+
variants.each { |name, thunk| puts " #{name.ljust(8)} #{thunk.call}" }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
puts
|
|
@@ -63,7 +63,7 @@ VARIANTS.each do |group, variants|
|
|
|
63
63
|
puts "--- #{group} ---"
|
|
64
64
|
Benchmark.ips do |x|
|
|
65
65
|
x.config(warmup: 0.5, time: 2)
|
|
66
|
-
variants.each {|name, thunk| x.report(name, &thunk) }
|
|
66
|
+
variants.each { |name, thunk| x.report(name, &thunk) }
|
|
67
67
|
x.compare!
|
|
68
68
|
end
|
|
69
69
|
end
|
|
@@ -93,7 +93,7 @@ iseq_count = -> {
|
|
|
93
93
|
counts = ObjectSpace.count_imemo_objects
|
|
94
94
|
counts[:imemo_iseq] || counts[:iseq]
|
|
95
95
|
}
|
|
96
|
-
context = ActiveRecord::Refined::BlockContext.new
|
|
96
|
+
context = ActiveRecord::Refined::BlockContext.new(User)
|
|
97
97
|
syntax = ActiveRecord::Refined::BlockSyntax
|
|
98
98
|
|
|
99
99
|
{
|
|
@@ -117,13 +117,13 @@ puts
|
|
|
117
117
|
puts "=== where the block path spends its time ==="
|
|
118
118
|
block = proc { :name == "alice" }
|
|
119
119
|
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
120
|
-
context = ActiveRecord::Refined::BlockContext.new
|
|
120
|
+
context = ActiveRecord::Refined::BlockContext.new(User)
|
|
121
121
|
Benchmark.ips do |x|
|
|
122
122
|
x.config(warmup: 0.5, time: 2)
|
|
123
123
|
x.report("Proc#refined alone") { block.refined(ActiveRecord::Refined::BlockSyntax) }
|
|
124
124
|
x.report("instance_exec of pre-refined proc") { context.instance_exec(&refined_block) }
|
|
125
125
|
x.report("refined + instance_exec") {
|
|
126
|
-
ActiveRecord::Refined::BlockContext.new.instance_exec(&block.refined(ActiveRecord::Refined::BlockSyntax))
|
|
126
|
+
ActiveRecord::Refined::BlockContext.new(User).instance_exec(&block.refined(ActiveRecord::Refined::BlockSyntax))
|
|
127
127
|
}
|
|
128
128
|
x.compare!
|
|
129
129
|
end
|
data/examples/aggregations.rb
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
12
|
def up
|
|
11
|
-
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country }
|
|
12
|
-
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
|
-
create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
|
|
13
|
+
create_table(:authors) { |t| t.string :name; t.integer :age; t.string :country }
|
|
14
|
+
create_table(:posts) { |t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
15
|
+
create_table(:comments) { |t| t.string :body; t.integer :post_id; t.integer :score }
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
Setup.new.up
|
|
@@ -54,11 +56,11 @@ puts
|
|
|
54
56
|
# coalesce function + GROUP BY + aggregates + multiple ORDER BY
|
|
55
57
|
query2 =
|
|
56
58
|
Author.
|
|
57
|
-
group { coalesce(:country,
|
|
58
|
-
order { [count(:id).desc, coalesce(:country,
|
|
59
|
+
group { coalesce(:country, "unknown") }.
|
|
60
|
+
order { [count(:id).desc, coalesce(:country, "unknown").asc] }.
|
|
59
61
|
select {
|
|
60
62
|
[
|
|
61
|
-
coalesce(:country,
|
|
63
|
+
coalesce(:country, "unknown").as(:country),
|
|
62
64
|
count(:id).as(:author_count),
|
|
63
65
|
avg(:age).as(:avg_age),
|
|
64
66
|
]
|
|
@@ -74,7 +76,7 @@ query3 =
|
|
|
74
76
|
Author.
|
|
75
77
|
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
76
78
|
joins(:comments) { :comments[:post_id] == :posts[:id] }.
|
|
77
|
-
where { !:posts[:title].include?(
|
|
79
|
+
where { !:posts[:title].include?("draft") & (:comments[:score] >= 0) }.
|
|
78
80
|
group { :authors[:id] }.
|
|
79
81
|
having { sum(:comments[:score]) > 10 }.
|
|
80
82
|
order { sum(:comments[:score]).desc }.
|
data/examples/complex_joins.rb
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
12
|
def up
|
|
11
|
-
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country; t.integer :mentor_id }
|
|
12
|
-
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
|
-
create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
|
|
13
|
+
create_table(:authors) { |t| t.string :name; t.integer :age; t.string :country; t.integer :mentor_id }
|
|
14
|
+
create_table(:posts) { |t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
15
|
+
create_table(:comments) { |t| t.string :body; t.integer :post_id; t.integer :score }
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
Setup.new.up
|
|
@@ -47,7 +49,7 @@ query2 =
|
|
|
47
49
|
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
48
50
|
joins(:comments) { :comments[:post_id] == :posts[:id] }.
|
|
49
51
|
where {
|
|
50
|
-
!:posts[:title].include?(
|
|
52
|
+
!:posts[:title].include?("draft") &
|
|
51
53
|
!:comments[:score].in?([0, -1]) &
|
|
52
54
|
(:authors[:age] >= 18)
|
|
53
55
|
}
|
|
@@ -98,14 +100,14 @@ puts
|
|
|
98
100
|
begin
|
|
99
101
|
Author.right_outer_joins(:posts)
|
|
100
102
|
rescue ArgumentError => e
|
|
101
|
-
puts
|
|
103
|
+
puts "--- and it says so without one ---"
|
|
102
104
|
puts " #{e.message}"
|
|
103
105
|
puts
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
# 6. CROSS JOIN: every row against every row, so there is no condition to
|
|
107
109
|
# give and no block to write it in. `as` still names the table.
|
|
108
|
-
puts
|
|
110
|
+
puts "--- 6. CROSS JOIN ---"
|
|
109
111
|
puts Author.cross_joins(:posts).to_sql
|
|
110
112
|
puts Author.cross_joins(:authors, as: :others).
|
|
111
113
|
select { [:authors[:name].as(:a), :others[:name].as(:b)] }.to_sql
|
data/examples/ctes.rb
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
12
|
def up
|
|
11
|
-
create_table(: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
|
+
create_table(:categories) { |t| t.string :name; t.integer :parent_id }
|
|
14
|
+
create_table(:products) { |t| t.string :name; t.integer :category_id; t.integer :price }
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
Setup.new.up
|
|
@@ -20,14 +22,14 @@ end
|
|
|
20
22
|
class Product < ActiveRecord::Base
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
electronics = Category.create!(name:
|
|
24
|
-
computers = Category.create!(name:
|
|
25
|
-
laptops = Category.create!(name:
|
|
26
|
-
groceries = Category.create!(name:
|
|
25
|
+
electronics = Category.create!(name: "electronics")
|
|
26
|
+
computers = Category.create!(name: "computers", parent_id: electronics.id)
|
|
27
|
+
laptops = Category.create!(name: "laptops", parent_id: computers.id)
|
|
28
|
+
groceries = Category.create!(name: "groceries")
|
|
27
29
|
|
|
28
|
-
Product.create!(name:
|
|
29
|
-
Product.create!(name:
|
|
30
|
-
Product.create!(name:
|
|
30
|
+
Product.create!(name: "ultrabook", category_id: laptops.id, price: 1200)
|
|
31
|
+
Product.create!(name: "keyboard", category_id: computers.id, price: 80)
|
|
32
|
+
Product.create!(name: "apple", category_id: groceries.id, price: 2)
|
|
31
33
|
|
|
32
34
|
# 1. Recursive CTE: every category below 'electronics', itself included.
|
|
33
35
|
# The recursive member joins the CTE by name, so its ON clause is a block
|
|
@@ -47,7 +49,7 @@ subtree =
|
|
|
47
49
|
]
|
|
48
50
|
).from_cte(:tree)
|
|
49
51
|
|
|
50
|
-
puts
|
|
52
|
+
puts "--- 1. Recursive CTE walking a category tree ---"
|
|
51
53
|
puts subtree.to_sql
|
|
52
54
|
puts subtree.order { :name }.pluck(:name).inspect
|
|
53
55
|
puts
|
|
@@ -68,14 +70,14 @@ forest =
|
|
|
68
70
|
]
|
|
69
71
|
).from_cte(:tree).order { [:depth, :id] }
|
|
70
72
|
|
|
71
|
-
puts
|
|
73
|
+
puts "--- 2. Recursive CTE carrying the root and the depth down ---"
|
|
72
74
|
puts forest.to_sql
|
|
73
|
-
puts forest.map {|c| [c.name, c.root_id, c.depth] }.inspect
|
|
75
|
+
puts forest.map { |c| [c.name, c.root_id, c.depth] }.inspect
|
|
74
76
|
|
|
75
77
|
# The alias from_cte puts on the CTE is what lets this `where` qualify
|
|
76
78
|
# root_id; without it the column would be looked for in a table the query no
|
|
77
79
|
# longer has.
|
|
78
|
-
puts forest.where { :root_id == electronics.id }.map {|c| [c.name, c.depth] }.inspect
|
|
80
|
+
puts forest.where { :root_id == electronics.id }.map { |c| [c.name, c.depth] }.inspect
|
|
79
81
|
puts
|
|
80
82
|
|
|
81
83
|
# 3. The same CTE as a subquery: products anywhere under 'electronics'.
|
|
@@ -88,7 +90,7 @@ products_below =
|
|
|
88
90
|
]
|
|
89
91
|
).joins(:tree) { :tree[:id] == :products[:category_id] }
|
|
90
92
|
|
|
91
|
-
puts
|
|
93
|
+
puts "--- 3. Recursive CTE joined from the outer query ---"
|
|
92
94
|
puts products_below.to_sql
|
|
93
95
|
puts products_below.order { :name }.pluck(:name).inspect
|
|
94
96
|
puts
|
|
@@ -107,7 +109,7 @@ expensive =
|
|
|
107
109
|
]
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
puts
|
|
112
|
+
puts "--- 4. Plain CTE joined and aggregated ---"
|
|
111
113
|
puts expensive.to_sql
|
|
112
|
-
puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
114
|
+
puts expensive.map { |c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
113
115
|
puts
|
data/examples/expressions.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'activerecord-refined'
|
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "activerecord-refined"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
7
9
|
ActiveRecord::Migration.verbose = false
|
|
8
10
|
|
|
9
11
|
class Setup < ActiveRecord::Migration[8.1]
|
|
@@ -22,10 +24,10 @@ Setup.new.up
|
|
|
22
24
|
class LineItem < ActiveRecord::Base
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
LineItem.create!(sku:
|
|
26
|
-
LineItem.create!(sku:
|
|
27
|
-
LineItem.create!(sku:
|
|
28
|
-
LineItem.create!(sku:
|
|
27
|
+
LineItem.create!(sku: "A-1", category: "tools", price: 1200, quantity: 2, flags: 12)
|
|
28
|
+
LineItem.create!(sku: "A-2", category: "tools", price: 300, quantity: 5, flags: 10)
|
|
29
|
+
LineItem.create!(sku: "B-1", category: "paper", price: 80, quantity: 10, flags: 3)
|
|
30
|
+
LineItem.create!(sku: "C-1", category: nil, price: 50, quantity: 1, flags: 4)
|
|
29
31
|
|
|
30
32
|
def show(title, relation, rows = nil)
|
|
31
33
|
puts "--- #{title} ---"
|
|
@@ -36,31 +38,44 @@ end
|
|
|
36
38
|
|
|
37
39
|
# 1. Arithmetic. Ruby puts + - * / above the comparison operators, so an
|
|
38
40
|
# expression groups the way it reads, without parentheses.
|
|
39
|
-
show(
|
|
41
|
+
show("arithmetic in a condition",
|
|
40
42
|
LineItem.where { :price * :quantity > 1000 },
|
|
41
43
|
LineItem.where { :price * :quantity > 1000 }.pluck(:sku))
|
|
42
44
|
|
|
43
|
-
show(
|
|
45
|
+
show("arithmetic in a select list, and aggregated",
|
|
44
46
|
LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] },
|
|
45
47
|
LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] }.
|
|
46
|
-
map {|i| [i.sku, i.subtotal] })
|
|
48
|
+
map { |i| [i.sku, i.subtotal] })
|
|
47
49
|
|
|
48
|
-
show(
|
|
50
|
+
show("an aggregate over an expression",
|
|
49
51
|
LineItem.select { sum(:price * :quantity).as(:total) },
|
|
50
52
|
LineItem.select { sum(:price * :quantity).as(:total) }.first.total)
|
|
51
53
|
|
|
54
|
+
# The number may stand on the left; only a column or an expression on the
|
|
55
|
+
# right builds a query, so Ruby's own arithmetic is untouched. BigDecimal
|
|
56
|
+
# is a number here too -- what a decimal column's values are.
|
|
57
|
+
show("the number on the left, and a BigDecimal",
|
|
58
|
+
LineItem.select { [:sku, (12 - :quantity).as(:to_the_dozen)] },
|
|
59
|
+
LineItem.select { [:sku, (12 - :quantity).as(:to_the_dozen)] }.
|
|
60
|
+
map { |i| [i.sku, i.to_the_dozen] })
|
|
61
|
+
|
|
62
|
+
show("a tax through BigDecimal, exact on the wire",
|
|
63
|
+
LineItem.select { [:sku, (BigDecimal("1.1") * :price).as(:taxed)] },
|
|
64
|
+
LineItem.select { [:sku, (BigDecimal("1.1") * :price).as(:taxed)] }.
|
|
65
|
+
map { |i| [i.sku, i.taxed] })
|
|
66
|
+
|
|
52
67
|
# The bitwise operators. & and | are AND and OR between conditions, which is
|
|
53
68
|
# what leaves them free here. Each expression parenthesises itself, so the
|
|
54
69
|
# grouping is Ruby's rather than the adapter's.
|
|
55
|
-
show(
|
|
70
|
+
show("a bit test in a condition",
|
|
56
71
|
LineItem.where { :flags & 4 > 0 },
|
|
57
72
|
LineItem.where { :flags & 4 > 0 }.pluck(:sku))
|
|
58
73
|
|
|
59
74
|
# XOR is the one the three adapters do not share. SQLite has none, so it gets
|
|
60
75
|
# the two operations XOR is made of; PostgreSQL would say #, MySQL ^.
|
|
61
|
-
show(
|
|
76
|
+
show("xor, spelled the way the adapter spells it",
|
|
62
77
|
LineItem.select { [:sku, (:flags ^ 10).as(:xored)] },
|
|
63
|
-
LineItem.select { [:sku, (:flags ^ 10).as(:xored)] }.map {|i| [i.sku, i.xored] })
|
|
78
|
+
LineItem.select { [:sku, (:flags ^ 10).as(:xored)] }.map { |i| [i.sku, i.xored] })
|
|
64
79
|
|
|
65
80
|
# A condition cannot be an operand, and neither can a boolean column: two of
|
|
66
81
|
# the three adapters would quietly answer as AND does and the third has no
|
|
@@ -68,86 +83,106 @@ show('xor, spelled the way the adapter spells it',
|
|
|
68
83
|
begin
|
|
69
84
|
LineItem.where { :flags & (:price == 1) }
|
|
70
85
|
rescue ArgumentError => e
|
|
71
|
-
puts
|
|
86
|
+
puts "--- a condition is not an operand of & ---"
|
|
72
87
|
puts " #{e.message}"
|
|
73
88
|
puts
|
|
74
89
|
end
|
|
75
90
|
|
|
76
91
|
# 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
|
|
77
92
|
# COUNT(DISTINCT ...); the rest are sum, avg, min and max.
|
|
78
|
-
show(
|
|
93
|
+
show("COUNT(*) and COUNT(DISTINCT ...)",
|
|
79
94
|
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] },
|
|
80
95
|
LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
|
|
81
|
-
map {|i| [i.rows, i.categories] })
|
|
96
|
+
map { |i| [i.rows, i.categories] })
|
|
82
97
|
|
|
83
98
|
# filter takes the aggregate over the rows a condition holds for. SQLite and
|
|
84
99
|
# PostgreSQL have the FILTER clause; MySQL gets the CASE that means the same,
|
|
85
100
|
# since an aggregate passes over the NULL a missed row leaves.
|
|
86
|
-
show(
|
|
101
|
+
show("two aggregates over different rows of the same query",
|
|
87
102
|
LineItem.select {
|
|
88
|
-
[count(:*).as(:all), sum(:price).filter { :category ==
|
|
103
|
+
[count(:*).as(:all), sum(:price).filter { :category == "tools" }.as(:tools)]
|
|
89
104
|
},
|
|
90
105
|
LineItem.select {
|
|
91
|
-
[count(:*).as(:all), sum(:price).filter { :category ==
|
|
92
|
-
}.map {|i| [i.all, i.tools] })
|
|
106
|
+
[count(:*).as(:all), sum(:price).filter { :category == "tools" }.as(:tools)]
|
|
107
|
+
}.map { |i| [i.all, i.tools] })
|
|
93
108
|
|
|
94
109
|
# CASE has two shapes: an operand to compare each when against, or a condition
|
|
95
110
|
# on every when. case is a Ruby keyword, so the method behind both is only
|
|
96
111
|
# reachable through the receiver -- self.case -- and each shape has a shorthand
|
|
97
112
|
# that does not need it.
|
|
98
|
-
show(
|
|
99
|
-
LineItem.select { [:sku, :category.when(
|
|
100
|
-
LineItem.select { [:sku, :category.when(
|
|
101
|
-
map {|i| [i.sku, i.kind] })
|
|
113
|
+
show("a CASE with an operand, through the shorthand",
|
|
114
|
+
LineItem.select { [:sku, :category.when("tools").then("hardware").else("other").as(:kind)] },
|
|
115
|
+
LineItem.select { [:sku, :category.when("tools").then("hardware").else("other").as(:kind)] }.
|
|
116
|
+
map { |i| [i.sku, i.kind] })
|
|
102
117
|
|
|
103
|
-
show(
|
|
118
|
+
show("a CASE where each when carries its own condition",
|
|
104
119
|
LineItem.select {
|
|
105
|
-
[:sku, case_when { :price >= 1000 }.then(
|
|
106
|
-
then(
|
|
120
|
+
[:sku, case_when { :price >= 1000 }.then("dear").when { :price >= 100 }.
|
|
121
|
+
then("middling").else("cheap").as(:band)]
|
|
107
122
|
},
|
|
108
123
|
LineItem.select {
|
|
109
|
-
[:sku, case_when { :price >= 1000 }.then(
|
|
110
|
-
then(
|
|
111
|
-
}.map {|i| [i.sku, i.band] })
|
|
124
|
+
[:sku, case_when { :price >= 1000 }.then("dear").when { :price >= 100 }.
|
|
125
|
+
then("middling").else("cheap").as(:band)]
|
|
126
|
+
}.map { |i| [i.sku, i.band] })
|
|
112
127
|
|
|
113
128
|
# It is an expression like any other, so it goes inside an aggregate too.
|
|
114
|
-
show(
|
|
129
|
+
show("counting with a CASE",
|
|
115
130
|
LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) },
|
|
116
131
|
LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) }.
|
|
117
|
-
map {|i| i.dear_ones })
|
|
132
|
+
map { |i| i.dear_ones })
|
|
118
133
|
|
|
119
134
|
# 3. Functions. Seven scalar ones have methods of their own; fn reaches
|
|
120
135
|
# anything else, emitting the name as written.
|
|
121
|
-
show(
|
|
136
|
+
show("built-in functions and the fn escape hatch",
|
|
122
137
|
LineItem.
|
|
123
138
|
where { length(:sku) == 3 }.
|
|
124
|
-
select { [upper(:sku).as(:sku), coalesce(:category,
|
|
139
|
+
select { [upper(:sku).as(:sku), coalesce(:category, "unsorted").as(:category)] },
|
|
125
140
|
LineItem.
|
|
126
141
|
where { length(:sku) == 3 }.
|
|
127
|
-
select { [upper(:sku).as(:sku), coalesce(:category,
|
|
128
|
-
map {|i| [i.sku, i.category] })
|
|
142
|
+
select { [upper(:sku).as(:sku), coalesce(:category, "unsorted").as(:category)] }.
|
|
143
|
+
map { |i| [i.sku, i.category] })
|
|
129
144
|
|
|
130
|
-
show(
|
|
145
|
+
show("fn, for a function without a method of its own",
|
|
131
146
|
LineItem.select { fn(:hex, :price).as(:hex_price) },
|
|
132
147
|
LineItem.select { fn(:hex, :price).as(:hex_price) }.map(&:hex_price))
|
|
133
148
|
|
|
149
|
+
# op is the same for operators: the operator is emitted as written --
|
|
150
|
+
# checked against the operator characters -- and both sides are quoted
|
|
151
|
+
# values, columns or expressions. What it gives compares like any other
|
|
152
|
+
# expression.
|
|
153
|
+
show("op, for an operator without a method of its own",
|
|
154
|
+
LineItem.where { op("%", :quantity, 2) == 1 },
|
|
155
|
+
LineItem.where { op("%", :quantity, 2) == 1 }.pluck(:sku))
|
|
156
|
+
|
|
157
|
+
# sql is the last resort, and the one way a string means SQL inside a block:
|
|
158
|
+
# a bare string is refused there, and ? and :name placeholders take quoted
|
|
159
|
+
# values. What it gives is parenthesized wherever it stands as an operand.
|
|
160
|
+
show("sql, for what neither fn nor op can spell",
|
|
161
|
+
LineItem.where { sql("price % ?", 100) == 0 },
|
|
162
|
+
LineItem.where { sql("price % ?", 100) == 0 }.pluck(:sku))
|
|
163
|
+
|
|
164
|
+
# A string sent `as` is a value, like a number.
|
|
165
|
+
show("a string literal in a select list",
|
|
166
|
+
LineItem.select { [:sku, "listed".as(:state)] },
|
|
167
|
+
LineItem.select { [:sku, "listed".as(:state)] }.map { |i| [i.sku, i.state] })
|
|
168
|
+
|
|
134
169
|
# 4. Ordering. asc and desc take nulls_first / nulls_last. MySQL has no
|
|
135
170
|
# such syntax, but Arel emulates it there, so the order is the same
|
|
136
171
|
# everywhere.
|
|
137
|
-
show(
|
|
172
|
+
show("NULLS LAST",
|
|
138
173
|
LineItem.order { [:category.asc.nulls_last, :sku.asc] },
|
|
139
174
|
LineItem.order { [:category.asc.nulls_last, :sku.asc] }.pluck(:category, :sku))
|
|
140
175
|
|
|
141
176
|
# Aggregates and expressions can be ordered by, too.
|
|
142
|
-
show(
|
|
177
|
+
show("grouped, aggregated and ordered by the aggregate",
|
|
143
178
|
LineItem.
|
|
144
179
|
group { :category }.
|
|
145
180
|
having { count(:*) > 1 }.
|
|
146
181
|
order { sum(:price * :quantity).desc }.
|
|
147
|
-
select { [coalesce(:category,
|
|
182
|
+
select { [coalesce(:category, "unsorted").as(:category), sum(:price * :quantity).as(:total)] },
|
|
148
183
|
LineItem.
|
|
149
184
|
group { :category }.
|
|
150
185
|
having { count(:*) > 1 }.
|
|
151
186
|
order { sum(:price * :quantity).desc }.
|
|
152
|
-
select { [coalesce(:category,
|
|
153
|
-
map {|i| [i.category, i.total] })
|
|
187
|
+
select { [coalesce(:category, "unsorted").as(:category), sum(:price * :quantity).as(:total)] }.
|
|
188
|
+
map { |i| [i.category, i.total] })
|