activerecord-refined 0.8.1 → 0.10.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +111 -815
  4. data/activerecord-refined.gemspec +36 -17
  5. data/docs/conditions.md +206 -0
  6. data/docs/ctes.md +65 -0
  7. data/docs/expressions.md +125 -0
  8. data/docs/functions.md +219 -0
  9. data/docs/grouping.md +55 -0
  10. data/docs/joins.md +73 -0
  11. data/docs/json.md +230 -0
  12. data/docs/ordering.md +55 -0
  13. data/docs/time_zones.md +30 -0
  14. data/docs/windows.md +41 -0
  15. data/docs/writing.md +33 -0
  16. data/examples/aggregations.rb +31 -11
  17. data/examples/complex_joins.rb +12 -10
  18. data/examples/ctes.rb +22 -20
  19. data/examples/expressions.rb +110 -45
  20. data/examples/json.rb +77 -38
  21. data/examples/postgresql.rb +64 -53
  22. data/examples/predicates.rb +35 -33
  23. data/examples/subqueries.rb +20 -18
  24. data/examples/windows.rb +23 -21
  25. data/examples/writes.rb +26 -24
  26. data/lib/active_record/refined/ast.rb +1117 -373
  27. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  28. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  29. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  30. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  31. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  32. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  33. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  34. data/lib/active_record/refined/dialect.rb +340 -0
  35. data/lib/active_record/refined.rb +877 -307
  36. data/lib/activerecord-refined/version.rb +3 -1
  37. data/lib/activerecord-refined.rb +9 -5
  38. metadata +186 -15
  39. data/.github/workflows/push_gem.yml +0 -45
  40. data/.github/workflows/sandbox.yml +0 -295
  41. data/.github/workflows/test.yml +0 -90
  42. data/.gitignore +0 -19
  43. data/Gemfile +0 -12
  44. data/Rakefile +0 -25
  45. data/benchmark/query_building.rb +0 -129
  46. data/test/test_block_syntax.rb +0 -2493
  47. data/test/test_helper.rb +0 -221
