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.
@@ -1,33 +1,47 @@
1
1
  # -*- encoding: utf-8 -*-
2
- lib = File.expand_path('../lib', __FILE__)
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 'activerecord-refined/version'
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 = 'Adding clean and powerful query syntax on Active Record using refinements'
12
- gem.summary = 'Write Active Record queries as Ruby expressions'
13
- gem.homepage = 'https://github.com/shugo/activerecord-refined'
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 is
17
- # addressed to whoever is working on the repository, not to anyone using it.
18
- gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$})
19
- gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
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 = '>= 4.1.0.dev'
28
+ gem.required_ruby_version = ">= 4.1.0.dev"
26
29
 
27
- gem.add_dependency 'activerecord', ['>= 7.0']
28
- gem.add_development_dependency 'sqlite3', ['>= 0']
29
- gem.add_development_dependency 'minitest', ['>= 0']
30
- gem.add_development_dependency 'rake', ['>= 0']
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 'bump', ['>= 0']
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
@@ -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
- # Run without bundler, so the profiling gems don't need to live in the
8
- # Gemfile:
9
+ # The profiling gems are development dependencies, so:
9
10
  #
10
- # gem install benchmark-ips memory_profiler
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
@@ -1,16 +1,18 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
5
+ require "active_record"
6
+ require "activerecord-refined"
7
+
8
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
7
9
  ActiveRecord::Migration.verbose = false
8
10
 
9
11
  class Setup < ActiveRecord::Migration[8.1]
10
12
  def up
11
- create_table(:authors) {|t| t.string :name; 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, 'unknown') }.
58
- order { [count(:id).desc, coalesce(:country, 'unknown').asc] }.
59
+ group { coalesce(:country, "unknown") }.
60
+ order { [count(:id).desc, coalesce(:country, "unknown").asc] }.
59
61
  select {
60
62
  [
61
- coalesce(:country, 'unknown').as(: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?('draft') & (:comments[:score] >= 0) }.
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 }.
@@ -1,16 +1,18 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
5
+ require "active_record"
6
+ require "activerecord-refined"
7
+
8
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
7
9
  ActiveRecord::Migration.verbose = false
8
10
 
9
11
  class Setup < ActiveRecord::Migration[8.1]
10
12
  def up
11
- create_table(:authors) {|t| t.string :name; 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?('draft') &
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 '--- and it says so without one ---'
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 '--- 6. CROSS JOIN ---'
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
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
5
+ require "active_record"
6
+ require "activerecord-refined"
7
+
8
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
7
9
  ActiveRecord::Migration.verbose = false
8
10
 
9
11
  class Setup < ActiveRecord::Migration[8.1]
10
12
  def up
11
- create_table(: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: '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')
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: '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)
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 '--- 1. Recursive CTE walking a category tree ---'
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 '--- 2. Recursive CTE carrying the root and the depth down ---'
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 '--- 3. Recursive CTE joined from the outer query ---'
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 '--- 4. Plain CTE joined and aggregated ---'
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
@@ -1,9 +1,11 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
1
+ # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'activerecord-refined'
3
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
5
4
 
6
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
5
+ require "active_record"
6
+ require "activerecord-refined"
7
+
8
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
7
9
  ActiveRecord::Migration.verbose = false
8
10
 
9
11
  class Setup < ActiveRecord::Migration[8.1]
@@ -22,10 +24,10 @@ Setup.new.up
22
24
  class LineItem < ActiveRecord::Base
23
25
  end
24
26
 
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)
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('arithmetic in a condition',
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('arithmetic in a select list, and aggregated',
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('an aggregate over an expression',
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('a bit test in a condition',
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('xor, spelled the way the adapter spells it',
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 '--- a condition is not an operand of & ---'
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('COUNT(*) and COUNT(DISTINCT ...)',
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('two aggregates over different rows of the same query',
101
+ show("two aggregates over different rows of the same query",
87
102
  LineItem.select {
88
- [count(:*).as(:all), sum(:price).filter { :category == 'tools' }.as(:tools)]
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 == 'tools' }.as(:tools)]
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('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] })
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('a CASE where each when carries its own condition',
118
+ show("a CASE where each when carries its own condition",
104
119
  LineItem.select {
105
- [:sku, case_when { :price >= 1000 }.then('dear').when { :price >= 100 }.
106
- then('middling').else('cheap').as(:band)]
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('dear').when { :price >= 100 }.
110
- then('middling').else('cheap').as(:band)]
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('counting with a CASE',
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('built-in functions and the fn escape hatch',
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, 'unsorted').as(: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, 'unsorted').as(: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('fn, for a function without a method of its own',
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('NULLS LAST',
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('grouped, aggregated and ordered by the aggregate',
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, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] },
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, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] }.
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] })