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
data/examples/windows.rb
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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(:sales) {|t| t.string :region; t.string :day; t.integer :amount }
|
|
13
|
+
create_table(:sales) { |t| t.string :region; t.string :day; t.integer :amount }
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
Setup.new.up
|
|
@@ -16,11 +18,11 @@ Setup.new.up
|
|
|
16
18
|
class Sale < ActiveRecord::Base
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
Sale.create!(region:
|
|
20
|
-
Sale.create!(region:
|
|
21
|
-
Sale.create!(region:
|
|
22
|
-
Sale.create!(region:
|
|
23
|
-
Sale.create!(region:
|
|
21
|
+
Sale.create!(region: "east", day: "2026-01-01", amount: 100)
|
|
22
|
+
Sale.create!(region: "east", day: "2026-01-02", amount: 40)
|
|
23
|
+
Sale.create!(region: "east", day: "2026-01-03", amount: 60)
|
|
24
|
+
Sale.create!(region: "west", day: "2026-01-01", amount: 20)
|
|
25
|
+
Sale.create!(region: "west", day: "2026-01-02", amount: 90)
|
|
24
26
|
|
|
25
27
|
def show(title, relation, rows = nil)
|
|
26
28
|
puts "--- #{title} ---"
|
|
@@ -32,18 +34,18 @@ end
|
|
|
32
34
|
# 1. A window on an aggregate. over turns an aggregate into a window
|
|
33
35
|
# function: the rows stay, and the aggregate is worked out over the window
|
|
34
36
|
# beside each one. Without partition or order the window is every row.
|
|
35
|
-
show(
|
|
37
|
+
show("the total beside every row",
|
|
36
38
|
Sale.select { [:region, :amount, sum(:amount).over.as(:total)] },
|
|
37
39
|
Sale.select { [:region, :amount, sum(:amount).over.as(:total)] }.
|
|
38
|
-
map {|s| [s.region, s.amount, s.total] })
|
|
40
|
+
map { |s| [s.region, s.amount, s.total] })
|
|
39
41
|
|
|
40
42
|
# partition divides the rows into groups the window is worked out within.
|
|
41
43
|
totals = -> {
|
|
42
44
|
Sale.select { [:region, :amount, sum(:amount).over.partition(:region).as(:region_total)] }
|
|
43
45
|
}
|
|
44
|
-
show(
|
|
46
|
+
show("a total per region, still row by row",
|
|
45
47
|
totals.call,
|
|
46
|
-
totals.call.map {|s| [s.region, s.amount, s.region_total] })
|
|
48
|
+
totals.call.map { |s| [s.region, s.amount, s.region_total] })
|
|
47
49
|
|
|
48
50
|
# 2. Frames. With an order, the window can be cut down to a range of rows
|
|
49
51
|
# around the current one. A Range of integers is what says which: 0 is the
|
|
@@ -55,9 +57,9 @@ running = -> {
|
|
|
55
57
|
sum(:amount).over.partition(:region).order(:day).rows(..0).as(:running)]
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
show(
|
|
60
|
+
show("a running total within each region",
|
|
59
61
|
running.call,
|
|
60
|
-
running.call.map {|s| [s.region, s.day, s.amount, s.running] })
|
|
62
|
+
running.call.map { |s| [s.region, s.day, s.amount, s.running] })
|
|
61
63
|
|
|
62
64
|
# 3. The functions that say nothing without a window. row_number, rank and
|
|
63
65
|
# dense_rank number the rows of each partition; lag and lead reach the row
|
|
@@ -70,14 +72,14 @@ ranked = -> {
|
|
|
70
72
|
lag(:amount).over.partition(:region).order(:day).as(:previous)]
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
show(
|
|
75
|
+
show("the place within the region, and the day before",
|
|
74
76
|
ranked.call,
|
|
75
|
-
ranked.call.map {|s| [s.region, s.amount, s.place, s.previous] })
|
|
77
|
+
ranked.call.map { |s| [s.region, s.amount, s.place, s.previous] })
|
|
76
78
|
|
|
77
79
|
begin
|
|
78
80
|
Sale.select { row_number }
|
|
79
81
|
rescue ArgumentError => e
|
|
80
|
-
puts
|
|
82
|
+
puts "--- a window function needs a window ---"
|
|
81
83
|
puts " #{e.message}"
|
|
82
84
|
puts
|
|
83
85
|
end
|
|
@@ -90,7 +92,7 @@ numbered = Sale.select {
|
|
|
90
92
|
}
|
|
91
93
|
# The subquery is named after the model's own table because Active Record goes
|
|
92
94
|
# on qualifying columns with that name, so where has to find it there.
|
|
93
|
-
show(
|
|
95
|
+
show("the biggest sale of each region",
|
|
94
96
|
Sale.from(numbered, :sales).where { :place == 1 },
|
|
95
97
|
Sale.from(numbered, :sales).where { :place == 1 }.
|
|
96
|
-
map {|s| [s.region, s.day, s.amount] })
|
|
98
|
+
map { |s| [s.region, s.day, s.amount] })
|
data/examples/writes.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,22 +24,22 @@ Setup.new.up
|
|
|
22
24
|
class Page < ActiveRecord::Base
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
Page.create!(path:
|
|
26
|
-
Page.create!(path:
|
|
27
|
-
Page.create!(path:
|
|
27
|
+
Page.create!(path: "/index", hits: 10, bonus: 1, title: "home")
|
|
28
|
+
Page.create!(path: "/about", hits: 3, bonus: 0, title: "about us")
|
|
29
|
+
Page.create!(path: "/faq", hits: 0, bonus: 5, title: "faq")
|
|
28
30
|
|
|
29
31
|
# These statements run rather than being built, so unlike the other examples
|
|
30
32
|
# the SQL is taken from the notification Active Record sends for each one.
|
|
31
33
|
def write(title)
|
|
32
34
|
statements = []
|
|
33
|
-
subscriber = ActiveSupport::Notifications.subscribe(
|
|
35
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
34
36
|
statements << payload[:sql] unless %w[SCHEMA TRANSACTION].include?(payload[:name])
|
|
35
37
|
end
|
|
36
38
|
yield
|
|
37
39
|
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
38
40
|
puts "--- #{title} ---"
|
|
39
|
-
statements.each {|sql| puts sql }
|
|
40
|
-
Page.order(:path).each {|p| puts " #{p.path} hits=#{p.hits} title=#{p.title}" }
|
|
41
|
+
statements.each { |sql| puts sql }
|
|
42
|
+
Page.order(:path).each { |p| puts " #{p.path} hits=#{p.hits} title=#{p.title}" }
|
|
41
43
|
puts
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -45,17 +47,17 @@ end
|
|
|
45
47
|
# :hits) would set the column to the symbol itself -- and the block reads a
|
|
46
48
|
# symbol as the column it names, which is what lets the new value be worked
|
|
47
49
|
# out from the old.
|
|
48
|
-
write(
|
|
49
|
-
Page.where { :hits > 0 }.update_all { {hits: :hits + :bonus} }
|
|
50
|
+
write("hits made from the old hits") do
|
|
51
|
+
Page.where { :hits > 0 }.update_all { { hits: :hits + :bonus } }
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
write(
|
|
53
|
-
Page.update_all { {title: upper(:title)} }
|
|
54
|
+
write("a function over the column") do
|
|
55
|
+
Page.update_all { { title: upper(:title) } }
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
# An expression as involved as any other block builds.
|
|
57
|
-
write(
|
|
58
|
-
Page.update_all { {hits: case_when { :hits > 10 }.then(10).else(:hits)} }
|
|
59
|
+
write("CASE in an update") do
|
|
60
|
+
Page.update_all { { hits: case_when { :hits > 10 }.then(10).else(:hits) } }
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# 2. upsert_all. The block is the part that decides what happens to a row
|
|
@@ -63,22 +65,22 @@ end
|
|
|
63
65
|
# inserted. PostgreSQL and SQLite name it that; MySQL says VALUES(column),
|
|
64
66
|
# and the block comes out as whichever the adapter reads.
|
|
65
67
|
incoming = [
|
|
66
|
-
{path:
|
|
67
|
-
{path:
|
|
68
|
+
{ path: "/index", hits: 100, bonus: 0, title: "HOME" },
|
|
69
|
+
{ path: "/new", hits: 7, bonus: 0, title: "NEW" },
|
|
68
70
|
]
|
|
69
|
-
write(
|
|
70
|
-
Page.upsert_all(incoming, unique_by: :path) { {hits: :hits + excluded(:hits)} }
|
|
71
|
+
write("the old value and the new one added together") do
|
|
72
|
+
Page.upsert_all(incoming, unique_by: :path) { { hits: :hits + excluded(:hits) } }
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
# Without a block, upsert_all overwrites -- that is Active Record's own
|
|
74
76
|
# behaviour, and the block is what makes the old value reachable.
|
|
75
|
-
write(
|
|
76
|
-
Page.upsert_all([{path:
|
|
77
|
+
write("and without a block it is a plain overwrite") do
|
|
78
|
+
Page.upsert_all([{ path: "/index", hits: 1, bonus: 0, title: "HOME" }],
|
|
77
79
|
unique_by: :path)
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
# insert_all has no block: Active Record type-casts each value into the VALUES
|
|
81
83
|
# list, so an expression there would become nothing rather than SQL.
|
|
82
|
-
write(
|
|
83
|
-
Page.insert_all([{path:
|
|
84
|
+
write("insert_all takes literals") do
|
|
85
|
+
Page.insert_all([{ path: "/legal", hits: 0, bonus: 0, title: "legal" }])
|
|
84
86
|
end
|