Skip to content

Commit 437319d

Browse files
authored
Merge pull request #81 from tiagopog/improvement/record-count-refactoring
Improvement/record count refactoring
2 parents 9604c35 + 567ceda commit 437319d

File tree

13 files changed

+396
-107
lines changed

13 files changed

+396
-107
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ rvm:
66
- 2.3.3
77
matrix:
88
before_install: gem install bundler -v 1.13.6
9-
script: bundle exec rspec spec/controllers
9+
script: bundle exec rspec spec
1010

lib/jsonapi/utils/response/formatters.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def turn_into_resource(record, options)
171171
end
172172
end
173173

174-
# Apply some result options like pagination params and count to a collection response.
174+
# Apply some result options like pagination params and record count to collection responses.
175175
#
176176
# @param records [ActiveRecord::Relation, Hash, Array<Hash>]
177177
# Object to be formatted into JSON
@@ -192,7 +192,7 @@ def result_options(records, options)
192192
end
193193

194194
if JSONAPI.configuration.top_level_meta_include_record_count
195-
data[:record_count] = count_records(records, options)
195+
data[:record_count] = record_count_for(records, options)
196196
end
197197
end
198198
end

lib/jsonapi/utils/support/pagination.rb

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ module JSONAPI
22
module Utils
33
module Support
44
module Pagination
5+
RecordCountError = Class.new(ArgumentError)
6+
57
# Apply proper pagination to the records.
68
#
79
# @param records [ActiveRecord::Relation, Array] collection of records
@@ -33,7 +35,23 @@ def apply_pagination(records, options = {})
3335
# @api public
3436
def pagination_params(records, options)
3537
return {} unless JSONAPI.configuration.top_level_links_include_pagination
36-
paginator.links_page_params(record_count: count_records(records, options))
38+
paginator.links_page_params(record_count: record_count_for(records, options))
39+
end
40+
41+
# Apply memoization to the record count result avoiding duplicate counts.
42+
#
43+
# @param records [ActiveRecord::Relation, Array] collection of records
44+
# e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }]
45+
#
46+
# @param options [Hash] JU's options
47+
# e.g.: { resource: V2::UserResource, count: 100 }
48+
#
49+
# @return [Integer]
50+
# e.g.: 42
51+
#
52+
# @api public
53+
def record_count_for(records, options)
54+
@record_count ||= count_records(records, options)
3755
end
3856

3957
private
@@ -130,14 +148,49 @@ def pagination_range
130148
#
131149
# @api private
132150
def count_records(records, options)
133-
if options[:count].present?
134-
options[:count]
135-
elsif records.is_a?(Array)
136-
records.length
137-
else
138-
records = apply_filter(records, options) if params[:filter].present?
139-
records.except(:group, :order).count("DISTINCT #{records.table.name}.id")
151+
return options[:count].to_i if options[:count].is_a?(Numeric)
152+
153+
case records
154+
when ActiveRecord::Relation then count_records_from_database(records, options)
155+
when Array then records.length
156+
else raise RecordCountError, "Can't count records with the given options"
157+
end
158+
end
159+
160+
# Count records from the datatase applying the given request filters
161+
# and skipping things like eager loading, grouping and sorting.
162+
#
163+
# @param records [ActiveRecord::Relation, Array] collection of records
164+
# e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }]
165+
#
166+
# @param options [Hash] JU's options
167+
# e.g.: { resource: V2::UserResource, count: 100 }
168+
#
169+
# @return [Integer]
170+
# e.g.: 42
171+
#
172+
# @api private
173+
def count_records_from_database(records, options)
174+
records = apply_filter(records, options) if params[:filter].present?
175+
count = -> (records, except:) do
176+
records.except(*except).count(distinct_count_sql(records))
140177
end
178+
count.(records, except: %i(includes group order))
179+
rescue ActiveRecord::StatementInvalid
180+
count.(records, except: %i(group order))
181+
end
182+
183+
# Build the SQL distinct count with some reflection on the "records" object.
184+
#
185+
# @param records [ActiveRecord::Relation] collection of records
186+
# e.g.: User.all
187+
#
188+
# @return [String]
189+
# e.g.: "DISTINCT users.id"
190+
#
191+
# @api private
192+
def distinct_count_sql(records)
193+
"DISTINCT #{records.table_name}.#{records.primary_key}"
141194
end
142195
end
143196
end

