calculate-all 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 35426c107c947f1f28da7166c703bade37858910
4
- data.tar.gz: 810c8b1b9020a9ec4aef2886b6a6fc523f44340e
2
+ SHA256:
3
+ metadata.gz: 0ccade1f3bc4b62c2842ef335e27d0c7a11a90fa18e90f3b1260bb572243a4b6
4
+ data.tar.gz: 9d8dd96e03c6155f828d5fb586a652e9f0d94a5cbb21cfb41fc3c5022772501c
5
5
  SHA512:
6
- metadata.gz: d90936efd4b9a78893e534b347c92934becc305281a7e2b2e9582644eaf3e063da8311c30a8da2b48ca0fa893024a6800e008af964d21a7f3eb64b4c2789a4d9
7
- data.tar.gz: ac2dcf69499e33939e28f5b9bc7cd9582e1796b628910edf1ac20173ce6c373f903d8b958eb94f4840927acfd2ead5b22d3b15be4dbec13f17d31a0debe604a4
6
+ metadata.gz: df3d5a6c997473a188f824f865951c05ed8d054758906c6647d4c868a55a37cb55cc0c5981b0bbe4106214d386a03c11fcb41211498c58b7f022a779b265d192
7
+ data.tar.gz: ca3c3d75e899ff84f85886aeb649f86cc81f0612476297102036a7d991ed872819381c9505f7ed34b3713aed508960f71d8377ac986d92599f9bf585fe493d78
@@ -0,0 +1,41 @@
1
+ name: build
2
+ on: [push, pull_request]
3
+ jobs:
4
+ build:
5
+ strategy:
6
+ fail-fast: false
7
+ matrix:
8
+ include:
9
+ - ruby: "3.4"
10
+ gemfile: Gemfile
11
+ - ruby: "3.1"
12
+ gemfile: gemfiles/activerecord70.gemfile
13
+ - ruby: "3.0"
14
+ gemfile: gemfiles/activerecord61.gemfile
15
+ - ruby: "2.7"
16
+ gemfile: gemfiles/activerecord60.gemfile
17
+ - ruby: "2.6"
18
+ gemfile: gemfiles/activerecord52.gemfile
19
+ - ruby: "2.6"
20
+ gemfile: gemfiles/activerecord51.gemfile
21
+ - ruby: "2.6"
22
+ gemfile: gemfiles/activerecord50.gemfile
23
+ - ruby: "2.3"
24
+ gemfile: gemfiles/activerecord42.gemfile
25
+ runs-on: ubuntu-latest
26
+ env:
27
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
28
+ steps:
29
+ - uses: actions/checkout@v2
30
+ - uses: ruby/setup-ruby@v1
31
+ with:
32
+ ruby-version: ${{ matrix.ruby }}
33
+ bundler-cache: true
34
+ - uses: ankane/setup-postgres@v1
35
+ with:
36
+ database: calculate_all_test
37
+ - uses: ankane/setup-mysql@v1
38
+ with:
39
+ database: calculate_all_test
40
+ - run: mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql
41
+ - run: bundle exec rake test
data/.gitignore CHANGED
@@ -7,3 +7,5 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+
11
+ *.lock
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## 0.3.0
2
+
3
+ * Allow expression shortcuts as attribute values too for renaming
4
+ * Allow grouping expressions to be returned in rows too
5
+ * Breaking change: only single *string* expression argument is returning unwrapped rows now.
6
+ Single expression shortcut like `:count` will be expanded to `{count: value}` rows.
7
+
8
+ ## 0.2.2
9
+
10
+ * Added support for Groupdate 4+ (Andrew <acekane1@gmail.com>)
11
+ * Tested with sqlite3, ruby 3.1, Rails 7
12
+
13
+ ## 0.2.1
14
+
15
+ * Silence deprecation warnings (Forrest Ye <fye@mutan.io>)
16
+
17
+ ## 0.2.0
18
+
19
+ * Rails 5 compatibility (Stef Schenkelaars <stef.schenkelaars@gmail.com>)
20
+
1
21
  ## 0.1.1
2
22
 
3
23
  * groupdate compatibility
data/Gemfile CHANGED
@@ -1,7 +1,16 @@
1
- source 'https://rubygems.org'
1
+ source "https://rubygems.org"
2
2
 
3
- # Specify your gem's dependencies in calculate-all.gemspec
4
3
  gemspec
5
4
 
6
- gem 'activerecord'
7
- gem 'pry'
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 8.0.0"
9
+ gem "groupdate", "~> 6"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3", ">= 2.1"
13
+
14
+ gem "ostruct"
15
+
16
+ gem "standardrb", require: false
data/README.md CHANGED
@@ -1,36 +1,48 @@
1
1
  # CalculateAll
