active_windows 0.1.8 → 0.1.10
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 +59 -2
- data/docs/REVIEW_AND_PLAN.md +3 -1
- data/lib/active_windows/active_record_extensions.rb +91 -7
- 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: 941df16a5d0a5717d98f3a22039e21755ba7c4ce3f1ead0fd39020e51e6483d7
|
|
4
|
+
data.tar.gz: 89ae6524909657f96930364026de99279b17faf03540dcaa102629849e449375
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51ca8df73a2d268462898de6ad0f105400bfb06ae79005c8084d41255427e14463618707967c6208ad0d596c17d61514481e71e0ef9fbdeb03a23dd1d70959da
|
|
7
|
+
data.tar.gz: 1de89bb61bedbeecba880ec7492a45fd0f257ebe4fa49e185b638de8b65c47879107493c6b427b5e38f63a12097f2cfbaaacbf2f2c4815008d9286aa5d62d171
|
data/README.md
CHANGED
|
@@ -80,6 +80,7 @@ Available options:
|
|
|
80
80
|
| `:as` | `Symbol` | Alias for the result column |
|
|
81
81
|
| `:frame` | `String` | Raw SQL frame clause (e.g. `"ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING"`) |
|
|
82
82
|
| `:value` | `Symbol`, `String`, `Array` | Expression(s) passed as function arguments |
|
|
83
|
+
| `:over` | `Symbol` | Reference to a named window defined via `define:` |
|
|
83
84
|
|
|
84
85
|
### Association Names
|
|
85
86
|
|
|
@@ -193,18 +194,74 @@ User.window(:min, :salary).partition_by(:department).as(:min_salary)
|
|
|
193
194
|
User.window(:max, :salary).partition_by(:department).as(:max_salary)
|
|
194
195
|
```
|
|
195
196
|
|
|
197
|
+
### Named Windows
|
|
198
|
+
|
|
199
|
+
Define a window once and reuse it across multiple functions with `define:` and `over:`:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
User.window(
|
|
203
|
+
define: { w: { partition_by: :department, order_by: :salary } },
|
|
204
|
+
row_number: { over: :w, as: :rn },
|
|
205
|
+
rank: { over: :w, as: :salary_rank },
|
|
206
|
+
sum: { value: :salary, over: :w, as: :running_total }
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
You can define multiple windows and extend them per-function:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
User.window(
|
|
214
|
+
define: {
|
|
215
|
+
by_dept: { partition_by: :department },
|
|
216
|
+
globally: { order_by: :salary }
|
|
217
|
+
},
|
|
218
|
+
row_number: { over: :by_dept, order_by: :salary, as: :dept_rn },
|
|
219
|
+
rank: { over: :globally, as: :global_rank }
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Options on the function (like `order_by:`) are merged with the named definition, so you can share the common parts and specialize per-function.
|
|
224
|
+
|
|
196
225
|
### Window Frames
|
|
197
226
|
|
|
198
|
-
|
|
227
|
+
Use a hash DSL to define frame clauses:
|
|
199
228
|
|
|
200
229
|
```ruby
|
|
230
|
+
# ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
201
231
|
User.window(sum: {
|
|
202
232
|
value: :salary,
|
|
203
233
|
partition_by: :department,
|
|
204
234
|
order_by: :hire_date,
|
|
205
|
-
frame:
|
|
235
|
+
frame: { rows: [:unbounded_preceding, :current_row] },
|
|
206
236
|
as: :running_total
|
|
207
237
|
})
|
|
238
|
+
|
|
239
|
+
# ROWS BETWEEN 3 PRECEDING AND 1 FOLLOWING
|
|
240
|
+
frame: { rows: [3, -1] }
|
|
241
|
+
|
|
242
|
+
# RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
|
243
|
+
frame: { range: [:unbounded_preceding, :unbounded_following] }
|
|
244
|
+
|
|
245
|
+
# Single bound: ROWS UNBOUNDED PRECEDING
|
|
246
|
+
frame: { rows: :unbounded_preceding }
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Available bounds: `:unbounded_preceding`, `:unbounded_following`, `:current_row`, or an integer (positive = PRECEDING, negative = FOLLOWING).
|
|
250
|
+
|
|
251
|
+
Fluent API:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
User.window(:sum, :salary)
|
|
255
|
+
.partition_by(:department)
|
|
256
|
+
.order_by(:hire_date)
|
|
257
|
+
.frame(rows: [:unbounded_preceding, :current_row])
|
|
258
|
+
.as(:running_total)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Raw SQL strings are also accepted:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW"
|
|
208
265
|
```
|
|
209
266
|
|
|
210
267
|
## Examples
|
data/docs/REVIEW_AND_PLAN.md
CHANGED
|
@@ -36,7 +36,9 @@ The gem provides a fluent DSL for SQL window functions in ActiveRecord. Core fun
|
|
|
36
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.
|
|
37
37
|
- ~~**WindowChain `order` naming collision**~~ — Fixed. Renamed to `order_by` to avoid conflict with ActiveRecord's `.order()`. Both fluent (`.order_by(:salary)`) and hash (`order_by: :salary`) APIs use `order_by`. WindowChain delegates `.order()` to the relation for query-level ordering. Uses `method_missing` for full relation method coverage.
|
|
38
38
|
- ~~**Association name resolution**~~ — Added. `belongs_to`: `partition_by(:user)` resolves to `user_id`. Works in both fluent and hash APIs.
|
|
39
|
-
- ~~**Unified `window()` entry point**~~ — Added. `window(:row_number)` returns a WindowChain (fluent), `window(:lag, :salary, 1, 0)` passes function args, `window(row_number: { ... })` is hash API. Single method, three modes.
|
|
39
|
+
- ~~**Unified `window()` entry point**~~ — Added. `window(:row_number)` returns a WindowChain (fluent), `window(:lag, :salary, 1, 0)` passes function args, `window(row_number: { ... })` is hash API. Single method, three modes.
|
|
40
|
+
- ~~**Named windows**~~ — Added. `define: { w: { partition_by: :department, order_by: :salary } }` with `over: :w` references. Multiple definitions supported. Function-level options merge with the definition.
|
|
41
|
+
- ~~**Frame clause is raw SQL**~~ — Fixed. Hash DSL for frames: `frame: { rows: [:unbounded_preceding, :current_row] }`. Supports `:rows`/`:range`, integer offsets, and all standard bounds. Raw SQL strings still accepted as fallback. 93 tests, 429 assertions.
|
|
40
42
|
|
|
41
43
|
---
|
|
42
44
|
|
|
@@ -13,6 +13,7 @@ module ActiveWindows
|
|
|
13
13
|
@alias_name = nil
|
|
14
14
|
@partition_columns = []
|
|
15
15
|
@order_columns = []
|
|
16
|
+
@frame = nil
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def as(name)
|
|
@@ -30,10 +31,16 @@ module ActiveWindows
|
|
|
30
31
|
self
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def frame(value)
|
|
35
|
+
@frame = value
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
33
39
|
def to_window_hash
|
|
34
40
|
options = {}
|
|
35
41
|
options[:partition_by] = @partition_columns unless @partition_columns.empty?
|
|
36
42
|
options[:order_by] = @order_columns unless @order_columns.empty?
|
|
43
|
+
options[:frame] = @frame if @frame
|
|
37
44
|
options[:as] = @alias_name if @alias_name
|
|
38
45
|
options[:value] = @function_args unless @function_args.empty?
|
|
39
46
|
{ @function => options }
|
|
@@ -65,7 +72,7 @@ module ActiveWindows
|
|
|
65
72
|
end
|
|
66
73
|
|
|
67
74
|
module QueryMethods
|
|
68
|
-
VALID_WINDOW_OPTIONS = %i[value partition_by order_by frame as].freeze
|
|
75
|
+
VALID_WINDOW_OPTIONS = %i[value partition_by order_by frame as over].freeze
|
|
69
76
|
|
|
70
77
|
# Fluent: window(:row_number) returns a WindowChain
|
|
71
78
|
# Fluent with args: window(:lag, :salary, 1, 0) returns a WindowChain
|
|
@@ -133,10 +140,66 @@ module ActiveWindows
|
|
|
133
140
|
window.order(*columns.flat_map { |o| arel_order(o) })
|
|
134
141
|
end
|
|
135
142
|
|
|
143
|
+
VALID_FRAME_TYPES = %i[rows range].freeze
|
|
144
|
+
VALID_FRAME_BOUNDS = %i[
|
|
145
|
+
unbounded_preceding unbounded_following current_row
|
|
146
|
+
].freeze
|
|
147
|
+
|
|
136
148
|
def apply_window_frame(window, frame)
|
|
137
|
-
|
|
149
|
+
case frame
|
|
150
|
+
when String
|
|
151
|
+
window.frame(Arel.sql(frame))
|
|
152
|
+
when Hash
|
|
153
|
+
window.frame(build_frame(frame))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_frame(frame_hash)
|
|
158
|
+
type, bounds = frame_hash.first
|
|
138
159
|
|
|
139
|
-
|
|
160
|
+
unless VALID_FRAME_TYPES.include?(type)
|
|
161
|
+
raise ArgumentError, "Invalid frame type: #{type}. Use :rows or :range"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
bounds = Array(bounds)
|
|
165
|
+
|
|
166
|
+
frame_class = type == :rows ? Arel::Nodes::Rows : Arel::Nodes::Range
|
|
167
|
+
|
|
168
|
+
if bounds.length == 1
|
|
169
|
+
# Single bound: ROWS <bound>
|
|
170
|
+
frame_class.new(build_frame_bound(bounds[0]))
|
|
171
|
+
else
|
|
172
|
+
# BETWEEN: ROWS BETWEEN <start> AND <end>
|
|
173
|
+
# Arel doesn't support BETWEEN frames natively, so we build SQL
|
|
174
|
+
start_sql = frame_bound_sql(bounds[0])
|
|
175
|
+
end_sql = frame_bound_sql(bounds[1])
|
|
176
|
+
type_sql = type.to_s.upcase
|
|
177
|
+
Arel.sql("#{type_sql} BETWEEN #{start_sql} AND #{end_sql}")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_frame_bound(bound)
|
|
182
|
+
case bound
|
|
183
|
+
when :current_row then Arel::Nodes::CurrentRow.new
|
|
184
|
+
when :unbounded_preceding then Arel::Nodes::Preceding.new
|
|
185
|
+
when :unbounded_following then Arel::Nodes::Following.new
|
|
186
|
+
when Integer
|
|
187
|
+
bound >= 0 ? Arel::Nodes::Preceding.new(bound) : Arel::Nodes::Following.new(bound.abs)
|
|
188
|
+
else
|
|
189
|
+
raise ArgumentError, "Invalid frame bound: #{bound}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def frame_bound_sql(bound)
|
|
194
|
+
case bound
|
|
195
|
+
when :current_row then "CURRENT ROW"
|
|
196
|
+
when :unbounded_preceding then "UNBOUNDED PRECEDING"
|
|
197
|
+
when :unbounded_following then "UNBOUNDED FOLLOWING"
|
|
198
|
+
when Integer
|
|
199
|
+
bound >= 0 ? "#{bound} PRECEDING" : "#{bound.abs} FOLLOWING"
|
|
200
|
+
else
|
|
201
|
+
raise ArgumentError, "Invalid frame bound: #{bound}"
|
|
202
|
+
end
|
|
140
203
|
end
|
|
141
204
|
|
|
142
205
|
def extract_window_value(value)
|
|
@@ -187,16 +250,37 @@ module ActiveWindows
|
|
|
187
250
|
end
|
|
188
251
|
|
|
189
252
|
def process_window_args(args)
|
|
253
|
+
# First pass: collect named window definitions from define: key
|
|
254
|
+
definitions = {}
|
|
255
|
+
args.each do |element|
|
|
256
|
+
next unless element.is_a?(Hash) && element.key?(:define)
|
|
257
|
+
|
|
258
|
+
element[:define].each do |name, opts|
|
|
259
|
+
definitions[name] = opts
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Second pass: build function list, resolving over: references
|
|
190
264
|
args.flat_map do |element|
|
|
191
265
|
case element
|
|
192
266
|
when Hash
|
|
193
|
-
element.
|
|
194
|
-
next unless v.is_a?(Hash)
|
|
267
|
+
element.except(:define).map do |k, v|
|
|
268
|
+
next [k, v] unless v.is_a?(Hash)
|
|
269
|
+
|
|
270
|
+
# Resolve over: reference to a named window definition
|
|
271
|
+
if v.key?(:over)
|
|
272
|
+
window_name = v[:over]
|
|
273
|
+
definition = definitions[window_name]
|
|
274
|
+
raise ArgumentError, "Unknown window definition: #{window_name}" unless definition
|
|
275
|
+
|
|
276
|
+
v = definition.merge(v.except(:over))
|
|
277
|
+
end
|
|
195
278
|
|
|
196
279
|
unsupported = v.keys - VALID_WINDOW_OPTIONS
|
|
197
280
|
raise ArgumentError, "Unsupported window options: #{unsupported.join(', ')}" unless unsupported.empty?
|
|
198
|
-
|
|
199
|
-
|
|
281
|
+
|
|
282
|
+
[k, v]
|
|
283
|
+
end.compact
|
|
200
284
|
when WindowChain
|
|
201
285
|
[element.to_window_hash.first]
|
|
202
286
|
else
|