lib/jsonapi/utils/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module JSONAPI
22
module Utils
3-
VERSION = '0.7.0'.freeze
3+
VERSION = '0.7.1'.freeze
44
end
55
end

spec/controllers/posts_controller_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
require 'spec_helper'
1+
require 'rails_helper'
22

33
describe PostsController, type: :controller do
44
include_context 'JSON API headers'

spec/controllers/profile_controller_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
require 'spec_helper'
1+
require 'rails_helper'
22

33
describe ProfileController, type: :controller do
44
include_context 'JSON API headers'

spec/controllers/users_controller_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
require 'spec_helper'
1+
require 'rails_helper'
22

33
describe UsersController, type: :controller do
44
include_context 'JSON API headers'

spec/features/record_count_spec.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
require 'rails_helper'
2+
3+
##
4+
# Configs
5+
##
6+
7+
# Resource
8+
class RecordCountTestResource < JSONAPI::Resource; end
9+
10+
# Controller
11+
class RecordCountTestController < BaseController
12+
def explicit_count
13+
jsonapi_render json: User.all, options: { count: 42, resource: UserResource }
14+
end
15+
16+
def array_count
17+
jsonapi_render json: User.all.to_a, options: { resource: UserResource }
18+
end
19+
20+
def active_record_count
21+
jsonapi_render json: User.all, options: { resource: UserResource }
22+
end
23+
24+
def active_record_count_with_eager_load
25+
users = User.all.includes(:posts)
26+
jsonapi_render json: users, options: { resource: UserResource }
27+
end
28+
29+
def active_record_count_with_eager_load_and_where_clause
30+
users = User.all.includes(:posts).where(posts: { id: Post.first.id })
31+
jsonapi_render json: users, options: { resource: UserResource }
32+
end
33+
end
34+
35+
# Routes
36+
def TestApp.draw_record_count_test_routes
37+
JSONAPI.configuration.json_key_format = :underscored_key
38+
39+
TestApp.routes.draw do
40+
controller :record_count_test do
41+
get :explicit_count
42+
get :array_count
43+
get :active_record_count
44+
get :active_record_count_with_eager_load
45+
get :active_record_count_with_eager_load_and_where_clause
46+
end
47+
end
48+
end
49+
50+
##
51+
# Feature tests
52+
##
53+
54+
describe RecordCountTestController, type: :controller do
55+
include_context 'JSON API headers'
56+
57+
before(:all) do
58+
TestApp.draw_record_count_test_routes
59+
FactoryGirl.create_list(:user, 3, :with_posts)
60+
end
61+
62+
describe 'explicit count' do
63+
it 'returns the count based on the passed "options"' do
64+
get :explicit_count
65+
expect(response).to have_meta_record_count(42)
66+
end
67+
end
68+
69+
describe 'array count' do
70+
it 'returns the count based on the array length' do
71+
get :array_count
72+
expect(response).to have_meta_record_count(User.count)
73+
end
74+
end
75+
76+
describe 'active record count' do
77+
it 'returns the count based on the AR\'s query result' do
78+
get :active_record_count
79+
expect(response).to have_meta_record_count(User.count)
80+
end
81+
end
82+
83+
describe 'active record count with eager load' do
84+
it 'returns the count based on the AR\'s query result' do
85+
get :active_record_count_with_eager_load
86+
expect(response).to have_meta_record_count(User.count)
87+
end
88+
end
89+
90+
describe 'active record count with eager load and where clause' do
91+
it 'returns the count based on the AR\'s query result' do
92+
get :active_record_count_with_eager_load_and_where_clause
93+
count = User.joins(:posts).where(posts: { id: Post.first.id }).count
94+
expect(response).to have_meta_record_count(count)
95+
end
96+
end
97+
end
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
require 'rails_helper'
2+
3+
describe JSONAPI::Utils::Support::Pagination do
4+
subject do
5+
OpenStruct.new(params: {}).extend(JSONAPI::Utils::Support::Pagination)
6+
end
7+
8+
before(:all) do
9+
FactoryGirl.create_list(:user, 2)
10+
end
11+
12+
let(:options) { {} }
13+
14+
##
15+
# Public API
16+
##
17+
18+
describe '#record_count_for' do
19+
context 'with array' do
20+
let(:records) { User.all.to_a }
21+
22+
it 'applies memoization on the record count' do
23+
expect(records).to receive(:length).and_return(records.length).once
24+
2.times { subject.record_count_for(records, options) }
25+
end
26+
end
27+
28+
context 'with ActiveRecord object' do
29+
let(:records) { User.all }
30+
31+
it 'applies memoization on the record count' do
32+
expect(records).to receive(:except).and_return(records).once
33+
2.times { subject.record_count_for(records, options) }
34+
end
35+
end
36+
end
37+
38+
##
39+
# Private API
40+
##
41+
42+
describe '#count_records' do
43+
shared_examples_for 'counting records' do
44+
it 'counts records' do
45+
expect(subject.send(:count_records, records, options)).to eq(count)
46+
end
47+
end
48+
49+
context 'with count present within the options' do
50+
let(:records) { User.all }
51+
let(:options) { { count: 999 } }
52+
let(:count) { 999 }
53+
it_behaves_like 'counting records'
54+
end
55+
56+
context 'with array' do
57+
let(:records) { User.all.to_a }
58+
let(:count) { records.length }
59+
it_behaves_like 'counting records'
60+
end
61+
62+
context 'with ActiveRecord object' do
63+
let(:records) { User.all }
64+
let(:count) { records.count }
65+
it_behaves_like 'counting records'
66+
end
67+
68+
context 'when no strategy can be applied' do
69+
let(:records) { Object.new }
70+
let(:count) { }
71+
72+
it 'raises an error' do
73+
expect {
74+
subject.send(:count_records, records, options)
75+
}.to raise_error(JSONAPI::Utils::Support::Pagination::RecordCountError)
76+
end
77+
end
78+
end
79+
80+
describe '#count_records_from_database' do
81+
shared_examples_for 'skipping eager load SQL when counting records' do
82+
it 'skips any eager load for the SQL count query (default)' do
83+
expect(records).to receive(:except)
84+
.with(:includes, :group, :order)
85+
.and_return(User.all)
86+
.once
87+
expect(records).to receive(:except)
88+
.with(:group, :order)
89+
.and_return(User.all)
90+
.exactly(0)
91+
.times
92+
subject.send(:count_records_from_database, records, options)
93+
end
94+
end
95+
96+
context 'when not eager loading records' do
97+
let(:records) { User.all }
98+
it_behaves_like 'skipping eager load SQL when counting records'
99+
end
100+
101+
context 'when eager loading records' do
102+
let(:records) { User.includes(:posts) }
103+
it_behaves_like 'skipping eager load SQL when counting records'
104+
end
105+
106+
context 'when eager loading records and using where clause on associations' do
107+
let(:records) { User.includes(:posts).where(posts: { id: 1 }) }
108+
109+
it 'fallbacks to the SQL count query with eager load' do
110+
expect(records).to receive(:except)
111+
.with(:includes, :group, :order)
112+
.and_raise(ActiveRecord::StatementInvalid)
113+
.once
114+
expect(records).to receive(:except)
115+
.with(:group, :order)
116+
.and_return(User.all)
117+
.once
118+
subject.send(:count_records_from_database, records, options)
119+
end
120+
end
121+
end
122+
123+
describe '#distinct_count_sql' do
124+
let(:records) { OpenStruct.new(table_name: 'foos', primary_key: 'id') }
125+
126+
it 'builds the distinct count SQL query' do
127+
expect(subject.send(:distinct_count_sql, records)).to eq('DISTINCT foos.id')
128+
end
129+
end
130+
end

spec/rails_helper.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require 'spec_helper'
2+
3+
require 'rails/all'
4+
require 'rails/test_help'
5+
require 'rspec/rails'
6+
7+
require 'jsonapi-resources'
8+
require 'jsonapi/utils'
9+
10+
require 'support/models'
11+
require 'support/factories'
12+
require 'support/resources'
13+
require 'support/controllers'
14+
require 'support/paginators'
15+
16+
require 'support/shared/jsonapi_errors'
17+
require 'support/shared/jsonapi_request'
18+
19+
require 'test_app'
20+
21+
RSpec.configure do |config|
22+
config.before(:all) do
23+
TestApp.draw_app_routes
24+
25+
%w[posts categories profiles users].each do |table_name|
26+
ActiveRecord::Base.connection.execute("DELETE FROM #{table_name}; VACUUM;")
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)