2
2
 
3
- Provides `#calculate_all` method on your Active Record models, scopes and relations.
4
- It's a little addition to Active Record's `#count`, `#maximum`, `#minimum`, `#average` and `#sum`.
5
- It allows to fetch all of the above and any other aggregate functions results in one request, with respect to grouping.
3
+ Provides the `#calculate_all` method for your Active Record models, scopes and relations.
4
+ It's a small addition to Active Record's `#count`, `#maximum`, `#minimum`, `#average`, `#sum`
5
+ and `#calculate`.
6
+ It allows you to fetch all of the above, as well as other aggregate function results,
7
+ in a single request, with support for grouping.
6
8
 
7
- Tested only with Postgres and MySQL only right now. It relies on automatic values type-casting of underlying driver.
9
+ Should be useful for dashboards, timeseries stats, and charts.
10
+
11
+ Currently tested with PostgreSQL, MySQL and SQLite3, ruby >= 2.3, rails >= 4, groupdate >= 4.
8
12
 
9
13
  ## Usage
10
14
 
15
+ (example SQL snippets are given for PostgreSQL)
16
+
11
17
  ```ruby
12
- stats = Order.group(:department_id).group(:payment_method).calculate_all(
18
+ stats = Order.group(:department_id).group(:payment_method).order(:payment_method).calculate_all(
19
+ :payment_method,
13
20
  :count,
14
- :count_distinct_user_id,
15
21
  :price_max,
16
22
  :price_min,
17
23
  :price_avg,
18
- price_median: 'percentile_cont(0.5) within group (order by price desc)'
24
+ total_users: :count_distinct_user_id,
25
+ price_median: "percentile_cont(0.5) within group (order by price asc)",
26
+ plan_ids: "array_agg(distinct plan_id order by plan_id)",
27
+ earnings: "sum(price) filter (where status = 'paid')"
19
28
  )
20
- #
21
- # (2.2ms) SELECT department_id, payment_method, percentile_cont(0.5) within group (order by price desc),
22
- # COUNT(*), COUNT(DISTINCT user_id), MAX(price), MIN(price), AVG(price) FROM "orders" GROUP BY "department_id", "payment_method"
23
- #
29
+ # Order Pluck (20.0ms) SELECT "orders"."department_id", "payment_method", COUNT(*), MAX(price), MIN(price), AVG(price),
30
+ # COUNT(DISTINCT user_id), percentile_cont(0.5) within group (order by price asc),
31
+ # array_agg(distinct plan_id order by plan_id), sum(price) filter (where status = 'paid')
32
+ # FROM "orders" GROUP BY "orders"."department_id", "payment_method" ORDER BY "payment_method" ASC
24
33
  # => {
25
- # [1, "cash"] => {
34
+ # [1, "card"] => {
35
+ # payment_method: "card",
26
36
  # count: 10,
27
- # count_distinct_user_id: 5,
28
37
  # price_max: 500,
29
38
  # price_min: 100,
30
- # price_avg: #<BigDecimal:7ff5932ff3d8,'0.3E3',9(27)>,
31
- # price_median: #<BigDecimal:7ff5932ff3c2,'0.4E3',9(27)>
39
+ # price_avg: 0.3e3,
40
+ # total_users: 5,
41
+ # price_median: 0.4e3,
42
+ # plan_ids: [4, 7, 12],
43
+ # earnings: 2340
32
44
  # },
33
- # [1, "card"] => {
45
+ # [1, "cash"] => {
34
46
  # ...
35
47
  # }
36
48
  # }
@@ -38,39 +50,37 @@ stats = Order.group(:department_id).group(:payment_method).calculate_all(
38
50
 
39
51
  ## Rationale
40
52
 
41
- Active Record allows to use most common DB aggregate functions, COUNT(), MAX(), MIN(), AVG(), SUM() really easy.
42
- But there's a whole world of wonderful other functions in
43
- [Postgres](http://www.postgresql.org/docs/9.5/static/functions-aggregate.html) which I can't recommend enough
44
- if you going to have any work with statistics and BI on your data, though MySQL has something
45
- [too](http://dev.mysql.com/doc/refman/5.7/en/group-by-functions.html).
53
+ Active Record makes it really easy to use most common database aggregate functions like COUNT(), MAX(), MIN(), AVG(), SUM().
54
+ But there's a whole world of other aggregate functions in
55
+ [PostgreSQL](http://www.postgresql.org/docs/current/functions-aggregate.html),
56
+ [MySQL](https://dev.mysql.com/doc/refman/9.3/en/aggregate-functions.html)
57
+ and [SQLite](https://www.sqlite.org/lang_aggfunc.html)
58
+ which I can’t recommend enough, especially if you’re working with statistics or business intelligence.
46
59
 
47
- Also, in many cases you'll need several metrics at once, and database often has to perform a full scan on
48
- the table for each metric, but it as well can calculate them all in one scan and one request.
60
+ Also, in many cases, youll need multiple metrics at once. Typically, the database performs a full scan of the table for each metric.
61
+ However, it can calculate all of them in a single scan and a single request.
49
62
 
50
63
  `#calculate_all` to the rescue!
