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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3591dbeb9c495d128c8fa3fd9f88cdc4be7d6fabad282e48e47a9d3b9e919d7
4
- data.tar.gz: ee011c561b8e84a1efe6b6366664616286632ff8facfaa1ff3a1df37a093c434
3
+ metadata.gz: 941df16a5d0a5717d98f3a22039e21755ba7c4ce3f1ead0fd39020e51e6483d7
4
+ data.tar.gz: 89ae6524909657f96930364026de99279b17faf03540dcaa102629849e449375
5
5
  SHA512:
6
- metadata.gz: dc03d55bfc15cf28e76347b6a8f0f04d52a89c24c6b41feb458cff6c8c4694ed1270802710f9686eb68e90f30281f096ef8d0a0fc9c638c356f347aa894015bd
7
- data.tar.gz: 92e13ee084266b0fe1a8baf40a5275338d95a07888cca71f44546ef2b518700208c758c2302f1fcb2ff4d32608666652f993a89bc96cbb6e8f692bfaacb46a36
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
- Pass a raw SQL frame clause via the hash API:
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: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
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
@@ -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. 82 tests, 399 assertions.
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
- return unless frame.is_a?(String)
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
- window.frame(Arel.sql(frame))
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.each_value do |v|
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
- end
199
- element.map { |k, v| [k, v] }
281
+
282
+ [k, v]
283
+ end.compact
200
284
  when WindowChain
201
285
  [element.to_window_hash.first]
202
286
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveWindows
4
- VERSION = "0.1.8"
4
+ VERSION = "0.1.10"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_windows
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk