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 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
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rubocop/rake_task"
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: :rubocop
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BatchAgg
4
+ VERSION = "0.1.0"
5
+ end
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
@@ -0,0 +1,6 @@
1
+ module Batchagg
2
+ VERSION: String
3
+ module DSL
4
+ def aggregate: (untyped base_model) { () -> void } -> AggregateResultClass
5
+ end
6
+ end
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: []