data/docs/windows.md ADDED
@@ -0,0 +1,41 @@
1
+ # Window functions
2
+
3
+
4
+ `over` gives a function a window, which is what turns an aggregate into a
5
+ running one and the only thing `row_number` and its kind can be used with.
6
+ The window is built by chaining, as Arel's own is:
7
+
8
+ ```ruby
9
+ Author.select { avg(:age).over.partition(:country).as(:country_average) }
10
+ # AVG("age") OVER (PARTITION BY "country") AS country_average
11
+
12
+ Author.select { row_number.over.partition(:country).order(:age.desc).as(:rank) }
13
+ # ROW_NUMBER() OVER (PARTITION BY "country" ORDER BY "age" DESC) AS rank
14
+
15
+ Author.select { count(:*).over.as(:total) } # COUNT(*) OVER () — every row
16
+ ```
17
+
18
+ `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`,
19
+ `lag`, `lead`, `first_value`, `last_value` and `nth_value` are the functions
20
+ that say nothing without a window; each raises `ArgumentError` if `over` never
21
+ arrives, rather than reaching the database as an error there. Every adapter
22
+ that has window functions at all spells them the same way, so unlike the
23
+ scalar functions there is nothing here to translate.
24
+
25
+ A frame is a range of rows counted from the current one — negative before it,
26
+ positive after, 0 the row itself, and an open end for unbounded:
27
+
28
+ ```ruby
29
+ Post.select { sum(:likes).over.order(:created_at).rows(..0).as(:running) }
30
+ # SUM("likes") OVER (ORDER BY "created_at" ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
31
+
32
+ Post.select { avg(:likes).over.order(:created_at).rows(-1..1).as(:smoothed) }
33
+ # ... ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
34
+
35
+ Post.select { sum(:likes).over.order(:created_at).rows(0..).as(:remaining) }
36
+ # ... ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
37
+ ```
38
+
39
+ `range` says `RANGE` where `rows` says `ROWS`, and a window has one frame or
40
+ none. Named windows — `WINDOW w AS (...)` — have no clause in Active Record to
41
+ live in, so they are not here.
data/docs/writing.md ADDED
@@ -0,0 +1,33 @@
1
+ # Writing
2
+
3
+
4
+ `update_all` reads its hash the way Active Record does — `update_all(likes: :likes)`
5
+ sets the column to the symbol itself. The block reads a symbol as the column it
6
+ names, as every other block here does, which is what lets the new value be
7
+ worked out from the old:
8
+
9
+ ```ruby
10
+ Post.where { :published == true }.update_all { { likes: :likes + 1 } }
11
+ # UPDATE "posts" SET "likes" = ("posts"."likes" + 1) WHERE ...
12
+
13
+ Post.update_all { { title: upper(:title), likes: case_when { :likes < 0 }.then(0).else(:likes) } }
14
+ ```
15
+
16
+ `upsert_all` takes one too, for the part that decides what happens to a row
17
+ that is already there. `excluded` is the row that could not be inserted:
18
+
19
+ ```ruby
20
+ Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
21
+ # ... ON CONFLICT ("page") DO UPDATE SET "hits"=("tallies"."hits" + "excluded"."hits")
22
+ ```
23
+
24
+ PostgreSQL and SQLite name that row `excluded`; MySQL spells the same thing
25
+ `VALUES(column)`, and the block comes out as whichever the adapter reads.
26
+ Active Record's own `on_duplicate:` takes SQL text and nothing else, so this is
27
+ the one place the DSL writes SQL out itself rather than handing Arel a tree —
28
+ and the two cannot both be given.
29
+
30
+ `insert_all` has no block: its values are literals by construction.
31
+ Active Record type-casts each one on the way into the `VALUES` list, so an
32
+ expression does not become SQL there — it becomes nothing, silently. Use
33
+ `upsert_all` where a row's value has to be worked out.
@@ -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 }.
@@ -89,3 +91,21 @@ query3 =
89
91
  puts "--- 3. Comment score aggregation across multi-table JOIN ---"
90
92
  puts query3.to_sql
91
93
  puts
94
+
95
+ # 4. The titles of each author's posts joined into one string
96
+ # string_agg + order inside the aggregate + filter; group_concat on SQLite
97
+ query4 =
98
+ Author.
99
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
100
+ group { :authors[:id] }.
101
+ select {
102
+ [
103
+ :authors[:name],
104
+ string_agg(:posts[:title], ", ").order(:posts[:title]).as(:titles),
105
+ string_agg(:posts[:title], ", ").filter { :posts[:published] == true }.as(:published),
106
+ ]
107
+ }
108
+
109
+ puts "--- 4. Titles per author, joined (string_agg / order / filter) ---"
110
+ puts query4.to_sql
111
+ puts
@@ -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]
@@ -14,6 +16,7 @@ class Setup < ActiveRecord::Migration[8.1]
14
16
  t.integer :price
15
17
  t.integer :quantity
16
18
  t.integer :flags
19
+ t.date :ordered_on
17
20
  end
18
21
  end
19
22
  end
@@ -22,10 +25,16 @@ Setup.new.up
22
25
  class LineItem < ActiveRecord::Base
23
26
  end
24
27
 
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)
28
+ LineItem.create!(sku: "A-1", category: "tools", price: 1200, quantity: 2, flags: 12,
29
+ ordered_on: Date.new(2026, 1, 5))
30
+ LineItem.create!(sku: "A-2", category: "tools", price: 300, quantity: 5, flags: 10,
31
+ ordered_on: Date.new(2026, 1, 20))
32
+ LineItem.create!(sku: "B-1", category: "paper", price: 80, quantity: 10, flags: 3,
33
+ ordered_on: Date.new(2026, 2, 3))
34
+ LineItem.create!(sku: "B-2", category: "paper", price: 80, quantity: 10, flags: 3,
35
+ ordered_on: Date.new(2026, 2, 3))
36
+ LineItem.create!(sku: "C-1", category: nil, price: 50, quantity: 1, flags: 4,
37
+ ordered_on: Date.new(2026, 2, 14))
29
38
 
