active_windows 0.1.6 → 0.1.7
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 +54 -43
- data/lib/active_windows/active_record_extensions.rb +5 -5
- 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: 78306f0ad28382b0fd83cc679dcfd418fc72a5e0c0a26e1e812ab0cdf3a289e2
|
|
4
|
+
data.tar.gz: a155a298bc07f13ead19b25764df4256996b995918b2362f518512066cccb4f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c2bb59c2105382c024acc58192266edd835c08422bc6a13221d480f634320185395af8eb3a742ff92a53ca123ed4775d50b765da48e7d9256f1f5ba3f397044
|
|
7
|
+
data.tar.gz: d9821806ab2b43d4ce12955f7311d0582b1312f76dd126255465c088e3a1bd9417c1d4fb870fc6a986361229e984253ffd6d67e587c001234a157a38a82ba545
|
data/README.md
CHANGED
|
@@ -4,10 +4,10 @@ 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).order_by(:salary).as(:rank)
|
|
8
8
|
|
|
9
9
|
# Hash API
|
|
10
|
-
User.window(row_number: { partition: :department,
|
|
10
|
+
User.window(row_number: { partition: :department, order_by: :salary, as: :rank })
|
|
11
11
|
|
|
12
12
|
# Both produce:
|
|
13
13
|
# SELECT "users".*, ROW_NUMBER() OVER (PARTITION BY "users"."department" ORDER BY "users"."salary") AS rank
|
|
@@ -41,16 +41,16 @@ 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()`, `.order_by()`, and `.as()`:
|
|
45
45
|
|
|
46
46
|
```ruby
|
|
47
|
-
User.row_number.partition_by(:department).
|
|
47
|
+
User.row_number.partition_by(:department).order_by(:salary).as(:rank)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
All three chain methods are optional.
|
|
50
|
+
All three chain methods are optional. `.order_by()` sets the window's `ORDER BY`, while ActiveRecord's `.order()` controls the query-level `ORDER BY`. You can use both together:
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
|
-
User.row_number.
|
|
53
|
+
User.row_number.order_by(:salary).as(:rn).order(:name)
|
|
54
54
|
# Window: OVER (ORDER BY salary)
|
|
55
55
|
# Query: ORDER BY name
|
|
56
56
|
```
|
|
@@ -58,8 +58,8 @@ User.row_number.window_order(:salary).as(:rn).order(:name)
|
|
|
58
58
|
Order can be mixed freely:
|
|
59
59
|
|
|
60
60
|
```ruby
|
|
61
|
-
User.row_number.as(:rn).
|
|
62
|
-
User.dense_rank.
|
|
61
|
+
User.row_number.as(:rn).order_by(:created_at)
|
|
62
|
+
User.dense_rank.order_by(:score).as(:position)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
### Hash API
|
|
@@ -68,33 +68,44 @@ Pass one or more window function definitions as a hash:
|
|
|
68
68
|
|
|
69
69
|
```ruby
|
|
70
70
|
User.window(
|
|
71
|
-
row_number: { partition: :department,
|
|
71
|
+
row_number: { partition: :department, order_by: :salary, as: :rank }
|
|
72
72
|
)
|
|
73
73
|
```
|
|
74
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
|
-
|
|
77
75
|
Available options:
|
|
78
76
|
|
|
79
77
|
| Option | Type | Description |
|
|
80
78
|
|--------|------|-------------|
|
|
81
79
|
| `:partition` | `Symbol`, `Array` | Column(s) for `PARTITION BY` |
|
|
82
|
-
| `:
|
|
80
|
+
| `:order_by` | `Symbol`, `Array` | Column(s) for `ORDER BY` |
|
|
83
81
|
| `:as` | `Symbol` | Alias for the result column |
|
|
84
82
|
| `:frame` | `String` | Raw SQL frame clause (e.g. `"ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING"`) |
|
|
85
83
|
| `:value` | `Symbol`, `String`, `Array` | Expression(s) passed as function arguments |
|
|
86
84
|
|
|
87
85
|
### Association Names
|
|
88
86
|
|
|
89
|
-
You can use
|
|
87
|
+
You can use association names instead of raw column names. ActiveWindows automatically resolves them:
|
|
88
|
+
|
|
89
|
+
**`belongs_to`** — resolves to the foreign key on the current table:
|
|
90
90
|
|
|
91
91
|
```ruby
|
|
92
92
|
# These are equivalent:
|
|
93
|
-
Order.row_number.partition_by(:user).
|
|
94
|
-
Order.row_number.partition_by(:user_id).
|
|
93
|
+
Order.row_number.partition_by(:user).order_by(:amount).as(:rn)
|
|
94
|
+
Order.row_number.partition_by(:user_id).order_by(:amount).as(:rn)
|
|
95
95
|
|
|
96
96
|
# Works in the hash API too:
|
|
97
|
-
Order.window(row_number: { partition: :user,
|
|
97
|
+
Order.window(row_number: { partition: :user, order_by: :amount, as: :rn })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**`has_one`** — auto-joins the associated table and references its primary key:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# User has_one :profile
|
|
104
|
+
# Automatically joins profiles and partitions by profiles.id
|
|
105
|
+
User.row_number.partition_by(:profile).order_by(:salary).as(:rn)
|
|
106
|
+
|
|
107
|
+
# Equivalent to:
|
|
108
|
+
User.joins(:profile).row_number.partition_by("profiles.id").order_by(:salary).as(:rn)
|
|
98
109
|
```
|
|
99
110
|
|
|
100
111
|
### Chaining with ActiveRecord
|
|
@@ -105,14 +116,14 @@ Window functions integrate naturally with standard ActiveRecord methods:
|
|
|
105
116
|
User.where(active: true)
|
|
106
117
|
.row_number
|
|
107
118
|
.partition_by(:department)
|
|
108
|
-
.
|
|
119
|
+
.order_by(:salary)
|
|
109
120
|
.as(:rank)
|
|
110
121
|
|
|
111
122
|
User.select(:name, :salary)
|
|
112
|
-
.window(row_number: {
|
|
123
|
+
.window(row_number: { order_by: :salary, as: :rn })
|
|
113
124
|
|
|
114
125
|
User.where(department: "Engineering")
|
|
115
|
-
.window(rank: {
|
|
126
|
+
.window(rank: { order_by: :salary, as: :salary_rank })
|
|
116
127
|
.limit(10)
|
|
117
128
|
```
|
|
118
129
|
|
|
@@ -123,7 +134,7 @@ When no `.select()` is specified, `*` is automatically included so all model col
|
|
|
123
134
|
Window function values are accessible as attributes on the returned records:
|
|
124
135
|
|
|
125
136
|
```ruby
|
|
126
|
-
results = User.row_number.partition_by(:department).
|
|
137
|
+
results = User.row_number.partition_by(:department).order_by(:salary).as(:rank)
|
|
127
138
|
|
|
128
139
|
results.each do |user|
|
|
129
140
|
puts "#{user.name}: rank #{user.attributes['rank']}"
|
|
@@ -136,44 +147,44 @@ end
|
|
|
136
147
|
|
|
137
148
|
```ruby
|
|
138
149
|
# ROW_NUMBER() - sequential integer within partition
|
|
139
|
-
User.row_number.partition_by(:department).
|
|
150
|
+
User.row_number.partition_by(:department).order_by(:salary).as(:rn)
|
|
140
151
|
|
|
141
152
|
# RANK() - rank with gaps for ties
|
|
142
|
-
User.rank.partition_by(:department).
|
|
153
|
+
User.rank.partition_by(:department).order_by(:salary).as(:salary_rank)
|
|
143
154
|
|
|
144
155
|
# DENSE_RANK() - rank without gaps for ties
|
|
145
|
-
User.dense_rank.partition_by(:department).
|
|
156
|
+
User.dense_rank.partition_by(:department).order_by(:salary).as(:dense_salary_rank)
|
|
146
157
|
|
|
147
158
|
# PERCENT_RANK() - relative rank as a fraction (0 to 1)
|
|
148
|
-
User.percent_rank.
|
|
159
|
+
User.percent_rank.order_by(:salary).as(:percentile)
|
|
149
160
|
|
|
150
161
|
# CUME_DIST() - cumulative distribution (fraction of rows <= current row)
|
|
151
|
-
User.cume_dist.
|
|
162
|
+
User.cume_dist.order_by(:salary).as(:cumulative)
|
|
152
163
|
|
|
153
164
|
# NTILE(n) - divide rows into n roughly equal buckets
|
|
154
|
-
User.ntile(4).
|
|
165
|
+
User.ntile(4).order_by(:salary).as(:quartile)
|
|
155
166
|
```
|
|
156
167
|
|
|
157
168
|
### Value Functions
|
|
158
169
|
|
|
159
170
|
```ruby
|
|
160
171
|
# LAG(column, offset, default) - value from a preceding row
|
|
161
|
-
User.lag(:salary).
|
|
162
|
-
User.lag(:salary, 2).
|
|
163
|
-
User.lag(:salary, 1, 0).
|
|
172
|
+
User.lag(:salary).order_by(:hire_date).as(:prev_salary)
|
|
173
|
+
User.lag(:salary, 2).order_by(:hire_date).as(:two_back) # custom offset
|
|
174
|
+
User.lag(:salary, 1, 0).order_by(:hire_date).as(:prev_or_zero) # with default
|
|
164
175
|
|
|
165
176
|
# LEAD(column, offset, default) - value from a following row
|
|
166
|
-
User.lead(:salary).
|
|
167
|
-
User.lead(:salary, 2, 0).
|
|
177
|
+
User.lead(:salary).order_by(:hire_date).as(:next_salary)
|
|
178
|
+
User.lead(:salary, 2, 0).order_by(:hire_date).as(:two_ahead)
|
|
168
179
|
|
|
169
180
|
# FIRST_VALUE(column) - first value in the window frame
|
|
170
|
-
User.first_value(:name).partition_by(:department).
|
|
181
|
+
User.first_value(:name).partition_by(:department).order_by(:salary).as(:lowest_paid)
|
|
171
182
|
|
|
172
183
|
# LAST_VALUE(column) - last value in the window frame
|
|
173
|
-
User.last_value(:name).partition_by(:department).
|
|
184
|
+
User.last_value(:name).partition_by(:department).order_by(:salary).as(:highest_paid)
|
|
174
185
|
|
|
175
186
|
# NTH_VALUE(column, n) - nth value in the window frame
|
|
176
|
-
User.nth_value(:name, 2).partition_by(:department).
|
|
187
|
+
User.nth_value(:name, 2).partition_by(:department).order_by(:salary).as(:second_lowest)
|
|
177
188
|
```
|
|
178
189
|
|
|
179
190
|
### Aggregate Window Functions
|
|
@@ -206,7 +217,7 @@ Pass a raw SQL frame clause via the hash API:
|
|
|
206
217
|
User.window(sum: {
|
|
207
218
|
value: :salary,
|
|
208
219
|
partition: :department,
|
|
209
|
-
|
|
220
|
+
order_by: :hire_date,
|
|
210
221
|
frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
|
|
211
222
|
as: :running_total
|
|
212
223
|
})
|
|
@@ -219,7 +230,7 @@ User.window(sum: {
|
|
|
219
230
|
```ruby
|
|
220
231
|
User.rank
|
|
221
232
|
.partition_by(:department)
|
|
222
|
-
.
|
|
233
|
+
.order_by(:salary)
|
|
223
234
|
.as(:salary_rank)
|
|
224
235
|
```
|
|
225
236
|
|
|
@@ -228,7 +239,7 @@ User.rank
|
|
|
228
239
|
```ruby
|
|
229
240
|
User.window(sum: {
|
|
230
241
|
value: :salary,
|
|
231
|
-
|
|
242
|
+
order_by: :hire_date,
|
|
232
243
|
frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
|
|
233
244
|
as: :running_total
|
|
234
245
|
})
|
|
@@ -250,14 +261,14 @@ end
|
|
|
250
261
|
```ruby
|
|
251
262
|
User.lag(:name)
|
|
252
263
|
.partition_by(:department)
|
|
253
|
-
.
|
|
264
|
+
.order_by(:hire_date)
|
|
254
265
|
.as(:previous_hire)
|
|
255
266
|
```
|
|
256
267
|
|
|
257
268
|
### Divide employees into salary quartiles
|
|
258
269
|
|
|
259
270
|
```ruby
|
|
260
|
-
User.ntile(4).
|
|
271
|
+
User.ntile(4).order_by(:salary).as(:quartile)
|
|
261
272
|
```
|
|
262
273
|
|
|
263
274
|
### Rank users by total order amount (with joins)
|
|
@@ -266,7 +277,7 @@ User.ntile(4).window_order(:salary).as(:quartile)
|
|
|
266
277
|
User.joins(:orders)
|
|
267
278
|
.group(:id)
|
|
268
279
|
.select("users.*, SUM(orders.amount) AS total_spent")
|
|
269
|
-
.window(rank: {
|
|
280
|
+
.window(rank: { order_by: "total_spent", as: :spending_rank })
|
|
270
281
|
```
|
|
271
282
|
|
|
272
283
|
### Window function on joined data
|
|
@@ -276,7 +287,7 @@ User.joins(:orders)
|
|
|
276
287
|
Order.joins(:user)
|
|
277
288
|
.row_number
|
|
278
289
|
.partition_by("users.id")
|
|
279
|
-
.
|
|
290
|
+
.order_by(amount: :desc)
|
|
280
291
|
.as(:order_rank)
|
|
281
292
|
|
|
282
293
|
# Number each user's orders chronologically
|
|
@@ -284,7 +295,7 @@ Order.joins(:user)
|
|
|
284
295
|
.select("orders.*, users.name AS user_name")
|
|
285
296
|
.row_number
|
|
286
297
|
.partition_by(:user_id)
|
|
287
|
-
.
|
|
298
|
+
.order_by(:created_at)
|
|
288
299
|
.as(:order_number)
|
|
289
300
|
```
|
|
290
301
|
|
|
@@ -297,7 +308,7 @@ Order.joins(:user)
|
|
|
297
308
|
.window(sum: {
|
|
298
309
|
value: :amount,
|
|
299
310
|
partition: :user_id,
|
|
300
|
-
|
|
311
|
+
order_by: :created_at,
|
|
301
312
|
frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
|
|
302
313
|
as: :running_total
|
|
303
314
|
})
|
|
@@ -25,7 +25,7 @@ module ActiveWindows
|
|
|
25
25
|
self
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
28
|
+
def order_by(*columns)
|
|
29
29
|
@order_columns = columns.flatten
|
|
30
30
|
self
|
|
31
31
|
end
|
|
@@ -33,7 +33,7 @@ module ActiveWindows
|
|
|
33
33
|
def to_window_hash
|
|
34
34
|
options = {}
|
|
35
35
|
options[:partition] = @partition_columns unless @partition_columns.empty?
|
|
36
|
-
options[:
|
|
36
|
+
options[:order_by] = @order_columns unless @order_columns.empty?
|
|
37
37
|
options[:as] = @alias_name if @alias_name
|
|
38
38
|
options[:value] = @function_args unless @function_args.empty?
|
|
39
39
|
{ @function => options }
|
|
@@ -65,7 +65,7 @@ module ActiveWindows
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
module QueryMethods
|
|
68
|
-
VALID_WINDOW_OPTIONS = %i[value partition
|
|
68
|
+
VALID_WINDOW_OPTIONS = %i[value partition order_by frame as].freeze
|
|
69
69
|
|
|
70
70
|
# Non-mutating: returns a new relation with window function projections
|
|
71
71
|
def window(*args)
|
|
@@ -78,7 +78,7 @@ module ActiveWindows
|
|
|
78
78
|
# Auto-join has_one associations referenced in partition/order
|
|
79
79
|
joins_needed = processed.flat_map do |_name, options|
|
|
80
80
|
next [] unless options.is_a?(Hash)
|
|
81
|
-
association_joins_for(options[:partition]) + association_joins_for(options[:
|
|
81
|
+
association_joins_for(options[:partition]) + association_joins_for(options[:order_by])
|
|
82
82
|
end.uniq
|
|
83
83
|
result = result.joins(*joins_needed) if joins_needed.any?
|
|
84
84
|
|
|
@@ -166,7 +166,7 @@ module ActiveWindows
|
|
|
166
166
|
window = Arel::Nodes::Window.new
|
|
167
167
|
|
|
168
168
|
apply_window_partition(window, options[:partition])
|
|
169
|
-
apply_window_order(window, options[:
|
|
169
|
+
apply_window_order(window, options[:order_by])
|
|
170
170
|
apply_window_frame(window, options[:frame]) if options[:frame]
|
|
171
171
|
|
|
172
172
|
expressions = extract_window_value(options[:value])
|