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 +7 -0
- data/LICENSE +21 -0
- data/README.md +309 -0
- data/Rakefile +11 -0
- data/docs/REVIEW_AND_PLAN.md +47 -0
- data/lib/active_windows/active_record_extensions.rb +242 -0
- data/lib/active_windows/railtie.rb +14 -0
- data/lib/active_windows/version.rb +5 -0
- data/lib/active_windows.rb +15 -0
- metadata +149 -0
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,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,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: []
|