51
64
 
52
65
  ## Arguments
53
66
 
54
- `#calculate_all` accepts a list of expression aliases and/or expression mapping.
55
- It could be either one string of SQL,
67
+ `#calculate_all` accepts a single SQL expression with aggregate functions,
56
68
 
57
69
  ```ruby
58
- Model.calculate_all('SUM(price) / COUNT(DISTINCT user_id)')
70
+ Model.calculate_all('CAST(SUM(price) as decimal) / COUNT(DISTINCT user_id)')
59
71
  ```
60
72
 
61
- a hash of expressions with arbitrary symbol keys
73
+ or arbitrary symbols and keyword arguments with SQL snippets, aggregate function shortcuts or previously given grouping values.
62
74
 
63
75
  ```ruby
64
- Model.calculate_all(total: 'COUNT(*)', average_spendings: 'SUM(price) / COUNT(DISTINCT user_id)')
76
+ Model.group(:currency).calculate_all(
77
+ :average_price, :currency, total: :sum_price, average_spendings: 'SUM(price)::decimal / COUNT(DISTINCT user_id)'
78
+ )
65
79
  ```
66
- or a list of one or more symbols without expressions, in which case `#calculate_all` tries to guess
67
- what you wanted from it.
68
80
 
69
- ```ruby
70
- Model.calculate_all(:count, :average_price, :sum_price)
71
- ```
81
+ For convenience, `calculate_all(:count, :avg_column)` is the same as `caculate(count: :count, avg_column: :avg_column)`
72
82
 
73
- It's not so smart right now, but here's a cheatsheet:
83
+ Here's a cheatsheet of recognized shortcuts:
74
84
 
75
85
  | symbol | would fetch
76
86
  |------------------------------------------------------------------------|------------
@@ -82,28 +92,30 @@ It's not so smart right now, but here's a cheatsheet:
82
92
  | `:avg_column1`, `:column1_avg`, `:average_column1`, `:column1_average` | `AVG(column1)`
83
93
  | `:sum_column1`, `:column1_sum` | `SUM(column1)`
84
94
 
95
+ Other functions are a bit too database specific, and are better to be given with an explicit SQL snippet.
96
+
97
+ Please don't put values from unverified sources (like HTML form or javascript call) into expression list,
98
+ it could result in malicious SQL injection.
99
+
85
100
  ## Result
86
101
 
87
102
  `#calculate_all` tries to mimic magic of Active Record's `#group`, `#count` and `#pluck`
88
103
  so result type depends on arguments and on groupings.
89
104
 
90
- If you have no `group()` on underlying scope, `#calculate_all` will return just one result.
105
+ If you have no `group()` on underlying scope, `#calculate_all` will return just one row.
91
106
 
92
107
  ```ruby
93
- # same as Order.distinct.count(:user_id), so, probably useless example
94
- # but you can have any expression with aggregate functions there.
95
- Order.calculate_all('COUNT(DISTINCT user_id)')
96
- # => 50
108
+ Order.calculate_all(:price_sum)
109
+ # => {price_sum: 123500}
97
110
  ```
98
111
 
99
- If you have one group, it will return hash of results, with simple keys.
112
+ If you have a single `group()`, it will return a hash of results with simple keys.
100
113
 
101
114
  ```ruby
102
- # again, Order.group(:department_id).distinct.count(:user_id) would do the same
103
115
  Order.group(:department_id).calculate_all(:count_distinct_user_id)
104
116
  # => {
105
- # 1 => 20,
106
- # 2 => 10,
117
+ # 1 => {count_distinct_user_id: 20},
118
+ # 2 => {count_distinct_user_id: 10},
107
119
  # ...
108
120
  # }
109
121
  ```
@@ -111,31 +123,42 @@ Order.group(:department_id).calculate_all(:count_distinct_user_id)
111
123
  If you have two or more groupings, each result will have an array as a key.
112
124
 
113
125
  ```ruby
114
- Order.group(:department_id).group(:department_method).calculate_all(:count_distinct_user_id)
126
+ Order.group(:department_id).group(:payment_method).calculate_all(:count)
115
127
  # => {
116
- # [1, "cash"] => 5,
117
- # [1, "card"] => 15,
118
- # [2, "cash"] => 1,
128
+ # [1, "cash"] => {count: 5},
129
+ # [1, "card"] => {count: 15},
130
+ # [2, "cash"] => {count: 1},
119
131
  # ...
120
132
  # }
121
133
  ```
