active_windows 0.1.1 → 0.1.3
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/README.md +37 -27
- data/docs/REVIEW_AND_PLAN.md +5 -4
- data/lib/active_windows/active_record_extensions.rb +5 -3
- data/lib/active_windows/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40d81b47d0bf35406b6d9004f3eb82b0137b3bfc0dc5bed7e2d33b697753aeae
|
|
4
|
+
data.tar.gz: 11897b5353a3c8b993922c7af8a4819964d417640bce9501182b145750051144
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b858116cd6290e77e23c5688336102efd83a234bb2caca193728e9b9458a91591fe0653420f5cc400238603921f86e472d3d92ab4028342e4ad9eb2396b4d2e
|
|
7
|
+
data.tar.gz: 3974f6c3a73e2efbbb6aeb603c2ab4ddfd65a88eb23d10353fe94ef50746f38fc4c5c7abceec862f2eb53e8b26f710d3a7b9e274f3889132d83c48874d984e3d
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A Ruby DSL for SQL window functions in ActiveRecord. Write expressive window fun
|
|
|
4
4
|
|
|
5
5
|
```ruby
|
|
6
6
|
# Fluent API
|
|
7
|
-
User.row_number.partition_by(:department).
|
|
7
|
+
User.row_number.partition_by(:department).window_order(:salary).as(:rank)
|
|
8
8
|
|
|
9
9
|
# Hash API
|
|
10
10
|
User.window(row_number: { partition: :department, order: :salary, as: :rank })
|
|
@@ -41,17 +41,25 @@ ActiveWindows provides two equivalent APIs: a **fluent API** with chainable meth
|
|
|
41
41
|
|
|
42
42
|
### Fluent API
|
|
43
43
|
|
|
44
|
-
Every window function method returns a chainable object with `.partition_by()`, `.
|
|
44
|
+
Every window function method returns a chainable object with `.partition_by()`, `.window_order()`, and `.as()`:
|
|
45
45
|
|
|
46
46
|
```ruby
|
|
47
|
-
User.row_number.partition_by(:department).
|
|
47
|
+
User.row_number.partition_by(:department).window_order(:salary).as(:rank)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
All three chain methods are optional.
|
|
50
|
+
All three chain methods are optional. The fluent API uses `.window_order()` (not `.order()`) to avoid collision with ActiveRecord's `.order()`, which controls the query-level `ORDER BY`. This lets you use both together:
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
|
-
User.row_number.as(:rn).order(:
|
|
54
|
-
|
|
53
|
+
User.row_number.window_order(:salary).as(:rn).order(:name)
|
|
54
|
+
# Window: OVER (ORDER BY salary)
|
|
55
|
+
# Query: ORDER BY name
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Order can be mixed freely:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
User.row_number.as(:rn).window_order(:created_at)
|
|
62
|
+
User.dense_rank.window_order(:score).as(:position)
|
|
55
63
|
```
|
|
56
64
|
|
|
57
65
|
### Hash API
|
|
@@ -64,6 +72,8 @@ User.window(
|
|
|
64
72
|
)
|
|
65
73
|
```
|
|
66
74
|
|
|
75
|
+
The hash API uses `order:` as a key (not a method call), so there's no naming conflict with ActiveRecord's `.order()`.
|
|
76
|
+
|
|
67
77
|
Available options:
|
|
68
78
|
|
|
69
79
|
| Option | Type | Description |
|
|
@@ -82,7 +92,7 @@ Window functions integrate naturally with standard ActiveRecord methods:
|
|
|
82
92
|
User.where(active: true)
|
|
83
93
|
.row_number
|
|
84
94
|
.partition_by(:department)
|
|
85
|
-
.
|
|
95
|
+
.window_order(:salary)
|
|
86
96
|
.as(:rank)
|
|
87
97
|
|
|
88
98
|
User.select(:name, :salary)
|
|
@@ -100,7 +110,7 @@ When no `.select()` is specified, `*` is automatically included so all model col
|
|
|
100
110
|
Window function values are accessible as attributes on the returned records:
|
|
101
111
|
|
|
102
112
|
```ruby
|
|
103
|
-
results = User.row_number.partition_by(:department).
|
|
113
|
+
results = User.row_number.partition_by(:department).window_order(:salary).as(:rank)
|
|
104
114
|
|
|
105
115
|
results.each do |user|
|
|
106
116
|
puts "#{user.name}: rank #{user.attributes['rank']}"
|
|
@@ -113,44 +123,44 @@ end
|
|
|
113
123
|
|
|
114
124
|
```ruby
|
|
115
125
|
# ROW_NUMBER() - sequential integer within partition
|
|
116
|
-
User.row_number.partition_by(:department).
|
|
126
|
+
User.row_number.partition_by(:department).window_order(:salary).as(:rn)
|
|
117
127
|
|
|
118
128
|
# RANK() - rank with gaps for ties
|
|
119
|
-
User.rank.partition_by(:department).
|
|
129
|
+
User.rank.partition_by(:department).window_order(:salary).as(:salary_rank)
|
|
120
130
|
|
|
121
131
|
# DENSE_RANK() - rank without gaps for ties
|
|
122
|
-
User.dense_rank.partition_by(:department).
|
|
132
|
+
User.dense_rank.partition_by(:department).window_order(:salary).as(:dense_salary_rank)
|
|
123
133
|
|
|
124
134
|
# PERCENT_RANK() - relative rank as a fraction (0 to 1)
|
|
125
|
-
User.percent_rank.
|
|
135
|
+
User.percent_rank.window_order(:salary).as(:percentile)
|
|
126
136
|
|
|
127
137
|
# CUME_DIST() - cumulative distribution (fraction of rows <= current row)
|
|
128
|
-
User.cume_dist.
|
|
138
|
+
User.cume_dist.window_order(:salary).as(:cumulative)
|
|
129
139
|
|
|
130
140
|
# NTILE(n) - divide rows into n roughly equal buckets
|
|
131
|
-
User.ntile(4).
|
|
141
|
+
User.ntile(4).window_order(:salary).as(:quartile)
|
|
132
142
|
```
|
|
133
143
|
|
|
134
144
|
### Value Functions
|
|
135
145
|
|
|
136
146
|
```ruby
|
|
137
147
|
# LAG(column, offset, default) - value from a preceding row
|
|
138
|
-
User.lag(:salary).
|
|
139
|
-
User.lag(:salary, 2).
|
|
140
|
-
User.lag(:salary, 1, 0).
|
|
148
|
+
User.lag(:salary).window_order(:hire_date).as(:prev_salary)
|
|
149
|
+
User.lag(:salary, 2).window_order(:hire_date).as(:two_back) # custom offset
|
|
150
|
+
User.lag(:salary, 1, 0).window_order(:hire_date).as(:prev_or_zero) # with default
|
|
141
151
|
|
|
142
152
|
# LEAD(column, offset, default) - value from a following row
|
|
143
|
-
User.lead(:salary).
|
|
144
|
-
User.lead(:salary, 2, 0).
|
|
153
|
+
User.lead(:salary).window_order(:hire_date).as(:next_salary)
|
|
154
|
+
User.lead(:salary, 2, 0).window_order(:hire_date).as(:two_ahead)
|
|
145
155
|
|
|
146
156
|
# FIRST_VALUE(column) - first value in the window frame
|
|
147
|
-
User.first_value(:name).partition_by(:department).
|
|
157
|
+
User.first_value(:name).partition_by(:department).window_order(:salary).as(:lowest_paid)
|
|
148
158
|
|
|
149
159
|
# LAST_VALUE(column) - last value in the window frame
|
|
150
|
-
User.last_value(:name).partition_by(:department).
|
|
160
|
+
User.last_value(:name).partition_by(:department).window_order(:salary).as(:highest_paid)
|
|
151
161
|
|
|
152
162
|
# NTH_VALUE(column, n) - nth value in the window frame
|
|
153
|
-
User.nth_value(:name, 2).partition_by(:department).
|
|
163
|
+
User.nth_value(:name, 2).partition_by(:department).window_order(:salary).as(:second_lowest)
|
|
154
164
|
```
|
|
155
165
|
|
|
156
166
|
### Aggregate Window Functions
|
|
@@ -196,7 +206,7 @@ User.window(sum: {
|
|
|
196
206
|
```ruby
|
|
197
207
|
User.rank
|
|
198
208
|
.partition_by(:department)
|
|
199
|
-
.
|
|
209
|
+
.window_order(:salary)
|
|
200
210
|
.as(:salary_rank)
|
|
201
211
|
```
|
|
202
212
|
|
|
@@ -227,14 +237,14 @@ end
|
|
|
227
237
|
```ruby
|
|
228
238
|
User.lag(:name)
|
|
229
239
|
.partition_by(:department)
|
|
230
|
-
.
|
|
240
|
+
.window_order(:hire_date)
|
|
231
241
|
.as(:previous_hire)
|
|
232
242
|
```
|
|
233
243
|
|
|
234
244
|
### Divide employees into salary quartiles
|
|
235
245
|
|
|
236
246
|
```ruby
|
|
237
|
-
User.ntile(4).
|
|
247
|
+
User.ntile(4).window_order(:salary).as(:quartile)
|
|
238
248
|
```
|
|
239
249
|
|
|
240
250
|
### Rank users by total order amount (with joins)
|
|
@@ -253,7 +263,7 @@ User.joins(:orders)
|
|
|
253
263
|
Order.joins(:user)
|
|
254
264
|
.row_number
|
|
255
265
|
.partition_by("users.id")
|
|
256
|
-
.
|
|
266
|
+
.window_order(amount: :desc)
|
|
257
267
|
.as(:order_rank)
|
|
258
268
|
|
|
259
269
|
# Number each user's orders chronologically
|
|
@@ -261,7 +271,7 @@ Order.joins(:user)
|
|
|
261
271
|
.select("orders.*, users.name AS user_name")
|
|
262
272
|
.row_number
|
|
263
273
|
.partition_by(:user_id)
|
|
264
|
-
.
|
|
274
|
+
.window_order(:created_at)
|
|
265
275
|
.as(:order_number)
|
|
266
276
|
```
|
|
267
277
|
|
data/docs/REVIEW_AND_PLAN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overall Assessment
|
|
4
4
|
|
|
5
|
-
The gem provides a fluent DSL for SQL window functions in ActiveRecord. Core functionality is implemented and tested, with 16 window functions available via both fluent and hash APIs.
|
|
5
|
+
The gem provides a fluent DSL for SQL window functions in ActiveRecord. Core functionality is implemented and tested, with 16 window functions available via both fluent and hash APIs. 67 tests passing with 328 assertions. CI runs against SQLite, PostgreSQL, and MySQL.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -31,6 +31,9 @@ The gem provides a fluent DSL for SQL window functions in ActiveRecord. Core fun
|
|
|
31
31
|
- ~~**Cross-platform lockfile**~~ — Fixed. Added `aarch64-linux`, `arm-linux`, `arm64-darwin`, `x86_64-darwin`, `x86_64-linux` platforms.
|
|
32
32
|
- ~~**Multiple window functions in one call**~~ — Tested. Single `window()` with multiple keys and chaining separate `window()` calls both work.
|
|
33
33
|
- ~~**Edge case tests**~~ — Added. Empty result sets, single-row partitions, NULL values in partition/order/value columns, chaining with `.joins()`, `.includes()`, `.where()` + `.joins()`. 67 tests, 328 assertions.
|
|
34
|
+
- ~~**PostgreSQL CI**~~ — Added. GitHub Actions workflow tests against PostgreSQL 17 with service container.
|
|
35
|
+
- ~~**MySQL CI**~~ — Added. GitHub Actions workflow tests against MySQL 8.0 with service container.
|
|
36
|
+
- ~~**MySQL compatibility**~~ — Fixed. Aliases now use `klass.connection.quote_column_name` to properly quote reserved words (e.g., `rank`) with backticks on MySQL and double quotes on PostgreSQL/SQLite. Test assertions use adapter-agnostic `q()` and `col()` helpers.
|
|
34
37
|
|
|
35
38
|
---
|
|
36
39
|
|
|
@@ -42,6 +45,4 @@ The gem provides a fluent DSL for SQL window functions in ActiveRecord. Core fun
|
|
|
42
45
|
|
|
43
46
|
2. **GitHub Actions for gem publishing** — No automated release workflow.
|
|
44
47
|
|
|
45
|
-
3. **
|
|
46
|
-
|
|
47
|
-
4. **Input validation on function arguments** — `lag`, `lead`, `ntile`, `nth_value` accept arbitrary values without type checking (e.g., `ntile("abc")` won't raise until query execution).
|
|
48
|
+
3. **Input validation on function arguments** — `lag`, `lead`, `ntile`, `nth_value` accept arbitrary values without type checking (e.g., `ntile("abc")` won't raise until query execution).
|
|
@@ -25,7 +25,7 @@ module ActiveWindows
|
|
|
25
25
|
self
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
28
|
+
def window_order(*columns)
|
|
29
29
|
@order_columns = columns.flatten
|
|
30
30
|
self
|
|
31
31
|
end
|
|
@@ -46,7 +46,7 @@ module ActiveWindows
|
|
|
46
46
|
|
|
47
47
|
# Delegate common relation/query methods so the chain is transparent
|
|
48
48
|
delegate :to_sql, :to_a, :to_ary, :load, :loaded?, :each, :map, :first, :last, :count,
|
|
49
|
-
:where, :select, :joins, :group, :having, :limit, :offset, :reorder, :pluck,
|
|
49
|
+
:where, :select, :joins, :group, :having, :order, :limit, :offset, :reorder, :pluck,
|
|
50
50
|
:find_each, :find_in_batches, :inspect, :exists?, :any?, :none?, :empty?,
|
|
51
51
|
to: :to_relation
|
|
52
52
|
end
|
|
@@ -149,10 +149,12 @@ module ActiveWindows
|
|
|
149
149
|
|
|
150
150
|
expressions = extract_window_value(options[:value])
|
|
151
151
|
|
|
152
|
+
alias_name = klass.connection.quote_column_name((options[:as] || name).to_s)
|
|
153
|
+
|
|
152
154
|
Arel::Nodes::NamedFunction.new(
|
|
153
155
|
name.to_s.upcase,
|
|
154
156
|
expressions
|
|
155
|
-
).over(window).as(
|
|
157
|
+
).over(window).as(alias_name)
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
def apply_window_partition(window, partition)
|