active_windows 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c506ec36f11ce9b82beab86a0f574479965825dfd347eca6f46274111db50f08
4
+ data.tar.gz: bd81dd06b95ed05826d6b56daa294ec6396d31e0f800e42a3c68783fed72ef59
5
+ SHA512:
6
+ metadata.gz: 7dfc6256cdaf8c2770f0ab59763de4a81552fe7a283b3852fe9bcb6d6d12f427709e5bf9e8ae999bee456761647ba7100ed191b2e5da2e3a808511d12935168e
7
+ data.tar.gz: b19cc5ad7f5bb62752ec2c64060230cff28582317cee5e9c5a95b065f9c31ce613c8f15a07ef0294786664dfe553db971cd79ca731e413d611ee34057c4f6f97
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Andrei Andriichuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # ActiveWindows
2
+
3
+ A Ruby DSL for SQL window functions in ActiveRecord. Write expressive window function queries using a fluent, chainable interface instead of raw SQL.
4
+
5
+ ```ruby
6
+ # Fluent API
7
+ User.row_number.partition_by(:department).order(:salary).as(:rank)
8
+
9
+ # Hash API
10
+ User.window(row_number: { partition: :department, order: :salary, as: :rank })
11
+
12
+ # Both produce:
13
+ # SELECT "users".*, ROW_NUMBER() OVER (PARTITION BY "users"."department" ORDER BY "users"."salary") AS rank
14
+ # FROM "users"
15
+ ```
16
+
17
+ ## Requirements
18
+
19
+ - Ruby >= 3.3
20
+ - Rails >= 8.0 (ActiveRecord >= 8.0)
21
+
22
+ ## Installation
23
+
24
+ Add to your Gemfile:
25
+
26
+ ```ruby
27
+ gem "active_windows"
28
+ ```
29
+
30
+ Then run:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
35
+
36
+ ActiveWindows automatically integrates with ActiveRecord via a Rails Railtie. No additional configuration is needed.
37
+
38
+ ## Usage
39
+
40
+ ActiveWindows provides two equivalent APIs: a **fluent API** with chainable methods and a **hash API** for inline definitions. Both support the same window functions and options.
41
+
42
+ ### Fluent API
43
+
44
+ Every window function method returns a chainable object with `.partition_by()`, `.order()`, and `.as()`:
45
+
46
+ ```ruby
47
+ User.row_number.partition_by(:department).order(:salary).as(:rank)
48
+ ```
49
+
50
+ All three chain methods are optional. Order can be mixed freely:
51
+
52
+ ```ruby
53
+ User.row_number.as(:rn).order(:created_at)
54
+ User.dense_rank.order(:score).as(:position)
55
+ ```
56
+
57
+ ### Hash API
58
+
59
+ Pass one or more window function definitions as a hash:
60
+
61
+ ```ruby
62
+ User.window(
63
+ row_number: { partition: :department, order: :salary, as: :rank }
64
+ )
65
+ ```
66
+
67
+ Available options:
68
+
69
+ | Option | Type | Description |
70
+ |--------|------|-------------|
71
+ | `:partition` | `Symbol`, `Array` | Column(s) for `PARTITION BY` |
72
+ | `:order` | `Symbol`, `Array` | Column(s) for `ORDER BY` |
73
+ | `:as` | `Symbol` | Alias for the result column |
74
+ | `:frame` | `String` | Raw SQL frame clause (e.g. `"ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING"`) |
75
+ | `:value` | `Symbol`, `String`, `Array` | Expression(s) passed as function arguments |
76
+
77
+ ### Chaining with ActiveRecord
78
+
79
+ Window functions integrate naturally with standard ActiveRecord methods:
80
+
81
+ ```ruby
82
+ User.where(active: true)
83
+ .row_number
84
+ .partition_by(:department)
85
+ .order(:salary)
86
+ .as(:rank)
87
+
88
+ User.select(:name, :salary)
89
+ .window(row_number: { order: :salary, as: :rn })
90
+
91
+ User.where(department: "Engineering")
92
+ .window(rank: { order: :salary, as: :salary_rank })
93
+ .limit(10)
94
+ ```
95
+
96
+ When no `.select()` is specified, `*` is automatically included so all model columns are available alongside the window function result.
97
+
98
+ ### Accessing Window Function Results
99
+
100
+ Window function values are accessible as attributes on the returned records:
101
+
102
+ ```ruby
103
+ results = User.row_number.partition_by(:department).order(:salary).as(:rank)
104
+
105
+ results.each do |user|
106
+ puts "#{user.name}: rank #{user.attributes['rank']}"
107
+ end
108
+ ```
109
+
110
+ ## Window Functions Reference
111
+
112
+ ### Ranking Functions
113
+
114
+ ```ruby
115
+ # ROW_NUMBER() - sequential integer within partition
116
+ User.row_number.partition_by(:department).order(:salary).as(:rn)
117
+
118
+ # RANK() - rank with gaps for ties
119
+ User.rank.partition_by(:department).order(:salary).as(:salary_rank)
120
+
121
+ # DENSE_RANK() - rank without gaps for ties
122
+ User.dense_rank.partition_by(:department).order(:salary).as(:dense_salary_rank)
123
+
124
+ # PERCENT_RANK() - relative rank as a fraction (0 to 1)
125
+ User.percent_rank.order(:salary).as(:percentile)
126
+
127
+ # CUME_DIST() - cumulative distribution (fraction of rows <= current row)
128
+ User.cume_dist.order(:salary).as(:cumulative)
129
+
130
+ # NTILE(n) - divide rows into n roughly equal buckets
131
+ User.ntile(4).order(:salary).as(:quartile)
132
+ ```
133
+
134
+ ### Value Functions
135
+
136
+ ```ruby
137
+ # LAG(column, offset, default) - value from a preceding row
138
+ User.lag(:salary).order(:hire_date).as(:prev_salary)
139
+ User.lag(:salary, 2).order(:hire_date).as(:two_back) # custom offset
140
+ User.lag(:salary, 1, 0).order(:hire_date).as(:prev_or_zero) # with default
141
+
142
+ # LEAD(column, offset, default) - value from a following row
143
+ User.lead(:salary).order(:hire_date).as(:next_salary)
144
+ User.lead(:salary, 2, 0).order(:hire_date).as(:two_ahead)
145
+
146
+ # FIRST_VALUE(column) - first value in the window frame
147
+ User.first_value(:name).partition_by(:department).order(:salary).as(:lowest_paid)
148
+
149
+ # LAST_VALUE(column) - last value in the window frame
150
+ User.last_value(:name).partition_by(:department).order(:salary).as(:highest_paid)
151
+
152
+ # NTH_VALUE(column, n) - nth value in the window frame
153
+ User.nth_value(:name, 2).partition_by(:department).order(:salary).as(:second_lowest)
154
+ ```
155
+
156
+ ### Aggregate Window Functions
157
+
158
+ Prefixed with `window_` to avoid conflicts with ActiveRecord's built-in aggregate methods:
159
+
160
+ ```ruby
161
+ # SUM(column) OVER(...)
162
+ User.window_sum(:salary).partition_by(:department).as(:dept_total)
163
+
164
+ # AVG(column) OVER(...)
165
+ User.window_avg(:salary).partition_by(:department).as(:dept_avg)
166
+
167
+ # COUNT(column) OVER(...)
168
+ User.window_count(:id).partition_by(:department).as(:dept_size)
169
+ User.window_count.partition_by(:department).as(:dept_size) # COUNT(*)
170
+
171
+ # MIN(column) OVER(...)
172
+ User.window_min(:salary).partition_by(:department).as(:min_salary)
173
+
174
+ # MAX(column) OVER(...)
175
+ User.window_max(:salary).partition_by(:department).as(:max_salary)
176
+ ```
177
+
178
+ ### Window Frames
179
+
180
+ Pass a raw SQL frame clause via the hash API:
181
+
182
+ ```ruby
183
+ User.window(sum: {
184
+ value: :salary,
185
+ partition: :department,
186
+ order: :hire_date,
187
+ frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
188
+ as: :running_total
189
+ })
190
+ ```
191
+
192
+ ## Examples
193
+
194
+ ### Rank employees by salary within each department
195
+
196
+ ```ruby
197
+ User.rank
198
+ .partition_by(:department)
199
+ .order(:salary)
200
+ .as(:salary_rank)
201
+ ```
202
+
203
+ ### Running total of salaries ordered by hire date
204
+
205
+ ```ruby
206
+ User.window(sum: {
207
+ value: :salary,
208
+ order: :hire_date,
209
+ frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
210
+ as: :running_total
211
+ })
212
+ ```
213
+
214
+ ### Compare each salary to the department average
215
+
216
+ ```ruby
217
+ users = User.window_avg(:salary).partition_by(:department).as(:dept_avg)
218
+
219
+ users.each do |user|
220
+ diff = user.salary - user.attributes["dept_avg"].to_f
221
+ puts "#{user.name}: #{diff >= 0 ? '+' : ''}#{diff.round(0)} vs department average"
222
+ end
223
+ ```
224
+
225
+ ### Find the previous and next hire in each department
226
+
227
+ ```ruby
228
+ User.lag(:name)
229
+ .partition_by(:department)
230
+ .order(:hire_date)
231
+ .as(:previous_hire)
232
+ ```
233
+
234
+ ### Divide employees into salary quartiles
235
+
236
+ ```ruby
237
+ User.ntile(4).order(:salary).as(:quartile)
238
+ ```
239
+
240
+ ### Rank users by total order amount (with joins)
241
+
242
+ ```ruby
243
+ User.joins(:orders)
244
+ .group(:id)
245
+ .select("users.*, SUM(orders.amount) AS total_spent")
246
+ .window(rank: { order: "total_spent", as: :spending_rank })
247
+ ```
248
+
249
+ ### Window function on joined data
250
+
251
+ ```ruby
252
+ # Rank orders by amount within each user
253
+ Order.joins(:user)
254
+ .row_number
255
+ .partition_by("users.id")
256
+ .order(amount: :desc)
257
+ .as(:order_rank)
258
+
259
+ # Number each user's orders chronologically
260
+ Order.joins(:user)
261
+ .select("orders.*, users.name AS user_name")
262
+ .row_number
263
+ .partition_by(:user_id)
264
+ .order(:created_at)
265
+ .as(:order_number)
266
+ ```
267
+
268
+ ### Combine window functions with scoped queries
269
+
270
+ ```ruby
271
+ # Running total of order amounts per user
272
+ Order.joins(:user)
273
+ .where(users: { department: "Engineering" })
274
+ .window(sum: {
275
+ value: :amount,
276
+ partition: :user_id,
277
+ order: :created_at,
278
+ frame: "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
279
+ as: :running_total
280
+ })
281
+ ```
282
+
283
+ ## Development
284
+
285
+ ```bash
286
+ bundle install
287
+ bundle exec rake test
288
+ ```
289
+
290
+ Run tests against a specific database:
291
+
292
+ ```bash
293
+ # SQLite (default)
294
+ bundle exec rake test
295
+
296
+ # PostgreSQL
297
+ DB_ADAPTER=postgresql POSTGRES_DB=active_windows_test bundle exec rake test
298
+
299
+ # MySQL
300
+ DB_ADAPTER=mysql2 MYSQL_DB=active_windows_test bundle exec rake test
301
+ ```
302
+
303
+ ## Contributing
304
+
305
+ Bug reports and pull requests are welcome on GitHub at https://github.com/andreiandriichuk/active_windows.
306
+
307
+ ## License
308
+
309
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/*_test.rb"
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,47 @@
1
+ # ActiveWindows — Review & Improvement Plan
2
+
3
+ ## Overall Assessment
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. 53 tests passing with 242 assertions.
6
+
7
+ ---
8
+
9
+ ## Completed
10
+
11
+ - ~~**WindowChain disconnected from window()**~~ — Fixed. WindowChain holds a relation reference and delegates query methods through `to_relation`.
12
+ - ~~**Relation state not preserved through cloning**~~ — Fixed. Uses ActiveRecord's `select()` infrastructure instead of custom `@window_values`, so state survives `spawn`/`clone`/chaining.
13
+ - ~~**Window frame hardcoded**~~ — Fixed. `apply_window_frame` now passes user-provided string to Arel.
14
+ - ~~**SQL injection via Arel.sql()**~~ — Mitigated. Column references now use `klass.arel_table[column]` instead of raw `Arel.sql()`.
15
+ - ~~**Broken test suite**~~ — Fixed. Placeholder test removed, 53 real tests passing.
16
+ - ~~**Bundler constraint**~~ — Fixed. Changed `~> 2.0` to `>= 2.0`.
17
+ - ~~**Standalone arel gem conflict**~~ — Fixed. Removed incompatible `arel >= 9.0` dependency (ActiveRecord 8 bundles its own).
18
+ - ~~**sqlite3 version**~~ — Fixed. Updated to `>= 2.1` for ActiveRecord 8 compatibility.
19
+ - ~~**Only row_number supported**~~ — Fixed. Added 15 more window functions: `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`, `lead`, `first_value`, `last_value`, `nth_value`, `window_sum`, `window_avg`, `window_count`, `window_min`, `window_max`.
20
+ - ~~**Empty partition/order arrays**~~ — Fixed. Guarded with early return.
21
+ - ~~**No real tests**~~ — Fixed. 53 Minitest tests with SQL verification and query execution against SQLite.
22
+ - ~~**Gemspec TODO placeholders**~~ — Fixed. Summary, description, homepage, source/changelog URIs all filled in.
23
+ - ~~**README boilerplate**~~ — Fixed. Full documentation with usage examples, API reference, and generated SQL.
24
+ - ~~**CI only Ruby 3.3.5**~~ — Fixed. Matrix now tests Ruby 3.3, 3.4, and 4.0.
25
+ - ~~**RSpec dependency**~~ — Removed. Tests rewritten to Minitest.
26
+ - ~~**RuboCop dependency**~~ — Removed.
27
+ - ~~**LICENSE**~~ — Cleaned up. Full name, no year, `.txt` extension removed.
28
+ - ~~**Boilerplate files**~~ — Removed. `CHANGELOG.md`, `CODE_OF_CONDUCT.md`, `bin/console`, `bin/setup`, `sig/active_windows.rbs`.
29
+ - ~~**Ordering direction**~~ — Fixed. Supports `order(salary: :desc)`, `order({ col: :asc }, { col: :desc })`, and hash API `order: { salary: :desc }`.
30
+ - ~~**Gemfile/gemspec duplication**~~ — Fixed. Dev gems (`minitest`, `rake`) defined only in Gemfile under `test, development` group. No more bundler override warnings.
31
+ - ~~**Cross-platform lockfile**~~ — Fixed. Added `aarch64-linux`, `arm-linux`, `arm64-darwin`, `x86_64-darwin`, `x86_64-linux` platforms.
32
+ - ~~**Multiple window functions in one call**~~ — Tested. Single `window()` with multiple keys and chaining separate `window()` calls both work.
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
+
35
+ ---
36
+
37
+ ## Remaining Work
38
+
39
+ ### Low Priority
40
+
41
+ 1. **RBS type signatures** — Add method signatures for `QueryMethods` and `WindowChain`.
42
+
43
+ 2. **GitHub Actions for gem publishing** — No automated release workflow.
44
+
45
+ 3. **PostgreSQL test coverage** — SQLite has limited window function support. Testing against PostgreSQL would catch more issues.
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).
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveWindows
6
+ class WindowChain
7
+ attr_reader :function, :alias_name, :partition_columns, :order_columns, :function_args
8
+
9
+ def initialize(function, relation, function_args: [])
10
+ @function = function
11
+ @relation = relation
12
+ @function_args = function_args
13
+ @alias_name = nil
14
+ @partition_columns = []
15
+ @order_columns = []
16
+ end
17
+
18
+ def as(name)
19
+ @alias_name = name
20
+ self
21
+ end
22
+
23
+ def partition_by(*columns)
24
+ @partition_columns = columns.flatten
25
+ self
26
+ end
27
+
28
+ def order(*columns)
29
+ @order_columns = columns.flatten
30
+ self
31
+ end
32
+
33
+ def to_window_hash
34
+ options = {}
35
+ options[:partition] = @partition_columns unless @partition_columns.empty?
36
+ options[:order] = @order_columns unless @order_columns.empty?
37
+ options[:as] = @alias_name if @alias_name
38
+ options[:value] = @function_args unless @function_args.empty?
39
+ { @function => options }
40
+ end
41
+
42
+ # Materialize the chain into a relation with the window applied
43
+ def to_relation
44
+ @relation.window(to_window_hash)
45
+ end
46
+
47
+ # Delegate common relation/query methods so the chain is transparent
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,
50
+ :find_each, :find_in_batches, :inspect, :exists?, :any?, :none?, :empty?,
51
+ to: :to_relation
52
+ end
53
+
54
+ module QueryMethods
55
+ VALID_WINDOW_OPTIONS = %i[value partition order frame as].freeze
56
+
57
+ # Non-mutating: returns a new relation with window function projections
58
+ def window(*args)
59
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if args.empty?
60
+
61
+ processed = process_window_args(args)
62
+ arel_nodes = processed.map { |name, options| build_window_function(name, options || {}) }
63
+
64
+ result = spawn
65
+ # Ensure we keep all columns alongside the window function columns
66
+ result = result.select(klass.arel_table[Arel.star]) if result.select_values.empty?
67
+ result.select(*arel_nodes)
68
+ end
69
+
70
+ # Ranking window functions
71
+ def row_number
72
+ WindowChain.new(:row_number, spawn)
73
+ end
74
+
75
+ def rank
76
+ WindowChain.new(:rank, spawn)
77
+ end
78
+
79
+ def dense_rank
80
+ WindowChain.new(:dense_rank, spawn)
81
+ end
82
+
83
+ def percent_rank
84
+ WindowChain.new(:percent_rank, spawn)
85
+ end
86
+
87
+ def cume_dist
88
+ WindowChain.new(:cume_dist, spawn)
89
+ end
90
+
91
+ def ntile(num_buckets)
92
+ WindowChain.new(:ntile, spawn, function_args: [num_buckets.to_s])
93
+ end
94
+
95
+ # Value window functions
96
+ def lag(column, offset = 1, default = nil)
97
+ args = [column.to_s, offset.to_s]
98
+ args << default.to_s unless default.nil?
99
+ WindowChain.new(:lag, spawn, function_args: args)
100
+ end
101
+
102
+ def lead(column, offset = 1, default = nil)
103
+ args = [column.to_s, offset.to_s]
104
+ args << default.to_s unless default.nil?
105
+ WindowChain.new(:lead, spawn, function_args: args)
106
+ end
107
+
108
+ def first_value(column)
109
+ WindowChain.new(:first_value, spawn, function_args: [column.to_s])
110
+ end
111
+
112
+ def last_value(column)
113
+ WindowChain.new(:last_value, spawn, function_args: [column.to_s])
114
+ end
115
+
116
+ def nth_value(column, n)
117
+ WindowChain.new(:nth_value, spawn, function_args: [column.to_s, n.to_s])
118
+ end
119
+
120
+ # Aggregate window functions
121
+ def window_sum(column)
122
+ WindowChain.new(:sum, spawn, function_args: [column.to_s])
123
+ end
124
+
125
+ def window_avg(column)
126
+ WindowChain.new(:avg, spawn, function_args: [column.to_s])
127
+ end
128
+
129
+ def window_count(column = "*")
130
+ WindowChain.new(:count, spawn, function_args: [column.to_s])
131
+ end
132
+
133
+ def window_min(column)
134
+ WindowChain.new(:min, spawn, function_args: [column.to_s])
135
+ end
136
+
137
+ def window_max(column)
138
+ WindowChain.new(:max, spawn, function_args: [column.to_s])
139
+ end
140
+
141
+ private
142
+
143
+ def build_window_function(name, options)
144
+ window = Arel::Nodes::Window.new
145
+
146
+ apply_window_partition(window, options[:partition])
147
+ apply_window_order(window, options[:order])
148
+ apply_window_frame(window, options[:frame]) if options[:frame]
149
+
150
+ expressions = extract_window_value(options[:value])
151
+
152
+ Arel::Nodes::NamedFunction.new(
153
+ name.to_s.upcase,
154
+ expressions
155
+ ).over(window).as((options[:as] || name).to_s)
156
+ end
157
+
158
+ def apply_window_partition(window, partition)
159
+ return unless partition
160
+
161
+ columns = Array(partition)
162
+ return if columns.empty?
163
+
164
+ window.partition(columns.map { |p| arel_column(p) })
165
+ end
166
+
167
+ def apply_window_order(window, order)
168
+ return unless order
169
+
170
+ # When order is a Hash like { salary: :desc }, pass it directly to arel_order
171
+ if order.is_a?(Hash)
172
+ window.order(*arel_order(order))
173
+ return
174
+ end
175
+
176
+ columns = Array(order)
177
+ return if columns.empty?
178
+
179
+ window.order(*columns.flat_map { |o| arel_order(o) })
180
+ end
181
+
182
+ def apply_window_frame(window, frame)
183
+ return unless frame.is_a?(String)
184
+
185
+ window.frame(Arel.sql(frame))
186
+ end
187
+
188
+ def extract_window_value(value)
189
+ case value
190
+ when Symbol, String
191
+ [Arel::Nodes::SqlLiteral.new(value.to_s)]
192
+ when nil
193
+ []
194
+ when Array
195
+ value.map { |v| Arel::Nodes::SqlLiteral.new(v.to_s) }
196
+ else
197
+ raise ArgumentError, "Invalid argument for window value: #{value.class}"
198
+ end
199
+ end
200
+
201
+ def arel_column(name)
202
+ if name.is_a?(Arel::Nodes::Node) || name.is_a?(Arel::Nodes::SqlLiteral)
203
+ name
204
+ else
205
+ klass.arel_table[name.to_sym]
206
+ end
207
+ end
208
+
209
+ def arel_order(expr)
210
+ case expr
211
+ when Arel::Nodes::Node, Arel::Nodes::SqlLiteral
212
+ [expr]
213
+ when Hash
214
+ expr.map do |col, dir|
215
+ node = arel_column(col)
216
+ dir.to_s.downcase == "desc" ? node.desc : node.asc
217
+ end
218
+ else
219
+ [arel_column(expr)]
220
+ end
221
+ end
222
+
223
+ def process_window_args(args)
224
+ args.flat_map do |element|
225
+ case element
226
+ when Hash
227
+ element.each_value do |v|
228
+ next unless v.is_a?(Hash)
229
+
230
+ unsupported = v.keys - VALID_WINDOW_OPTIONS
231
+ raise ArgumentError, "Unsupported window options: #{unsupported.join(', ')}" unless unsupported.empty?
232
+ end
233
+ element.map { |k, v| [k, v] }
234
+ when WindowChain
235
+ [element.to_window_hash.first]
236
+ else
237
+ raise ArgumentError, "Expected Hash or WindowChain, got #{element.class}"
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActiveWindows
6
+ class Railtie < Rails::Railtie
7
+ initializer "active_windows.active_record" do
8
+ ActiveSupport.on_load :active_record do
9
+ ActiveRecord::Relation.include(ActiveWindows::QueryMethods)
10
+ ActiveRecord::Querying.delegate(*ActiveWindows::QUERY_METHODS, to: :all)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWindows
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_windows/version"
4
+ require "active_windows/active_record_extensions"
5
+ require "active_windows/railtie" if defined?(Rails::Railtie)
6
+
7
+ module ActiveWindows
8
+ class Error < StandardError; end
9
+
10
+ QUERY_METHODS = %i[
11
+ window row_number rank dense_rank percent_rank cume_dist ntile
12
+ lag lead first_value last_value nth_value
13
+ window_sum window_avg window_count window_min window_max
14
+ ].freeze
15
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_windows
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrei Andriichuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bundler
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '8.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '8.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: sqlite3
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '2.1'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '2.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pg
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.5'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1.5'
96
+ - !ruby/object:Gem::Dependency
97
+ name: mysql2
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0.5'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0.5'
110
+ description: Expressive, chainable DSL for SQL window functions (ROW_NUMBER, RANK,
111
+ LAG, LEAD, SUM, etc.) that integrates naturally with ActiveRecord query methods.
112
+ email:
113
+ - andreiandriichuk@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - LICENSE
119
+ - README.md
120
+ - Rakefile
121
+ - docs/REVIEW_AND_PLAN.md
122
+ - lib/active_windows.rb
123
+ - lib/active_windows/active_record_extensions.rb
124
+ - lib/active_windows/railtie.rb
125
+ - lib/active_windows/version.rb
126
+ homepage: https://github.com/andreiandriichuk/active_windows
127
+ licenses:
128
+ - MIT
129
+ metadata:
130
+ source_code_uri: https://github.com/andreiandriichuk/active_windows
131
+ rubygems_mfa_required: 'true'
132
+ rdoc_options: []
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: 3.3.0
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubygems_version: 4.0.3
147
+ specification_version: 4
148
+ summary: A Ruby DSL for SQL window functions in ActiveRecord
149
+ test_files: []