122
134
 
123
- If you provide just one argument to `#calculate_all`, its calculated value will be returned as is.
124
- Otherwise results would be returned as hash(es) with symbol keys.
135
+ If you provide only one *string* argument to `#calculate_all`, its calculated value will be returned as-is.
136
+ This is just to make grouped companion to `Model.group(...).count` and friends, but for arbitrary expressions
137
+ with aggregate functions.
138
+
139
+ ```ruby
140
+ Order.group(:payment_method).calculate_all('CAST(SUM(price) AS decimal) / COUNT(DISTINCT user_id)')
141
+ # => {
142
+ # "card" => 0.524e3
143
+ # "cash" => 0.132e3
144
+ # }
145
+ ```
125
146
 
126
- so, `Order.calculate_all(:count)` will return just a single integer, but
147
+ Otherwise, the results will be returned as hash(es) with symbol keys.
127
148
 
128
149
  ```ruby
129
- Order.group(:department_id).group(:payment_method).calculate_all(:min_price, expr1: 'count(distinct user_id)')
150
+ Order.group(:department_id).group(:payment_method).calculate_all(
151
+ :min_price, type: :payment_method, expr1: 'count(distinct user_id)'
152
+ )
130
153
  # => {
131
- # [1, 'cash'] => {min_price: 100, expr1: 5},
132
- # [1, 'card'] => {min_price: 150, expr2: 15},
154
+ # [1, 'cash'] => {min_price: 100, type: 'cash', expr1: 5},
155
+ # [1, 'card'] => {min_price: 150, type: 'card', expr1: 15},
133
156
  # ...
134
157
  # }
135
158
  ```
136
159
 