30
39
  def show(title, relation, rows = nil)
31
40
  puts "--- #{title} ---"
@@ -36,31 +45,55 @@ end
36
45
 
37
46
  # 1. Arithmetic. Ruby puts + - * / above the comparison operators, so an
38
47
  # expression groups the way it reads, without parentheses.
39
- show('arithmetic in a condition',
48
+ show("arithmetic in a condition",
40
49
  LineItem.where { :price * :quantity > 1000 },
41
50
  LineItem.where { :price * :quantity > 1000 }.pluck(:sku))
42
51
 
43
- show('arithmetic in a select list, and aggregated',
52
+ show("arithmetic in a select list, and aggregated",
44
53
  LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] },
45
54
  LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] }.
46
- map {|i| [i.sku, i.subtotal] })
55
+ map { |i| [i.sku, i.subtotal] })
47
56
 
48
- show('an aggregate over an expression',
57
+ show("an aggregate over an expression",
49
58
  LineItem.select { sum(:price * :quantity).as(:total) },
50
59
  LineItem.select { sum(:price * :quantity).as(:total) }.first.total)
51
60
 
61
+ # The number may stand on the left; only a column or an expression on the
62
+ # right builds a query, so Ruby's own arithmetic is untouched. BigDecimal
63
+ # is a number here too -- what a decimal column's values are.
64
+ show("the number on the left, and a BigDecimal",
65
+ LineItem.select { [:sku, (12 - :quantity).as(:to_the_dozen)] },
66
+ LineItem.select { [:sku, (12 - :quantity).as(:to_the_dozen)] }.
67
+ map { |i| [i.sku, i.to_the_dozen] })
68
+
69
+ show("a tax through BigDecimal, exact on the wire",
70
+ LineItem.select { [:sku, (BigDecimal("1.1") * :price).as(:taxed)] },
71
+ LineItem.select { [:sku, (BigDecimal("1.1") * :price).as(:taxed)] }.
72
+ map { |i| [i.sku, i.taxed] })
73
+
74
+ # A duration moves a date. Each adapter spells the move its own way; SQLite
75
+ # has date() and datetime(), and a date column keeps being a date.
76
+ show("a date moved by a duration",
77
+ LineItem.where { :ordered_on + 30.days < Date.new(2026, 2, 10) },
78
+ LineItem.where { :ordered_on + 30.days < Date.new(2026, 2, 10) }.pluck(:sku))
79
+
80
+ show("a due date a month on",
81
+ LineItem.select { [:sku, (:ordered_on + 1.month).as(:due_on)] },
82
+ LineItem.select { [:sku, (:ordered_on + 1.month).as(:due_on)] }.
83
+ map { |i| [i.sku, i.due_on] })
84
+
52
85
  # The bitwise operators. & and | are AND and OR between conditions, which is
53
86
  # what leaves them free here. Each expression parenthesises itself, so the
54
87
  # grouping is Ruby's rather than the adapter's.
55
- show('a bit test in a condition',
88
+ show("a bit test in a condition",
56
89
  LineItem.where { :flags & 4 > 0 },
57
90
  LineItem.where { :flags & 4 > 0 }.pluck(:sku))
58
91
 
59
92
  # XOR is the one the three adapters do not share. SQLite has none, so it gets
60
93
  # the two operations XOR is made of; PostgreSQL would say #, MySQL ^.
61
- show('xor, spelled the way the adapter spells it',
94
+ show("xor, spelled the way the adapter spells it",
62
95
  LineItem.select { [:sku, (:flags ^ 10).as(:xored)] },
63
- LineItem.select { [:sku, (:flags ^ 10).as(:xored)] }.map {|i| [i.sku, i.xored] })
96
+ LineItem.select { [:sku, (:flags ^ 10).as(:xored)] }.map { |i| [i.sku, i.xored] })
64
97
 
65
98
  # A condition cannot be an operand, and neither can a boolean column: two of
66
99
  # the three adapters would quietly answer as AND does and the third has no
@@ -68,86 +101,118 @@ show('xor, spelled the way the adapter spells it',
68
101
  begin
69
102
  LineItem.where { :flags & (:price == 1) }
70
103
  rescue ArgumentError => e
71
- puts '--- a condition is not an operand of & ---'
104
+ puts "--- a condition is not an operand of & ---"
72
105
  puts " #{e.message}"
73
106
  puts
74
107
  end
75
108
 
76
109
  # 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
77
- # COUNT(DISTINCT ...); the rest are sum, avg, min and max.
78
- show('COUNT(*) and COUNT(DISTINCT ...)',
110
+ # COUNT(DISTINCT ...), which sum and avg take too; the rest are min and max.
111
+ show("COUNT(*) and COUNT(DISTINCT ...)",
79
112
  LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] },
80
113
  LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
81
- map {|i| [i.rows, i.categories] })
114
+ map { |i| [i.rows, i.categories] })
115
+
116
+ show("SUM(DISTINCT ...), each quantity counted once",
117
+ LineItem.select { [sum(:quantity).as(:all), sum(:quantity, distinct: true).as(:once)] },
118
+ LineItem.select { [sum(:quantity).as(:all), sum(:quantity, distinct: true).as(:once)] }.
119
+ map { |i| [i.all, i.once] })
82
120
 
83
121
  # filter takes the aggregate over the rows a condition holds for. SQLite and
84
122
  # PostgreSQL have the FILTER clause; MySQL gets the CASE that means the same,
85
123
  # since an aggregate passes over the NULL a missed row leaves.
86
- show('two aggregates over different rows of the same query',
124
+ show("two aggregates over different rows of the same query",
87
125
  LineItem.select {
88
- [count(:*).as(:all), sum(:price).filter { :category == 'tools' }.as(:tools)]
126
+ [count(:*).as(:all), sum(:price).filter { :category == "tools" }.as(:tools)]
89
127
  },
90
128
  LineItem.select {
91
- [count(:*).as(:all), sum(:price).filter { :category == 'tools' }.as(:tools)]
92
- }.map {|i| [i.all, i.tools] })
129
+ [count(:*).as(:all), sum(:price).filter { :category == "tools" }.as(:tools)]
130
+ }.map { |i| [i.all, i.tools] })
93
131
 
