batchagg 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/.rspec +1 -0
- data/.rubocop.yml +64 -0
- data/CODE_OF_CONDUCT.md +131 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +114 -0
- data/Rakefile +8 -0
- data/lib/batchagg/version.rb +5 -0
- data/lib/batchagg.rb +736 -0
- data/sig/batchagg.rbs +6 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2b6918b95a0a1d2b8989a596c2f1bcdc5652bf82e8f20fa39ff247ed3d61f52e
|
|
4
|
+
data.tar.gz: 58df5d2bac16759d9803855dcfaab1c41c952887b0a00a4058bf5ce83af4b6be
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 49a3bea438ae260b9331381bf566726133cfa3b8e2c9a081d5bd3e6afaa62bee9a7893bc0722d18b1c4b7f00cc0590a7c1fdd5c8d0f7fc1ec7c98c8ae480aee8
|
|
7
|
+
data.tar.gz: b6fecaf5afde6c779b7cc20d37ed73b0e0ff03900fa01e52fff14b3317a53c88913596ad7f5f876cfbba3686bb52cd1f42b3b9cfb42c166f5a8e99f824c0a078
|
data/.rspec
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--require spec_helper
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-rake
|
|
3
|
+
- rubocop-rspec
|
|
4
|
+
|
|
5
|
+
AllCops:
|
|
6
|
+
TargetRubyVersion: 3.3
|
|
7
|
+
NewCops: enable
|
|
8
|
+
|
|
9
|
+
Style/StringLiterals:
|
|
10
|
+
EnforcedStyle: double_quotes
|
|
11
|
+
|
|
12
|
+
Style/StringLiteralsInInterpolation:
|
|
13
|
+
EnforcedStyle: double_quotes
|
|
14
|
+
|
|
15
|
+
Metrics/ParameterLists:
|
|
16
|
+
Enabled: false
|
|
17
|
+
|
|
18
|
+
Metrics/AbcSize:
|
|
19
|
+
Enabled: false
|
|
20
|
+
|
|
21
|
+
Metrics/MethodLength:
|
|
22
|
+
Enabled: false
|
|
23
|
+
|
|
24
|
+
Metrics/BlockLength:
|
|
25
|
+
Enabled: false
|
|
26
|
+
|
|
27
|
+
Metrics/PerceivedComplexity:
|
|
28
|
+
Enabled: false
|
|
29
|
+
|
|
30
|
+
Metrics/CyclomaticComplexity:
|
|
31
|
+
Enabled: false
|
|
32
|
+
|
|
33
|
+
Metrics/ClassLength:
|
|
34
|
+
Enabled: false
|
|
35
|
+
|
|
36
|
+
Metrics/BlockNesting:
|
|
37
|
+
Enabled: false
|
|
38
|
+
|
|
39
|
+
Layout/LineLength:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Style/Documentation:
|
|
43
|
+
Enabled: false
|
|
44
|
+
|
|
45
|
+
RSpec/InstanceVariable:
|
|
46
|
+
Enabled: false
|
|
47
|
+
|
|
48
|
+
RSpec/MultipleExpectations:
|
|
49
|
+
Enabled: false
|
|
50
|
+
|
|
51
|
+
RSpec/ExampleLength:
|
|
52
|
+
Enabled: false
|
|
53
|
+
|
|
54
|
+
RSpec/IndexedLet:
|
|
55
|
+
Enabled: false
|
|
56
|
+
|
|
57
|
+
RSpec/DescribeClass:
|
|
58
|
+
Enabled: false
|
|
59
|
+
|
|
60
|
+
RSpec/ContextWording:
|
|
61
|
+
Enabled: false
|
|
62
|
+
|
|
63
|
+
RSpec/SpecFilePathFormat:
|
|
64
|
+
Enabled: false
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
- Demonstrating empathy and kindness toward other people
|
|
21
|
+
- Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
- Giving and gracefully accepting constructive feedback
|
|
23
|
+
- Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
- Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
- The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
- Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
- Public or private harassment
|
|
34
|
+
- Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
- Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at [INSERT CONTACT
|
|
63
|
+
METHOD]. All complaints will be reviewed and investigated promptly and fairly.
|
|
64
|
+
|
|
65
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
66
|
+
reporter of any incident.
|
|
67
|
+
|
|
68
|
+
## Enforcement Guidelines
|
|
69
|
+
|
|
70
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
71
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
72
|
+
|
|
73
|
+
### 1. Correction
|
|
74
|
+
|
|
75
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
76
|
+
unprofessional or unwelcome in the community.
|
|
77
|
+
|
|
78
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
79
|
+
clarity around the nature of the violation and an explanation of why the
|
|
80
|
+
behavior was inappropriate. A public apology may be requested.
|
|
81
|
+
|
|
82
|
+
### 2. Warning
|
|
83
|
+
|
|
84
|
+
**Community Impact**: A violation through a single incident or series of
|
|
85
|
+
actions.
|
|
86
|
+
|
|
87
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
88
|
+
interaction with the people involved, including unsolicited interaction with
|
|
89
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
90
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
91
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
92
|
+
ban.
|
|
93
|
+
|
|
94
|
+
### 3. Temporary Ban
|
|
95
|
+
|
|
96
|
+
**Community Impact**: A serious violation of community standards, including
|
|
97
|
+
sustained inappropriate behavior.
|
|
98
|
+
|
|
99
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
100
|
+
communication with the community for a specified period of time. No public or
|
|
101
|
+
private interaction with the people involved, including unsolicited interaction
|
|
102
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
103
|
+
Violating these terms may lead to a permanent ban.
|
|
104
|
+
|
|
105
|
+
### 4. Permanent Ban
|
|
106
|
+
|
|
107
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
108
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
109
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
110
|
+
|
|
111
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
112
|
+
community.
|
|
113
|
+
|
|
114
|
+
## Attribution
|
|
115
|
+
|
|
116
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
117
|
+
version 2.1, available at
|
|
118
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
119
|
+
|
|
120
|
+
Community Impact Guidelines were inspired by
|
|
121
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
122
|
+
|
|
123
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
124
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
125
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
126
|
+
|
|
127
|
+
[homepage]: https://www.contributor-covenant.org
|
|
128
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
129
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
130
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
131
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 JT A.
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 JT Archie
|
|
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,114 @@
|
|
|
1
|
+
# BatchAgg
|
|
2
|
+
|
|
3
|
+
BatchAgg is a Ruby gem for efficiently performing multiple database aggregations
|
|
4
|
+
on ActiveRecord models in a single query. It helps eliminate N+1 query problems
|
|
5
|
+
when calculating counts, sums, averages, and other aggregates across
|
|
6
|
+
associations.
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
BatchAgg addresses the common issue of needing multiple aggregation values for a
|
|
11
|
+
collection of records without making repeated database queries. It uses
|
|
12
|
+
correlated subqueries to fetch all aggregations in a single efficient database
|
|
13
|
+
call, improving application performance.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem 'batchagg'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Example 1: Aggregations for a single user
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
include BatchAgg::DSL
|
|
27
|
+
|
|
28
|
+
# Define the aggregations you need
|
|
29
|
+
user_stats = aggregate(User) do
|
|
30
|
+
count(:total_posts, &:posts)
|
|
31
|
+
count(:published_posts) { |user| user.posts.where(status: 'published') }
|
|
32
|
+
sum(:total_views, :views, &:posts)
|
|
33
|
+
avg(:avg_rating, :rating, &:posts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get aggregations for a specific user
|
|
37
|
+
user = User.find(1)
|
|
38
|
+
stats = user_stats.only(user)
|
|
39
|
+
|
|
40
|
+
puts "Total posts: #{stats[user.id].total_posts}"
|
|
41
|
+
puts "Published posts: #{stats[user.id].published_posts}"
|
|
42
|
+
puts "Total views: #{stats[user.id].total_views}"
|
|
43
|
+
puts "Average rating: #{stats[user.id].avg_rating}"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Example 2: Aggregations for multiple users
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
include BatchAgg::DSL
|
|
50
|
+
|
|
51
|
+
# Define the aggregations you need
|
|
52
|
+
user_stats = aggregate(User) do
|
|
53
|
+
count(:total_posts, &:posts)
|
|
54
|
+
count(:comments_received) { |user| user.posts.joins(:comments) }
|
|
55
|
+
string_agg(:post_titles, :title, delimiter: ', ', &:posts)
|
|
56
|
+
max(:highest_rating, :rating, &:posts)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get aggregations for all active users
|
|
60
|
+
active_users = User.where(active: true)
|
|
61
|
+
stats = user_stats.from(active_users)
|
|
62
|
+
|
|
63
|
+
# Use the aggregated data
|
|
64
|
+
active_users.each do |user|
|
|
65
|
+
user_stat = stats[user.id]
|
|
66
|
+
puts "User #{user.name} has #{user_stat.total_posts} posts"
|
|
67
|
+
puts "Most recent post titles: #{user_stat.post_titles}"
|
|
68
|
+
puts "Highest rating: #{user_stat.highest_rating}"
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported Aggregation Types
|
|
73
|
+
|
|
74
|
+
- `count`: Count of records
|
|
75
|
+
- `count_distinct`: Count of distinct values for a column
|
|
76
|
+
- `sum`: Sum of a column
|
|
77
|
+
- `avg`: Average of a column
|
|
78
|
+
- `min`: Minimum value of a column
|
|
79
|
+
- `max`: Maximum value of a column
|
|
80
|
+
- `string_agg`: Concatenation of values (GROUP_CONCAT)
|
|
81
|
+
|
|
82
|
+
You can also use the `_expression` variants of these methods for custom SQL
|
|
83
|
+
expressions.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can
|
|
88
|
+
also run `bin/console` for an interactive prompt that will allow you to
|
|
89
|
+
experiment.
|
|
90
|
+
|
|
91
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
92
|
+
release a new version, update the version number in `version.rb`, and then run
|
|
93
|
+
`bundle exec rake release`, which will create a git tag for the version, push
|
|
94
|
+
git commits and the created tag, and push the `.gem` file to
|
|
95
|
+
[rubygems.org](https://rubygems.org).
|
|
96
|
+
|
|
97
|
+
## Contributing
|
|
98
|
+
|
|
99
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
100
|
+
https://github.com/[USERNAME]/batchagg. This project is intended to be a safe,
|
|
101
|
+
welcoming space for collaboration, and contributors are expected to adhere to
|
|
102
|
+
the
|
|
103
|
+
[code of conduct](https://github.com/[USERNAME]/batchagg/blob/main/CODE_OF_CONDUCT.md).
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
The gem is available as open source under the terms of the
|
|
108
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
109
|
+
|
|
110
|
+
## Code of Conduct
|
|
111
|
+
|
|
112
|
+
Everyone interacting in the Batchagg project's codebases, issue trackers, chat
|
|
113
|
+
rooms and mailing lists is expected to follow the
|
|
114
|
+
[code of conduct](https://github.com/[USERNAME]/batchagg/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
data/lib/batchagg.rb
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module BatchAgg
|
|
6
|
+
# Represents a single aggregate function definition
|
|
7
|
+
class AggregateDefinition
|
|
8
|
+
attr_reader :name, :type, :block, :column, :expression, :options
|
|
9
|
+
|
|
10
|
+
def initialize(name, type, block, column = nil, expression = nil, options = nil)
|
|
11
|
+
@name = name
|
|
12
|
+
@type = type
|
|
13
|
+
@block = block
|
|
14
|
+
@column = column
|
|
15
|
+
@expression = expression
|
|
16
|
+
@options = options
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def column_based?
|
|
20
|
+
type == :column
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def computed?
|
|
24
|
+
type == :computed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def block?
|
|
28
|
+
!block.nil?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Handles association traversal for correlated subqueries
|
|
33
|
+
class AssociationResolver
|
|
34
|
+
def initialize(base_model_class, outer_table_alias)
|
|
35
|
+
@base_model_class = base_model_class
|
|
36
|
+
@outer_table_alias = outer_table_alias
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_association(association_name)
|
|
40
|
+
reflection = find_association_reflection(association_name)
|
|
41
|
+
target_class = reflection.klass
|
|
42
|
+
correlation_condition = build_correlation_condition(reflection)
|
|
43
|
+
|
|
44
|
+
target_class.where(correlation_condition)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def association?(association_name)
|
|
48
|
+
@base_model_class.reflect_on_association(association_name).present?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def find_association_reflection(association_name)
|
|
54
|
+
reflection = @base_model_class.reflect_on_association(association_name)
|
|
55
|
+
raise NoMethodError, "Association '#{association_name}' not found" unless reflection
|
|
56
|
+
|
|
57
|
+
reflection
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_correlation_condition(reflection)
|
|
61
|
+
if reflection.through_reflection
|
|
62
|
+
build_has_many_through_condition(reflection)
|
|
63
|
+
elsif belongs_to_association?(reflection)
|
|
64
|
+
build_belongs_to_condition(reflection)
|
|
65
|
+
else # has_many or has_one (direct)
|
|
66
|
+
build_has_many_condition(reflection)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def belongs_to_association?(reflection)
|
|
71
|
+
reflection.macro == :belongs_to
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_belongs_to_condition(reflection)
|
|
75
|
+
target_table = reflection.klass.arel_table
|
|
76
|
+
foreign_key_column = @outer_table_alias[reflection.foreign_key]
|
|
77
|
+
primary_key_column = target_table[reflection.association_primary_key]
|
|
78
|
+
|
|
79
|
+
primary_key_column.eq(foreign_key_column)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_has_many_condition(reflection)
|
|
83
|
+
target_table = reflection.klass.arel_table
|
|
84
|
+
foreign_key_column = target_table[reflection.foreign_key]
|
|
85
|
+
primary_key_column = @outer_table_alias[reflection.active_record_primary_key]
|
|
86
|
+
|
|
87
|
+
foreign_key_column.eq(primary_key_column)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_has_many_through_condition(reflection)
|
|
91
|
+
through_reflection = reflection.through_reflection
|
|
92
|
+
source_reflection = reflection.source_reflection
|
|
93
|
+
|
|
94
|
+
final_target_table = reflection.klass.arel_table
|
|
95
|
+
through_table = through_reflection.klass.arel_table
|
|
96
|
+
|
|
97
|
+
# Condition 1: Links the through_table to the final_target_table
|
|
98
|
+
# Based on source_reflection (e.g., Appointment.belongs_to :patient)
|
|
99
|
+
cond1_arel = if source_reflection.macro == :belongs_to
|
|
100
|
+
through_table[source_reflection.foreign_key].eq(final_target_table[source_reflection.association_primary_key])
|
|
101
|
+
else # :has_many, :has_one
|
|
102
|
+
final_target_table[source_reflection.foreign_key].eq(through_table[source_reflection.active_record_primary_key])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Condition 2: Links the through_table to the @outer_table_alias (base model)
|
|
106
|
+
# Based on through_reflection (e.g., Physician.has_many :appointments)
|
|
107
|
+
cond2_arel = if through_reflection.macro == :belongs_to
|
|
108
|
+
@outer_table_alias[through_reflection.foreign_key].eq(through_table[through_reflection.association_primary_key])
|
|
109
|
+
else # :has_many, :has_one
|
|
110
|
+
through_table[through_reflection.foreign_key].eq(@outer_table_alias[through_reflection.active_record_primary_key])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
subquery = through_table.project(Arel.sql("1"))
|
|
114
|
+
.where(cond1_arel.and(cond2_arel))
|
|
115
|
+
subquery.exists
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Provides method_missing interface for association access in aggregate blocks
|
|
120
|
+
class CorrelatedRelationBuilder
|
|
121
|
+
def initialize(base_model_class, outer_table_alias)
|
|
122
|
+
@association_resolver = AssociationResolver.new(base_model_class, outer_table_alias)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def method_missing(association_name, *args, &block_arg)
|
|
126
|
+
validate_method_call(association_name, args, block_arg)
|
|
127
|
+
@association_resolver.resolve_association(association_name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
131
|
+
@association_resolver.association?(method_name) || super
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def validate_method_call(association_name, args, block_arg)
|
|
137
|
+
return unless args.any? || block_arg
|
|
138
|
+
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"Unexpected arguments or block for association '#{association_name}' in aggregate definition."
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Provides access to outer table attributes in aggregate blocks
|
|
145
|
+
class OuterTableAttributeAccessor
|
|
146
|
+
def initialize(outer_table_alias, base_model_class)
|
|
147
|
+
@outer_table_alias = outer_table_alias
|
|
148
|
+
@base_model_class = base_model_class
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def method_missing(method_name, *args, &block_arg)
|
|
152
|
+
return super unless valid_attribute_access?(method_name, args, block_arg)
|
|
153
|
+
|
|
154
|
+
@outer_table_alias[method_name]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
158
|
+
column?(method_name) || super
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def valid_attribute_access?(method_name, args, block_arg)
|
|
164
|
+
args.empty? && block_arg.nil? && column?(method_name)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def column?(method_name)
|
|
168
|
+
@base_model_class.columns_hash.key?(method_name.to_s)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Handles building SQL projections for column aggregates
|
|
173
|
+
class ColumnProjectionBuilder
|
|
174
|
+
def initialize(outer_table_alias, base_model_class)
|
|
175
|
+
@outer_table_alias = outer_table_alias
|
|
176
|
+
@attribute_accessor = OuterTableAttributeAccessor.new(outer_table_alias, base_model_class)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_projection(aggregate_def, correlation_builder)
|
|
180
|
+
if aggregate_def.block?
|
|
181
|
+
build_block_based_projection(aggregate_def, correlation_builder)
|
|
182
|
+
else
|
|
183
|
+
build_direct_attribute_projection(aggregate_def)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def build_direct_attribute_projection(aggregate_def)
|
|
190
|
+
@outer_table_alias[aggregate_def.column].as(aggregate_def.name.to_s)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build_block_based_projection(aggregate_def, correlation_builder)
|
|
194
|
+
if (aliased_attribute_projection = try_aliased_attribute(aggregate_def))
|
|
195
|
+
aliased_attribute_projection
|
|
196
|
+
else
|
|
197
|
+
build_subquery_projection(aggregate_def, correlation_builder)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def try_aliased_attribute(aggregate_def)
|
|
202
|
+
value = aggregate_def.block.call(@attribute_accessor)
|
|
203
|
+
return nil unless arel_attribute_or_literal?(value)
|
|
204
|
+
|
|
205
|
+
value.as(aggregate_def.name.to_s)
|
|
206
|
+
rescue NoMethodError, ArgumentError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def arel_attribute_or_literal?(value)
|
|
211
|
+
value.is_a?(Arel::Attributes::Attribute) || value.is_a?(Arel::Nodes::SqlLiteral)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def build_subquery_projection(aggregate_def, correlation_builder)
|
|
215
|
+
relation = aggregate_def.block.call(correlation_builder)
|
|
216
|
+
validate_subquery_relation(relation, aggregate_def.name)
|
|
217
|
+
|
|
218
|
+
subquery_sql = relation.to_sql
|
|
219
|
+
Arel.sql("(#{subquery_sql})").as(aggregate_def.name.to_s)
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
raise ArgumentError,
|
|
222
|
+
"Block for column aggregate '#{aggregate_def.name}' failed. " \
|
|
223
|
+
"Not a valid aliased attribute or subquery. Error: #{e.message}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def validate_subquery_relation(relation, aggregate_name)
|
|
227
|
+
return if relation.is_a?(ActiveRecord::Relation)
|
|
228
|
+
|
|
229
|
+
raise ArgumentError,
|
|
230
|
+
"Block for column subquery '#{aggregate_name}' must return an ActiveRecord::Relation. " \
|
|
231
|
+
"Got: #{relation.class}"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Builds SQL for aggregate functions (count, sum, etc.)
|
|
236
|
+
class AggregateSubqueryBuilder
|
|
237
|
+
def build_subquery_sql(relation, aggregate_def)
|
|
238
|
+
base_query = relation.except(:select)
|
|
239
|
+
|
|
240
|
+
case aggregate_def.type
|
|
241
|
+
when :count
|
|
242
|
+
build_count_query(base_query)
|
|
243
|
+
when :count_expression
|
|
244
|
+
build_count_expression_query(base_query, aggregate_def.expression)
|
|
245
|
+
when :count_distinct
|
|
246
|
+
build_count_distinct_query(base_query, aggregate_def.column)
|
|
247
|
+
when :count_distinct_expression
|
|
248
|
+
build_count_distinct_expression_query(base_query, aggregate_def.expression)
|
|
249
|
+
when :sum
|
|
250
|
+
build_sum_query(base_query, aggregate_def.column)
|
|
251
|
+
when :sum_expression
|
|
252
|
+
build_sum_expression_query(base_query, aggregate_def.expression)
|
|
253
|
+
when :avg
|
|
254
|
+
build_avg_query(base_query, aggregate_def.column)
|
|
255
|
+
when :avg_expression
|
|
256
|
+
build_avg_expression_query(base_query, aggregate_def.expression)
|
|
257
|
+
when :min
|
|
258
|
+
build_min_query(base_query, aggregate_def.column)
|
|
259
|
+
when :min_expression
|
|
260
|
+
build_min_expression_query(base_query, aggregate_def.expression)
|
|
261
|
+
when :max
|
|
262
|
+
build_max_query(base_query, aggregate_def.column)
|
|
263
|
+
when :max_expression
|
|
264
|
+
build_max_expression_query(base_query, aggregate_def.expression)
|
|
265
|
+
when :string_agg
|
|
266
|
+
build_string_agg_query(base_query, aggregate_def.column, aggregate_def.options)
|
|
267
|
+
when :string_agg_expression
|
|
268
|
+
build_string_agg_expression_query(base_query, aggregate_def.expression, aggregate_def.options)
|
|
269
|
+
else
|
|
270
|
+
raise ArgumentError, "Unsupported aggregate type: #{aggregate_def.type}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def build_count_query(base_query)
|
|
277
|
+
base_query.select(Arel.star.count).to_sql
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def build_count_expression_query(base_query, expression)
|
|
281
|
+
base_query.select(Arel.sql("COUNT(#{expression})")).to_sql
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def build_count_distinct_query(base_query, column)
|
|
285
|
+
table = base_query.model.arel_table
|
|
286
|
+
base_query.select(table[column].count(true)).to_sql
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def build_count_distinct_expression_query(base_query, expression)
|
|
290
|
+
base_query.select(Arel.sql("COUNT(DISTINCT #{expression})")).to_sql
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def build_sum_query(base_query, column)
|
|
294
|
+
table = base_query.model.arel_table
|
|
295
|
+
sum_agg = table[column].sum
|
|
296
|
+
coalesced_sum = Arel::Nodes::NamedFunction.new("COALESCE", [sum_agg, Arel::Nodes.build_quoted(0)])
|
|
297
|
+
base_query.select(coalesced_sum).to_sql
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def build_sum_expression_query(base_query, expression)
|
|
301
|
+
base_query.select(Arel.sql("COALESCE(SUM(#{expression}), 0)")).to_sql
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def build_avg_query(base_query, column)
|
|
305
|
+
table = base_query.model.arel_table
|
|
306
|
+
avg_agg = table[column].average
|
|
307
|
+
coalesced_avg = Arel::Nodes::NamedFunction.new("COALESCE", [avg_agg, Arel::Nodes.build_quoted(0.0)])
|
|
308
|
+
base_query.select(coalesced_avg).to_sql
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_avg_expression_query(base_query, expression)
|
|
312
|
+
base_query.select(Arel.sql("COALESCE(AVG(#{expression}), 0.0)")).to_sql
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_min_query(base_query, column)
|
|
316
|
+
table = base_query.model.arel_table
|
|
317
|
+
min_agg = table[column].minimum
|
|
318
|
+
coalesced_min = Arel::Nodes::NamedFunction.new("COALESCE", [min_agg, Arel::Nodes.build_quoted(0)])
|
|
319
|
+
base_query.select(coalesced_min).to_sql
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def build_min_expression_query(base_query, expression)
|
|
323
|
+
base_query.select(Arel.sql("COALESCE(MIN(#{expression}), 0)")).to_sql
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def build_max_query(base_query, column)
|
|
327
|
+
table = base_query.model.arel_table
|
|
328
|
+
max_agg = table[column].maximum
|
|
329
|
+
coalesced_max = Arel::Nodes::NamedFunction.new("COALESCE", [max_agg, Arel::Nodes.build_quoted(0)])
|
|
330
|
+
base_query.select(coalesced_max).to_sql
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def build_max_expression_query(base_query, expression)
|
|
334
|
+
base_query.select(Arel.sql("COALESCE(MAX(#{expression}), 0)")).to_sql
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def build_string_agg_query(base_query, column, options)
|
|
338
|
+
table = base_query.model.arel_table
|
|
339
|
+
delimiter = options&.dig(:delimiter)
|
|
340
|
+
|
|
341
|
+
args = [table[column]]
|
|
342
|
+
args << Arel::Nodes.build_quoted(delimiter) if delimiter
|
|
343
|
+
|
|
344
|
+
base_query.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def build_string_agg_expression_query(base_query, expression, options)
|
|
348
|
+
delimiter = options&.dig(:delimiter)
|
|
349
|
+
|
|
350
|
+
args = [Arel.sql(expression)]
|
|
351
|
+
args << Arel::Nodes.build_quoted(delimiter) if delimiter
|
|
352
|
+
|
|
353
|
+
base_query.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Handles applying WHERE conditions from scope to outer table alias
|
|
358
|
+
class ScopeConditionApplier
|
|
359
|
+
def initialize(base_model, outer_table_alias)
|
|
360
|
+
@base_model = base_model
|
|
361
|
+
@outer_table_alias = outer_table_alias
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def apply_conditions_to_query(arel_query, scope)
|
|
365
|
+
processed_columns = apply_simple_where_conditions(arel_query, scope)
|
|
366
|
+
apply_arel_where_conditions(arel_query, scope, processed_columns)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
private
|
|
370
|
+
|
|
371
|
+
def apply_simple_where_conditions(arel_query, scope)
|
|
372
|
+
processed_columns = []
|
|
373
|
+
|
|
374
|
+
return processed_columns unless scope.respond_to?(:where_values_hash)
|
|
375
|
+
return processed_columns unless scope.where_values_hash.is_a?(Hash)
|
|
376
|
+
|
|
377
|
+
scope.where_values_hash.each do |column_name, value|
|
|
378
|
+
column_name_str = column_name.to_s
|
|
379
|
+
|
|
380
|
+
if model_column?(column_name_str)
|
|
381
|
+
arel_query.where(@outer_table_alias[column_name.to_sym].eq(value))
|
|
382
|
+
processed_columns << column_name_str
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
processed_columns
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def apply_arel_where_conditions(arel_query, scope, processed_columns)
|
|
390
|
+
where_clauses = extract_where_clauses(scope)
|
|
391
|
+
|
|
392
|
+
where_clauses.each do |constraint|
|
|
393
|
+
next unless simple_table_constraint?(constraint, processed_columns)
|
|
394
|
+
|
|
395
|
+
apply_constraint_to_query(arel_query, constraint)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def extract_where_clauses(scope)
|
|
400
|
+
scope.arel.ast.cores.flat_map(&:wheres)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def simple_table_constraint?(constraint, processed_columns)
|
|
404
|
+
return false unless constraint.left.is_a?(Arel::Attributes::Attribute)
|
|
405
|
+
return false unless constraint.left.relation.name == @base_model.table_name
|
|
406
|
+
return false if processed_columns.include?(constraint.left.name.to_s)
|
|
407
|
+
|
|
408
|
+
true
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def apply_constraint_to_query(arel_query, constraint)
|
|
412
|
+
if constraint.is_a?(Arel::Nodes::Equality)
|
|
413
|
+
apply_equality_constraint(arel_query, constraint)
|
|
414
|
+
elsif constraint.is_a?(Arel::Nodes::In)
|
|
415
|
+
apply_in_constraint(arel_query, constraint)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def apply_equality_constraint(arel_query, constraint)
|
|
420
|
+
column_name = constraint.left.name
|
|
421
|
+
value = constraint.right
|
|
422
|
+
arel_query.where(@outer_table_alias[column_name].eq(value))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def apply_in_constraint(arel_query, constraint)
|
|
426
|
+
column_name = constraint.left.name
|
|
427
|
+
values = extract_in_clause_values(constraint.right)
|
|
428
|
+
arel_query.where(@outer_table_alias[column_name].in(values))
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def extract_in_clause_values(right_operand)
|
|
432
|
+
return right_operand unless right_operand.is_a?(Array)
|
|
433
|
+
|
|
434
|
+
right_operand.map do |value|
|
|
435
|
+
value.is_a?(Arel::Nodes::BindParam) ? value.value.value_before_type_cast : value
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def model_column?(column_name)
|
|
440
|
+
@base_model.columns_hash.key?(column_name)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Main query builder that orchestrates the SQL generation
|
|
445
|
+
class QueryBuilder
|
|
446
|
+
def initialize(base_model)
|
|
447
|
+
@base_model = base_model
|
|
448
|
+
@subquery_builder = AggregateSubqueryBuilder.new
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def build_query_for_scope(scope, aggregates)
|
|
452
|
+
outer_table_alias = create_outer_table_alias
|
|
453
|
+
builders = create_helper_builders(outer_table_alias)
|
|
454
|
+
|
|
455
|
+
arel_query = create_base_query(scope, outer_table_alias)
|
|
456
|
+
# Filter out computed aggregates before building SQL projections
|
|
457
|
+
sql_aggregates = aggregates.reject(&:computed?)
|
|
458
|
+
add_projections_to_query(arel_query, sql_aggregates, builders, outer_table_alias)
|
|
459
|
+
apply_scope_conditions(arel_query, scope, outer_table_alias)
|
|
460
|
+
|
|
461
|
+
arel_query
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def create_outer_table_alias
|
|
467
|
+
@base_model.arel_table.alias("batchagg_outer_#{@base_model.table_name}")
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def create_helper_builders(outer_table_alias)
|
|
471
|
+
{
|
|
472
|
+
correlation: CorrelatedRelationBuilder.new(@base_model, outer_table_alias),
|
|
473
|
+
column_projection: ColumnProjectionBuilder.new(outer_table_alias, @base_model)
|
|
474
|
+
}
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def create_base_query(scope, outer_table_alias)
|
|
478
|
+
arel_query = Arel::SelectManager.new(scope.klass.connection)
|
|
479
|
+
arel_query.from(outer_table_alias)
|
|
480
|
+
|
|
481
|
+
primary_key_projection = outer_table_alias[@base_model.primary_key].as(@base_model.primary_key.to_s)
|
|
482
|
+
arel_query.project(primary_key_projection)
|
|
483
|
+
|
|
484
|
+
arel_query
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def add_projections_to_query(arel_query, aggregates, builders, _outer_table_alias)
|
|
488
|
+
aggregates.each do |aggregate_def|
|
|
489
|
+
# At this point, aggregate_def should not be a computed? one due to filtering above
|
|
490
|
+
projection = build_projection_for_aggregate(aggregate_def, builders)
|
|
491
|
+
arel_query.project(projection)
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def build_projection_for_aggregate(aggregate_def, builders)
|
|
496
|
+
if aggregate_def.column_based?
|
|
497
|
+
builders[:column_projection].build_projection(aggregate_def, builders[:correlation])
|
|
498
|
+
else
|
|
499
|
+
build_aggregate_function_projection(aggregate_def, builders[:correlation])
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def build_aggregate_function_projection(aggregate_def, correlation_builder)
|
|
504
|
+
correlated_relation = aggregate_def.block.call(correlation_builder)
|
|
505
|
+
subquery_sql = @subquery_builder.build_subquery_sql(correlated_relation, aggregate_def)
|
|
506
|
+
Arel.sql("(#{subquery_sql})").as(aggregate_def.name.to_s)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def apply_scope_conditions(arel_query, scope, outer_table_alias)
|
|
510
|
+
condition_applier = ScopeConditionApplier.new(@base_model, outer_table_alias)
|
|
511
|
+
condition_applier.apply_conditions_to_query(arel_query, scope)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Handles type casting of primary key values for hash keys
|
|
516
|
+
class PrimaryKeyTypeConverter
|
|
517
|
+
def self.cast_for_hash_key(id_value, model_class)
|
|
518
|
+
primary_key_column = find_primary_key_column(model_class)
|
|
519
|
+
type_converter = create_type_converter(primary_key_column)
|
|
520
|
+
type_converter.deserialize(id_value)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def self.find_primary_key_column(model_class)
|
|
524
|
+
model_class.columns_hash[model_class.primary_key]
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def self.create_type_converter(primary_key_column)
|
|
528
|
+
ActiveRecord::Type.lookup(
|
|
529
|
+
primary_key_column.type,
|
|
530
|
+
limit: primary_key_column.limit,
|
|
531
|
+
precision: primary_key_column.precision,
|
|
532
|
+
scale: primary_key_column.scale
|
|
533
|
+
)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Represents aggregate results for a single record
|
|
538
|
+
class AggregateResultForRecord
|
|
539
|
+
def initialize(row_data, all_aggregate_definitions)
|
|
540
|
+
@data = symbolize_keys(row_data)
|
|
541
|
+
@all_aggregate_definitions = all_aggregate_definitions
|
|
542
|
+
@computed_cache = {}
|
|
543
|
+
|
|
544
|
+
sql_aggregate_definitions = @all_aggregate_definitions.reject(&:computed?)
|
|
545
|
+
define_sql_aggregate_methods(sql_aggregate_definitions)
|
|
546
|
+
|
|
547
|
+
computed_aggregate_definitions = @all_aggregate_definitions.select(&:computed?)
|
|
548
|
+
define_computed_aggregate_methods(computed_aggregate_definitions)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
private
|
|
552
|
+
|
|
553
|
+
def symbolize_keys(hash)
|
|
554
|
+
hash.transform_keys(&:to_sym)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def define_sql_aggregate_methods(sql_aggregate_definitions)
|
|
558
|
+
sql_aggregate_definitions.each do |aggregate_def|
|
|
559
|
+
define_singleton_method(aggregate_def.name) do
|
|
560
|
+
@data[aggregate_def.name]
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def define_computed_aggregate_methods(computed_aggregate_definitions)
|
|
566
|
+
computed_aggregate_definitions.each do |aggregate_def|
|
|
567
|
+
define_singleton_method(aggregate_def.name) do
|
|
568
|
+
return @computed_cache[aggregate_def.name] if @computed_cache.key?(aggregate_def.name)
|
|
569
|
+
|
|
570
|
+
value = instance_exec(self, &aggregate_def.block)
|
|
571
|
+
@computed_cache[aggregate_def.name] = value
|
|
572
|
+
value
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Orchestrates query execution and result processing
|
|
579
|
+
class AggregateResultProcessor
|
|
580
|
+
def initialize(aggregates, base_model)
|
|
581
|
+
@aggregates = aggregates
|
|
582
|
+
@base_model = base_model
|
|
583
|
+
@query_builder = QueryBuilder.new(base_model)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def process_single_record(record)
|
|
587
|
+
scope = create_single_record_scope(record)
|
|
588
|
+
process_scope(scope)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def process_scope(scope)
|
|
592
|
+
query_results = execute_query(scope)
|
|
593
|
+
convert_results_to_hash(query_results, scope.klass)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
private
|
|
597
|
+
|
|
598
|
+
def create_single_record_scope(record)
|
|
599
|
+
@base_model.where(@base_model.primary_key => record.id)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def execute_query(scope)
|
|
603
|
+
query_arel = @query_builder.build_query_for_scope(scope, @aggregates)
|
|
604
|
+
scope.klass.connection.select_all(query_arel.to_sql).to_a
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def convert_results_to_hash(query_results, model_class)
|
|
608
|
+
query_results.each_with_object({}) do |row_hash, result_hash|
|
|
609
|
+
record_id = extract_and_cast_record_id(row_hash, model_class)
|
|
610
|
+
result_hash[record_id] = AggregateResultForRecord.new(row_hash, @aggregates)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def extract_and_cast_record_id(row_hash, model_class)
|
|
615
|
+
raw_id = row_hash[@base_model.primary_key.to_s]
|
|
616
|
+
PrimaryKeyTypeConverter.cast_for_hash_key(raw_id, model_class)
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Main class that provides the public API for aggregate results
|
|
621
|
+
class AggregateResultClass
|
|
622
|
+
def initialize(aggregates, base_model)
|
|
623
|
+
@processor = AggregateResultProcessor.new(aggregates, base_model)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def only(record)
|
|
627
|
+
@processor.process_single_record(record)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def from(scope)
|
|
631
|
+
@processor.process_scope(scope)
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Builder for creating aggregate definitions using DSL methods
|
|
636
|
+
class AggregateDefinitionCollector
|
|
637
|
+
def initialize
|
|
638
|
+
@aggregates = []
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def column(name, &block)
|
|
642
|
+
add_aggregate(:column, name, block, column: name)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def count(name, &block)
|
|
646
|
+
add_aggregate(:count, name, block)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def count_distinct(name, column, &block)
|
|
650
|
+
add_aggregate(:count_distinct, name, block, column: column)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def count_expression(name, expression, &block)
|
|
654
|
+
add_aggregate(:count_expression, name, block, expression: expression)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def count_distinct_expression(name, expression, &block)
|
|
658
|
+
add_aggregate(:count_distinct_expression, name, block, expression: expression)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def sum(name, column, &block)
|
|
662
|
+
add_aggregate(:sum, name, block, column: column)
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def sum_expression(name, expression, &block)
|
|
666
|
+
add_aggregate(:sum_expression, name, block, expression: expression)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def avg(name, column, &block)
|
|
670
|
+
add_aggregate(:avg, name, block, column: column)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def avg_expression(name, expression, &block)
|
|
674
|
+
add_aggregate(:avg_expression, name, block, expression: expression)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def min(name, column, &block)
|
|
678
|
+
add_aggregate(:min, name, block, column: column)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def min_expression(name, expression, &block)
|
|
682
|
+
add_aggregate(:min_expression, name, block, expression: expression)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def max(name, column, &block)
|
|
686
|
+
add_aggregate(:max, name, block, column: column)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def max_expression(name, expression, &block)
|
|
690
|
+
add_aggregate(:max_expression, name, block, expression: expression)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def string_agg(name, column, delimiter: nil, &block)
|
|
694
|
+
add_aggregate(:string_agg, name, block, column: column, options: { delimiter: delimiter })
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def string_agg_expression(name, expression, delimiter: nil, &block)
|
|
698
|
+
add_aggregate(:string_agg_expression, name, block, expression: expression, options: { delimiter: delimiter })
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def computed(name, &block)
|
|
702
|
+
add_aggregate(:computed, name, block)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
attr_reader :aggregates
|
|
706
|
+
|
|
707
|
+
private
|
|
708
|
+
|
|
709
|
+
def add_aggregate(type, name, block, column: nil, expression: nil, options: nil)
|
|
710
|
+
@aggregates << AggregateDefinition.new(name, type, block, column, expression, options)
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Main builder that coordinates the DSL and creates the result class
|
|
715
|
+
class AggregateBuilder
|
|
716
|
+
def initialize(base_model)
|
|
717
|
+
@base_model = base_model
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def build_class(&)
|
|
721
|
+
collector = AggregateDefinitionCollector.new
|
|
722
|
+
collector.instance_eval(&)
|
|
723
|
+
aggregates = collector.aggregates
|
|
724
|
+
|
|
725
|
+
AggregateResultClass.new(aggregates, @base_model)
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Public DSL module
|
|
730
|
+
module DSL
|
|
731
|
+
def aggregate(base_model, &)
|
|
732
|
+
builder = BatchAgg::AggregateBuilder.new(base_model)
|
|
733
|
+
builder.build_class(&)
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
end
|
data/sig/batchagg.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: batchagg
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- JT Archie
|
|
8
|
+
bindir: exe
|
|
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: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: BatchAgg eliminates N+1 query problems when calculating counts, sums,
|
|
27
|
+
averages, and other aggregates across associations by using correlated subqueries
|
|
28
|
+
to fetch all aggregations in a single efficient database call.
|
|
29
|
+
email:
|
|
30
|
+
- jtarchie@gmail.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- ".rspec"
|
|
36
|
+
- ".rubocop.yml"
|
|
37
|
+
- CODE_OF_CONDUCT.md
|
|
38
|
+
- LICENSE
|
|
39
|
+
- LICENSE.txt
|
|
40
|
+
- README.md
|
|
41
|
+
- Rakefile
|
|
42
|
+
- lib/batchagg.rb
|
|
43
|
+
- lib/batchagg/version.rb
|
|
44
|
+
- sig/batchagg.rbs
|
|
45
|
+
homepage: https://github.com/jtarchie/batchagg.
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
homepage_uri: https://github.com/jtarchie/batchagg.
|
|
50
|
+
source_code_uri: https://github.com/jtarchie/batchagg.
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 3.3.0
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.6.7
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: Efficiently perform multiple database aggregations on ActiveRecord models
|
|
69
|
+
in a single query
|
|
70
|
+
test_files: []
|