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.
- checksums.yaml +4 -4
- data/.yardopts +17 -0
- data/README.md +111 -815
- data/activerecord-refined.gemspec +36 -17
- data/docs/conditions.md +206 -0
- data/docs/ctes.md +65 -0
- data/docs/expressions.md +125 -0
- data/docs/functions.md +219 -0
- data/docs/grouping.md +55 -0
- data/docs/joins.md +73 -0
- data/docs/json.md +230 -0
- data/docs/ordering.md +55 -0
- data/docs/time_zones.md +30 -0
- data/docs/windows.md +41 -0
- data/docs/writing.md +33 -0
- data/examples/aggregations.rb +31 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +110 -45
- 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 +1117 -373
- data/lib/active_record/refined/dialect/mariadb.rb +25 -0
- data/lib/active_record/refined/dialect/mysql.rb +18 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
- data/lib/active_record/refined/dialect/oracle.rb +110 -0
- data/lib/active_record/refined/dialect/postgresql.rb +120 -0
- data/lib/active_record/refined/dialect/sql_server.rb +115 -0
- data/lib/active_record/refined/dialect/sqlite.rb +57 -0
- data/lib/active_record/refined/dialect.rb +340 -0
- data/lib/active_record/refined.rb +877 -307
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +9 -5
- metadata +186 -15
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -90
- data/.gitignore +0 -19
- data/Gemfile +0 -12
- data/Rakefile +0 -25
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2493
- 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.
|
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 }.
|
|
@@ -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
|
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]
|
|
@@ -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:
|
|
26
|
-
|
|
27
|
-
LineItem.create!(sku:
|
|
28
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
78
|
-
show(
|
|
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(
|
|
124
|
+
show("two aggregates over different rows of the same query",
|
|
87
125
|
LineItem.select {
|
|
88
|
-
[count(:*).as(:all), sum(:price).filter { :category ==
|
|
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 ==
|
|
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(
|
|
99
|
-
LineItem.select { [:sku, :category.when(
|
|
100
|
-
LineItem.select { [:sku, :category.when(
|
|
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(
|
|
141
|
+
show("a CASE where each when carries its own condition",
|
|
104
142
|
LineItem.select {
|
|
105
|
-
[:sku, case_when { :price >= 1000 }.then(
|
|
106
|
-
then(
|
|
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(
|
|
110
|
-
then(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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] })
|