94
132
  # CASE has two shapes: an operand to compare each when against, or a condition
95
133
  # on every when. case is a Ruby keyword, so the method behind both is only
96
134
  # reachable through the receiver -- self.case -- and each shape has a shorthand
97
135
  # 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] })
136
+ show("a CASE with an operand, through the shorthand",
137
+ LineItem.select { [:sku, :category.when("tools").then("hardware").else("other").as(:kind)] },
138
+ LineItem.select { [:sku, :category.when("tools").then("hardware").else("other").as(:kind)] }.
139
+ map { |i| [i.sku, i.kind] })
102
140
 
103
- show('a CASE where each when carries its own condition',
141
+ show("a CASE where each when carries its own condition",
104
142
  LineItem.select {
105
- [:sku, case_when { :price >= 1000 }.then('dear').when { :price >= 100 }.
106
- then('middling').else('cheap').as(:band)]
143
+ [:sku, case_when { :price >= 1000 }.then("dear").when { :price >= 100 }.
144
+ then("middling").else("cheap").as(:band)]
107
145
  },
108
146
  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] })
147
+ [:sku, case_when { :price >= 1000 }.then("dear").when { :price >= 100 }.
148
+ then("middling").else("cheap").as(:band)]
149
+ }.map { |i| [i.sku, i.band] })
112
150
 
113
151
  # It is an expression like any other, so it goes inside an aggregate too.
114
- show('counting with a CASE',
152
+ show("counting with a CASE",
115
153
  LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) },
116
154
  LineItem.select { sum(case_when { :price >= 100 }.then(1).else(0)).as(:dear_ones) }.
117
- map {|i| i.dear_ones })
155
+ map { |i| i.dear_ones })
118
156
 