137
- You can pass block to calculate_all. Rows will be passed to it and returned value will be used instead of
138
- row in result hash (or returned as is if there's no grouping)
160
+ You can pass a block to `calculate_all`. Rows will be passed to it, and returned value will be used instead of
161
+ the row in the result hash (or returned as-is if there's no grouping).
139
162
 
140
163
  ```ruby
141
164
  Order.group(:country_id).calculate_all(:count, :avg_price) { |count:, avg_price:|
@@ -146,7 +169,7 @@ Order.group(:country_id).calculate_all(:count, :avg_price) { |count:, avg_price:
146
169
  # 2 => "10 orders, 200 dollars average"
147
170
  # }
148
171
 
149
- Order.group(:country_id).calculate_all(:avg_price) { |avg_price| avg_price.to_i }
172
+ Order.group(:country_id).calculate_all("AVG(price)") { |avg_price| avg_price.to_i }
150
173
  # => {
151
174
  # 1 => 120,
152
175
  # 2 => 200
@@ -154,6 +177,17 @@ Order.group(:country_id).calculate_all(:avg_price) { |avg_price| avg_price.to_i
154
177
 
155
178
  Order.calculate_all(:count, :max_price, &OpenStruct.method(:new))
156
179
  # => #<OpenStruct max_price=500, count=15>
180
+
181
+ Stats = Data.define(:count, :max_price) do
182
+ # needed only for groupdate to provide defaults for empty periods
183
+ def initialize(count: 0, max_price: nil) = super
184
+ end
185
+ Order.group_by_year(:created_at).calculate_all(*Stats.members, &Stats.method(:new))
186
+ # => {
187
+ # Wed, 01 Jan 2014 => #<data Stats count=2, max_price=700>,
188
+ # Thu, 01 Jan 2015 => #<data Stats count=0, max_price=nil>,
189
+ # Fri, 01 Jan 2016 => #<data Stats count=3, max_price800>
190
+ # }
157
191
  ```
158
192
 
159
193
  ## groupdate compatibility
@@ -161,16 +195,18 @@ Order.calculate_all(:count, :max_price, &OpenStruct.method(:new))
161
195
  calculate-all should work with [groupdate](https://github.com/ankane/groupdate) too:
162
196
 
163
197
  ```ruby
164
- Order.group_by_year(:created_at, last: 5, default_value: {}).calculate_all(:price_min, :price_max)
165
- => {
166
- Sun, 01 Jan 2012 => {},
167
- Tue, 01 Jan 2013 => {},
168
- Wed, 01 Jan 2014 => {},
169
- Thu, 01 Jan 2015 => {},
170
- Fri, 01 Jan 2016 => {:price_min=>100, :price_max=>500}
171
- }
198
+ Order.group_by_year(:created_at, last: 5).calculate_all(:price_min, :price_max)
199
+ # => {
200
+ # Sun, 01 Jan 2012 => {},
201
+ # Tue, 01 Jan 2013 => {},
202
+ # Wed, 01 Jan 2014 => {},
203
+ # Thu, 01 Jan 2015 => {},
204
+ # Fri, 01 Jan 2016 => {:price_min=>100, :price_max=>500}
205
+ # }
172
206
  ```
173
207
 
208
+ It works even with groupdate < 4, though you'd have to explicitly provide `default_value: {}` for blank periods.
209
+
174
210
  ## Installation
175
211
 
176
212
  Add this line to your application's Gemfile:
@@ -189,9 +225,19 @@ Or install it yourself as:
189
225
 
190
226
  ## Development
191
227
 
192
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
228
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
229
+ Run `BUNDLE_GEMFILE=gemfiles/activerecord60.gemfile bundle` then `BUNDLE_GEMFILE=gemfiles/activerecord60.gemfile rake`
230
+ to test agains specific active record version.
231
+
232
+ To experiment you can load a test database and jump to IRB with
233
+
234
+ ```sh
235
+ rake VERBOSE=1 CONSOLE=1 TESTOPTS="--name=test_console" test:postgresql
236
+ ```
193
237
 
194
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
238
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version
239
+ number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags,
240
+ and push the `.gem` file to [rubygems.org](https://rubygems.org).
195
241
 
196
242
  ## Contributing
197
243
 
data/Rakefile CHANGED
@@ -1,10 +1,22 @@
1
- require 'bundler/gem_tasks'
2
- require 'rake/testtask'
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
3
 
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << 'test'
6
- t.libs << 'lib'
7
- t.test_files = FileList['test/**/*_test.rb']
4
+ ADAPTERS = %w[postgresql mysql sqlite]
5
+
6
+ ADAPTERS.each do |adapter|
7
+ namespace :test do
8
+ task("env:#{adapter}") { ENV["ADAPTER"] = adapter }
9
+
10
+ Rake::TestTask.new(adapter => "env:#{adapter}") do |t|
11
+ t.description = "Run tests for #{adapter}"
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
15
+ end
16
+ end
8
17
  end
9
18
 
10
- task :default => :test
19
+ desc "Run all adapter tests"
20
+ task test: ADAPTERS.map { |adapter| "test:#{adapter}" }
21
+
22
+ task default: :test
data/bin/setup CHANGED
@@ -2,6 +2,9 @@
2
2
  set -euo pipefail
3
3
  IFS=$'\n\t'
4
4
 
5
+ echo "Installing gems..."
5
6
  bundle install
6
-
7
- # Do any other automated setup that you need to do here
7
+ echo "Creating postgres database..."
8
+ createdb calculate_all_test || echo "...failed"
9
+ echo "Creating mysql database..."
10
+ mysqladmin -u root create calculate_all_test || echo "...failed"
@@ -1,30 +1,24 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path("../lib", __FILE__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'calculate-all/version'
3
+ require "calculate-all/version"
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "calculate-all"
8
- spec.version = CalculateAll::VERSION
9
- spec.authors = ["codesnik"]
10
- spec.email = ["aronaxis@gmail.com"]
6
+ spec.name = "calculate-all"
7
+ spec.version = CalculateAll::VERSION
8
+ spec.authors = ["Alexey Trofimenko"]
9
+ spec.email = ["aronaxis@gmail.com"]
11
10
 
12
- spec.summary = %q{Fetch from database results of several aggregate functions at once}
13
- spec.description = %q{Extends Active Record with #calculate_all method}
14
- spec.homepage = "http://github.com/codesnik/calculate-all"
15
- spec.license = "MIT"
11
+ spec.summary = "Fetch from database results of several aggregate functions at once"
12
+ spec.description = "Extends Active Record with #calculate_all method"
13
+ spec.homepage = "http://github.com/codesnik/calculate-all"
14
+ spec.license = "MIT"
16
15
 
17
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
- spec.bindir = "exe"
19
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
19
  spec.require_paths = ["lib"]
21
20
 
22
- spec.add_dependency "activerecord"
21
+ spec.required_ruby_version = '>= 2.3.0'
23
22
 
24
- spec.add_development_dependency "bundler", "~> 1.10"
25
- spec.add_development_dependency "rake", "~> 10.0"
26
- spec.add_development_dependency "minitest"
27
- spec.add_development_dependency "pg"
28
- spec.add_development_dependency "mysql2"
29
- spec.add_development_dependency "groupdate"
23
+ spec.add_dependency "activesupport", ">= 4.0.0"
30
24
  end
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest", "~> 5.0"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 4.2.0"
9
+ gem "groupdate", "~> 3.0.0"
10
+ gem "pg", "~> 0.15"
11
+ gem "mysql2"
12
+ gem "sqlite3", "~> 1.3.0"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 5.0.0"
9
+ gem "groupdate", "~> 4.0.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3", "~> 1.3.0"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 5.1.0"
9
+ gem "groupdate", "~> 5.0.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 5.2.0"
9
+ gem "groupdate", "~> 5.0.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 6.0.0"
9
+ gem "groupdate", "~> 5.0.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 6.1.0"
9
+ gem "groupdate", "~> 5.0.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3", "~> 1.4"
@@ -0,0 +1,12 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec path: ".."
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "minitest-reporters"
8
+ gem "activerecord", "~> 7.0.0"
9
+ gem "groupdate", "~> 5.2.0"
10
+ gem "pg"
11
+ gem "mysql2"
12
+ gem "sqlite3", "~> 1.4"
@@ -1,3 +1,3 @@
1
1
  module CalculateAll
2
- VERSION = '0.2.1'
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/calculate-all.rb CHANGED
@@ -1,89 +1,125 @@
1
- require 'calculate-all/version'
2
- require 'calculate-all/helpers'
3
- require 'calculate-all/querying'
1
+ require "active_support"
2
+ require "calculate-all/version"
4
3
 
5
4
  module CalculateAll
6
- # Method to aggregate function results in one request
7
- def calculate_all(*function_aliases, **functions, &block)
8
-
9
- # If only one function_alias is given, the result can be just a single value
10
- # So return [{ cash: 3 }] instead of [{ cash: { count: 3 }}]
11
- if function_aliases.size == 1 && functions == {}
5
+ # Calculates multiple aggregate values on a scope in one request, similarly to #calculate
6
+ def calculate_all(*expression_shortcuts, **named_expressions, &block)
7
+ # If only one aggregate is given as a string or Arel.sql without explicit naming,
8
+ # return row(s) directly without wrapping in Hash
9
+ if expression_shortcuts.size == 1 && expression_shortcuts.first.is_a?(String) &&
10
+ named_expressions.size == 0
12
11
  return_plain_values = true
13
12
  end
14
13
 
15
- # Convert the function_aliases to actual SQL
16
- functions.merge!(
17
- CalculateAll::Helpers.decode_function_aliases(function_aliases)
18
- )
14
+ named_expressions = expression_shortcuts.map { |name| [name, name] }.to_h.merge(named_expressions)
19
15
 
20
- # Check if any functions are given
21
- if functions == {}
22
- raise ArgumentError, 'provide at least one function to calculate'
16
+ named_expressions.transform_values! do |shortcut|
17
+ Helpers.decode_expression_shortcut(shortcut, group_values)
23
18
  end
24
19
 
25
- # If function is called without a group, the pluck method will still return
26
- # an array but it is an array with the final results instead of each group
27
- # The plain_rows boolean states how the results should be used
28
- if functions.size == 1 && group_values.size == 0
29
- plain_rows = true
30
- end
20
+ raise ArgumentError, "provide at least one expression to calculate" if named_expressions.empty?
31
21
 
32
- # Final output hash
33
- results = {}
22
+ # Some older active_record versions do not allow for repeating expressions in pluck list,
23
+ # and named expressions could contain group values.
24
+ columns = (group_values + named_expressions.values).uniq
25
+ value_mapping = named_expressions.transform_values { |column| columns.index(column) }
26
+ columns.map! { |column| column.is_a?(String) ? Arel.sql(column) : column }
34
27
 
35
- # Fetch all the requested calculations from the database
36
- # Note the map(&:to_s). It is required since groupdate returns a
37
- # Groupdate::OrderHack instead of a string for the group_values which is not
38
- # accepted by ActiveRecord's pluck method.
39
- sql_snippets = group_values.map(&:to_s) + functions.values
40
- # Fix DEPRECATION WARNING:
41
- # Dangerous query method, will be disallowed in Rails 6.0
42
- # using Arel.sql() to silence the warning
43
- # https://github.com/rails/rails/commit/310c3a8f2d043f3d00d3f703052a1e160430a2c2
44
- pluck(*sql_snippets.map { |sql| Arel.sql(sql) }).each do |row|
45
-
46
- # If no grouping, make sure it is still a results array
47
- row = [row] if plain_rows
48
-
49
- # If only one value, return a single value, else return a hash
50
- if return_plain_values
51
- value = row.last
28
+ results = {}
29
+ pluck(*columns).each do |row|
30
+ # If pluck called with with a single argument
31
+ # it will return an array of sclars instead of array of arrays
32
+ row = [row] if columns.size == 1
33
+
34
+ key = if group_values.size == 0
35
+ :ALL
36
+ elsif group_values.size == 1
37
+ # If only one group is provided, the resulting key is just a scalar value
38
+ row.first
52
39
  else
53
- value = functions.keys.zip(row.last(functions.size)).to_h
40
+ # if multiple groups, the key will be an array.
41
+ row.first(group_values.size)
54
42
  end
55
43
 
56
- # Call the block for each group
57
- value = block.call(value) if block
44
+ value = value_mapping.transform_values { |index| row[index] }
58
45
 
59
- # Return unwrapped hash directly for scope without any .group()
60
- return value if group_values.empty?
46
+ value = value.values.last if return_plain_values
61
47
 
62
- # If only one group is provided, the resulting key is just the group name
63
- # if multiple group methods are provided, the key will be an array.
64
- if group_values.size == 1
65
- key = row.first
66
- else
67
- key = row.first(group_values.size)
48
+ results[key] = value
49
+ end
50
+
51
+ # Additional groupdate magic of filling empty periods with defaults
52
+ if defined?(Groupdate.process_result)
53
+ # Since that hash is the same instance for every backfilled row, at least
54
+ # freeze it to prevent surprize modifications across multiple rows in the calling code.
55
+ default_value = return_plain_values ? nil : {}.freeze
56
+ results = Groupdate.process_result(self, results, default_value: default_value)
57
+ end
58
+
59
+ if block
60
+ results.transform_values! do |value|
61
+ return_plain_values ? block.call(value) : block.call(**value)
68
62
  end
63
+ end
69
64
 
70
- # Set the value in the output array
71
- results[key] = value
65
+ # Return unwrapped hash directly for scope without any .group()
66
+ if group_values.empty?
67
+ results[:ALL]
68
+ else
69
+ results
70
+ end
71
+ end
72
+
73
+ # module just to not pollute namespace
74
+ module Helpers
75
+ module_function
76
+
77
+ # Convert shortcuts like :count_distinct_id to SQL aggregate functions like 'COUNT(DISTINCT ID)'
78
+ # If shortcut is actually one of the grouping expressions, just return it as-is.
79
+ def decode_expression_shortcut(shortcut, group_values = [])
80
+ case shortcut
81
+ when String
82
+ shortcut
83
+ when *group_values
84
+ shortcut
85
+ when :count
86
+ "COUNT(*)"
87
+ when /^(\w+)_distinct_count$/, /^count_distinct_(\w+)$/
88
+ "COUNT(DISTINCT #{$1})"
89
+ when /^(\w+)_(count|sum|max|min|avg)$/
90
+ "#{$2.upcase}(#{$1})"
91
+ when /^(count|sum|max|min|avg)_(\w+)$/
92
+ "#{$1.upcase}(#{$2})"
93
+ when /^(\w+)_average$/, /^average_(\w+)$/
94
+ "AVG(#{$1})"
95
+ when /^(\w+)_maximum$/, /^maximum_(\w+)$/
96
+ "MAX(#{$1})"
97
+ when /^(\w+)_minimum$/, /^minimum_(\w+)$/
98
+ "MIN(#{$1})"
99
+ else
100
+ raise ArgumentError, "Can't recognize expression shortcut #{key}"
101
+ end
72
102
  end
103
+ end
73
104
 
74
- # Return the output array
75
- results
105
+ module Querying
106
+ # @see CalculateAll#calculate_all
107
+ def calculate_all(*args, **kwargs, &block)
108
+ all.calculate_all(*args, **kwargs, &block)
109
+ end
76
110
  end
77
111
  end
78
112
 
79
- # Make the calculate_all method available for all ActiveRecord::Relations instances
80
- ActiveRecord::Relation.include CalculateAll
113
+ ActiveSupport.on_load(:active_record) do
114
+ # Make the calculate_all method available for all ActiveRecord::Relations instances
115
+ ActiveRecord::Relation.include CalculateAll
81
116
 
82
- # Make the calculate_all method available for all ActiveRecord::Base classes
83
- # You can for example call Orders.calculate_all(:count, :sum_cents)
84
- ActiveRecord::Base.extend CalculateAll::Querying
117
+ # Make the calculate_all method available for all ActiveRecord::Base classes
118
+ # You can for example call Orders.calculate_all(:count, :sum_cents)
119
+ ActiveRecord::Base.extend CalculateAll::Querying
85
120
 
86
- # A hack for groupdate since it checks if the calculate_all method is defined
87
- # on the ActiveRecord::Calculations module. It is never called but it is just
88
- # needed for the check.
89
- ActiveRecord::Calculations.include CalculateAll::Querying
121
+ # A hack for groupdate 3.0 since it checks if the calculate_all method is defined
122
+ # on the ActiveRecord::Calculations module. It is never called but it is just
123
+ # needed for the check.
124
+ ActiveRecord::Calculations.include CalculateAll::Querying
125
+ end
metadata CHANGED
@@ -1,113 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calculate-all
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - codesnik
8
- autorequire:
7
+ - Alexey Trofimenko
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2018-08-13 00:00:00.000000000 Z
10
+ date: 2025-05-18 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: activerecord
13
+ name: activesupport
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: 4.0.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
27
- - !ruby/object:Gem::Dependency
28
- name: bundler
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '1.10'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '1.10'
41
- - !ruby/object:Gem::Dependency
42
- name: rake
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '10.0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '10.0'
55
- - !ruby/object:Gem::Dependency
56
- name: minitest
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: pg
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: mysql2
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
- - !ruby/object:Gem::Dependency
98
- name: groupdate
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
25
+ version: 4.0.0
111
26
  description: 'Extends Active Record with #calculate_all method'
112
27
  email:
113
28
  - aronaxis@gmail.com
@@ -115,26 +30,29 @@ executables: []
115
30
  extensions: []
116
31
  extra_rdoc_files: []
117
32
  files:
33
+ - ".github/workflows/build.yml"
118
34
  - ".gitignore"
119
- - ".travis.yml"
120
35
  - CHANGELOG.md
121
36
  - Gemfile
122
37
  - LICENSE.txt
123
38
  - README.md
124
39
  - Rakefile
125
40
  - _config.yml
126
- - bin/console
127
41
  - bin/setup
128
42
  - calculate-all.gemspec
43
+ - gemfiles/activerecord42.gemfile
44
+ - gemfiles/activerecord50.gemfile
45
+ - gemfiles/activerecord51.gemfile
46
+ - gemfiles/activerecord52.gemfile
47
+ - gemfiles/activerecord60.gemfile
48
+ - gemfiles/activerecord61.gemfile
49
+ - gemfiles/activerecord70.gemfile
129
50
  - lib/calculate-all.rb
130
- - lib/calculate-all/helpers.rb
131
- - lib/calculate-all/querying.rb
132
51
  - lib/calculate-all/version.rb
133
52
  homepage: http://github.com/codesnik/calculate-all
134
53
  licenses:
135
54
  - MIT
136
55
  metadata: {}
137
- post_install_message:
138
56
  rdoc_options: []
139
57
  require_paths:
140
58
  - lib
@@ -142,16 +60,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
142
60
  requirements:
143
61
  - - ">="
144
62
  - !ruby/object:Gem::Version
145
- version: '0'
63
+ version: 2.3.0
146
64
  required_rubygems_version: !ruby/object:Gem::Requirement
147
65
  requirements:
148
66
  - - ">="
149
67
  - !ruby/object:Gem::Version
150
68
  version: '0'
151
69
  requirements: []
152
- rubyforge_project:
153
- rubygems_version: 2.4.5.1
154
- signing_key:
70
+ rubygems_version: 3.6.2
155
71
  specification_version: 4
156
72
  summary: Fetch from database results of several aggregate functions at once
157
73
  test_files: []
data/.travis.yml DELETED
@@ -1,4 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - 2.2.0
4
- before_install: gem install bundler -v 1.10.6
data/bin/console DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require 'bundler/setup'
4
- require 'calculate-all'
5
-
6
- require './test/test_helper'
7
- require 'logger'
8
- ActiveRecord::Base.logger = Logger.new(STDERR)
9
- require 'pry'
10
- Pry.start
@@ -1,32 +0,0 @@
1
- module CalculateAll
2
- module Helpers
3
- module_function
4
- # Method to convert function aliases like :count to SQL commands like 'COUNT(*)'
5
- def decode_function_aliases(aliases)
6
- aliases.map do |key|
7
- function =
8
- case key
9
- when String
10
- key
11
- when :count
12
- 'COUNT(*)'
13
- when /^(.*)_distinct_count$/, /^count_distinct_(.*)$/
14
- "COUNT(DISTINCT #{$1})"
15
- when /^(.*)_(count|sum|max|min|avg)$/
16
- "#{$2.upcase}(#{$1})"
17
- when /^(count|sum|max|min|avg)_(.*)$$/
18
- "#{$1.upcase}(#{$2})"
19
- when /^(.*)_average$/, /^average_(.*)$/
20
- "AVG(#{$1})"
21
- when /^(.*)_maximum$/, /^maximum_(.*)$/
22
- "MAX(#{$1})"
23
- when /^(.*)_minimum$/, /^minimum_(.*)$/
24
- "MIN(#{$1})"
25
- else
26
- raise ArgumentError, "Can't recognize function alias #{key}"
27
- end
28
- [key, function]
29
- end.to_h
30
- end
31
- end
32
- end
@@ -1,5 +0,0 @@
1
- module CalculateAll
2
- module Querying
3
- delegate :calculate_all, to: :all
4
- end
5
- end