119
157
  # 3. Functions. Seven scalar ones have methods of their own; fn reaches
120
158
  # anything else, emitting the name as written.
121
- show('built-in functions and the fn escape hatch',
159
+ show("built-in functions and the fn escape hatch",
122
160
  LineItem.
123
161
  where { length(:sku) == 3 }.
124
- select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] },
162
+ select { [upper(:sku).as(:sku), coalesce(:category, "unsorted").as(:category)] },
125
163
  LineItem.
126
164
  where { length(:sku) == 3 }.
127
- select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] }.
128
- map {|i| [i.sku, i.category] })
165
+ select { [upper(:sku).as(:sku), coalesce(:category, "unsorted").as(:category)] }.
166
+ map { |i| [i.sku, i.category] })
129
167
 
130
- show('fn, for a function without a method of its own',
168
+ show("fn, for a function without a method of its own",
131
169
  LineItem.select { fn(:hex, :price).as(:hex_price) },
132
170
  LineItem.select { fn(:hex, :price).as(:hex_price) }.map(&:hex_price))
133
171
 
172
+ # op is the same for operators: the operator is emitted as written --
173
+ # checked against the operator characters -- and both sides are quoted
174
+ # values, columns or expressions. What it gives compares like any other
175
+ # expression.
176
+ show("op, for an operator without a method of its own",
177
+ LineItem.where { op("%", :quantity, 2) == 1 },
178
+ LineItem.where { op("%", :quantity, 2) == 1 }.pluck(:sku))
179
+
180
+ # sql is the last resort, and the one way a string means SQL inside a block:
181
+ # a bare string is refused there, and ? and :name placeholders take quoted
182
+ # values. What it gives is parenthesized wherever it stands as an operand.
183
+ show("sql, for what neither fn nor op can spell",
184
+ LineItem.where { sql("price % ?", 100) == 0 },
185
+ LineItem.where { sql("price % ?", 100) == 0 }.pluck(:sku))
186
+
187
+ # A string sent `as` is a value, like a number.
188
+ show("a string literal in a select list",
189
+ LineItem.select { [:sku, "listed".as(:state)] },
190
+ LineItem.select { [:sku, "listed".as(:state)] }.map { |i| [i.sku, i.state] })
191
+
134
192
  # 4. Ordering. asc and desc take nulls_first / nulls_last. MySQL has no
135
193
  # such syntax, but Arel emulates it there, so the order is the same
136
194
  # everywhere.
137
- show('NULLS LAST',
195
+ show("NULLS LAST",
138
196
  LineItem.order { [:category.asc.nulls_last, :sku.asc] },
139
197
  LineItem.order { [:category.asc.nulls_last, :sku.asc] }.pluck(:category, :sku))
140
198
 
199
+ # collate names a collation -- how the database compares and orders strings --
200
+ # and gives back an expression, so it carries into a comparison or an order.
201
+ # The names are the database's own; nocase is SQLite's case-insensitive one.
202
+ show("a case-insensitive comparison under a collation",
203
+ LineItem.where { :category.collate(:nocase) == "TOOLS" },
204
+ LineItem.where { :category.collate(:nocase) == "TOOLS" }.pluck(:sku))
205
+
141
206
  # Aggregates and expressions can be ordered by, too.
142
- show('grouped, aggregated and ordered by the aggregate',
207
+ show("grouped, aggregated and ordered by the aggregate",
143
208
  LineItem.
144
209
  group { :category }.
145
210
  having { count(:*) > 1 }.
146
211
  order { sum(:price * :quantity).desc }.
147
- select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] },
212
+ select { [coalesce(:category, "unsorted").as(:category), sum(:price * :quantity).as(:total)] },
148
213
  LineItem.
149
214
  group { :category }.
150
215
  having { count(:*) > 1 }.
151
216
  order { sum(:price * :quantity).desc }.
152
- select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] }.
153
- map {|i| [i.category, i.total] })
217
+ select { [coalesce(:category, "unsorted").as(:category), sum(:price * :quantity).as(:total)] }.
218
+ map { |i| [i.category, i.total] })