Skip to content

Non-ActiveRecord paginator support #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ Content-Type: application/vnd.api+json

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

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).

Expand Down
1 change: 1 addition & 0 deletions jsonapi-utils.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'smart_rspec', '~> 0.1.6'
spec.add_development_dependency 'pry', '~> 0.10.3'
spec.add_development_dependency 'pry-byebug'
spec.add_development_dependency 'simplecov'
end
47 changes: 5 additions & 42 deletions lib/jsonapi/utils/support/pagination.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module JSONAPI
module Utils
module Support
module Pagination
autoload :RecordCounter, 'jsonapi/utils/support/pagination/record_counter'

RecordCountError = Class.new(ArgumentError)

# Apply proper pagination to the records.
Expand Down Expand Up @@ -150,11 +152,8 @@ def pagination_range
def count_records(records, options)
return options[:count].to_i if options[:count].is_a?(Numeric)

case records
when ActiveRecord::Relation then count_records_from_database(records, options)
when Array then records.length
else raise RecordCountError, "Can't count records with the given options"
end
records = apply_filter(records, options) if params[:filter].present?
RecordCounter.count( records, params, options )
end

# Count pages in order to build a proper pagination and to fill up the "page_count" response's member.
Expand All @@ -170,45 +169,9 @@ def page_count_for(record_count)
return 0 if record_count.to_i < 1

size = (page_params['size'] || page_params['limit']).to_i
size = JSONAPI.configuration.default_page_size unless size.nonzero?
size = JSONAPI.configuration.default_page_size if size.zero?
(record_count.to_f / size).ceil
end

# Count records from the datatase applying the given request filters
# and skipping things like eager loading, grouping and sorting.
#
# @param records [ActiveRecord::Relation, Array] collection of records
# e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }]
#
# @param options [Hash] JU's options
# e.g.: { resource: V2::UserResource, count: 100 }
#
# @return [Integer]
# e.g.: 42
#
# @api private
def count_records_from_database(records, options)
records = apply_filter(records, options) if params[:filter].present?
count = -> (records, except:) do
records.except(*except).count(distinct_count_sql(records))
end
count.(records, except: %i(includes group order))
rescue ActiveRecord::StatementInvalid
count.(records, except: %i(group order))
end

# Build the SQL distinct count with some reflection on the "records" object.
#
# @param records [ActiveRecord::Relation] collection of records
# e.g.: User.all
#
# @return [String]
# e.g.: "DISTINCT users.id"
#
# @api private
def distinct_count_sql(records)
"DISTINCT #{records.table_name}.#{records.primary_key}"
end
end
end
end
Expand Down
133 changes: 133 additions & 0 deletions lib/jsonapi/utils/support/pagination/record_counter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
module JSONAPI
module Utils
module Support
module Pagination
module RecordCounter
# mapping of record collenction types to classes responsible for counting them
#
# @api private
@counter_mappings = {}

class << self

# Add a new counter class to the mappings hash
#
# @param counter_class [ < BaseCounter] class to register with RecordCounter
# e.g.: ActiveRecordCounter
#
# @api public
def add(counter_class)
@counter_mappings ||= {}
@counter_mappings[counter_class.type] = counter_class
end

# Execute the appropriate counting call for a collection with controller params and opts
#
# @param records [ActiveRecord::Relation, Array] collection of records
# e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }]
#
# @param params [Hash] Rails params
#
# @param options [Hash] JU's options
# e.g.: { resource: V2::UserResource, count: 100 }
#
# @return [Integer]
# e.g.: 42
#
#@api public
def count(records, params = {}, options = {})
# Go through the counter types to see if there's a counter class that
# knows how to handle the current record set type
@counter_mappings.each do |counted_class, counter_class|
if records.is_a? counted_class
# counter class found; execute the call
return counter_class.new(records, params, options).count
end
end

raise RecordCountError, "Can't count records with the given options"
end
end

class BaseCounter
attr_accessor :records, :params, :options

def initialize(records, params = {}, options = {})
@records = records
@params = params
@options = options
end

class << self

attr_accessor :type


# Register the class with RecordCounter to let it know that this class
# is responsible for counting the type
#
# @param @type: [String] snake_cased modultarized name of the record type the
# counter class is responsible for handling
# e.g.: 'arcive_record/relation'
#
# @api public
def counts(type)
self.type = type.camelize.constantize
Rails.logger.info "Registered #{self} to count #{type.camelize}" if Rails.logger.present?
RecordCounter.add self
rescue NameError
Rails.logger.warn "Unable to register #{self}: uninitialized constant #{type.camelize}" if Rails.logger.present?
end
end
end

class ArrayCounter < BaseCounter
counts "array"

delegate :count, to: :records
end


class ActiveRecordCounter < BaseCounter
counts "active_record/relation"

# Count records from the datatase applying the given request filters
# and skipping things like eager loading, grouping and sorting.
#
# @param records [ActiveRecord::Relation, Array] collection of records
# e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }]
#
# @param options [Hash] JU's options
# e.g.: { resource: V2::UserResource, count: 100 }
#
# @return [Integer]
# e.g.: 42
#
# @api public
def count
count = -> (records, except:) do
records.except(*except).count(distinct_count_sql(records))
end
count.(@records, except: %i(includes group order))
rescue ActiveRecord::StatementInvalid
count.(@records, except: %i(group order))
end

# Build the SQL distinct count with some reflection on the "records" object.
#
# @param records [ActiveRecord::Relation] collection of records
# e.g.: User.all
#
# @return [String]
# e.g.: "DISTINCT users.id"
#
# @api private
def distinct_count_sql(records)
"DISTINCT #{records.table_name}.#{records.primary_key}"
end
end
end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/jsonapi/utils/support/sort.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ def apply_sort(records)
records.sort { |a, b| comp = 0; eval(sort_criteria) }
elsif records.respond_to?(:order)
records.order(sort_params)
else
records
end
end

Expand Down
54 changes: 49 additions & 5 deletions spec/jsonapi/utils/support/pagination_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
let(:records) { User.all.to_a }

it 'applies memoization on the record count' do
expect(records).to receive(:length).and_return(records.length).once
expect(records).to receive(:count).and_return(records.count).once
2.times { subject.record_count_for(records, options) }
end
end
Expand Down Expand Up @@ -55,7 +55,7 @@

context 'with array' do
let(:records) { User.all.to_a }
let(:count) { records.length }
let(:count) { records.count }
it_behaves_like 'counting records'
end

Expand Down Expand Up @@ -113,8 +113,13 @@
it_behaves_like 'counting pages'
end
end
end

describe '#count_records_from_database' do
describe JSONAPI::Utils::Support::Pagination::RecordCounter::ActiveRecordCounter do
let(:options) { {} }

subject { described_class.new( records, options ) }
describe '#count' do
shared_examples_for 'skipping eager load SQL when counting records' do
it 'skips any eager load for the SQL count query (default)' do
expect(records).to receive(:except)
Expand All @@ -126,7 +131,7 @@
.and_return(User.all)
.exactly(0)
.times
subject.send(:count_records_from_database, records, options)
subject.send(:count)
end
end

Expand All @@ -152,7 +157,7 @@
.with(:group, :order)
.and_return(User.all)
.once
subject.send(:count_records_from_database, records, options)
subject.send(:count)
end
end
end
Expand All @@ -165,3 +170,42 @@
end
end
end

describe JSONAPI::Utils::Support::Pagination::RecordCounter do

describe '#add' do
context 'when adding an unusable counter type' do
it "doesn't explode" do
expect{ described_class.add( BogusCounter ) }.to_not raise_error( )
end
end

context 'when adding good counter type' do
subject { described_class.add( StringCounter ) }
it 'should add it' do
expect{ subject }.to_not( raise_error )
end
it 'should count' do
expect( described_class.send( :count, "lol" ) ).to eq( 3 )
end
end
end
describe "#count" do
context "when params are present" do
let( :params ){ { a: :b } }
it "passes them into the counters" do
described_class.add HashParamCounter

expect( described_class.send( :count, {}, params, {} ) ).to eq( { a: :b } )
end
end
context "when options are present" do
let( :options ) { { a: :b } }
it "passes them into the counters" do
described_class.add HashOptionsCounter

expect( described_class.send( :count, {}, {}, options ) ).to eq( { a: :b } )
end
end
end
end
2 changes: 2 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
require 'smart_rspec'
require 'factory_girl'
require 'support/helpers'
require 'simplecov'
SimpleCov.start

RSpec.configure do |config|
config.include FactoryGirl::Syntax::Methods
Expand Down
29 changes: 29 additions & 0 deletions spec/support/paginators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,32 @@ def pagination_range(page_params)
offset..offset + limit - 1
end
end


class BogusCounter < JSONAPI::Utils::Support::Pagination::RecordCounter::BaseCounter
counts "junk"
end

class StringCounter < JSONAPI::Utils::Support::Pagination::RecordCounter::BaseCounter
counts "string"

def count
@records.length
end
end

class HashParamCounter < JSONAPI::Utils::Support::Pagination::RecordCounter::BaseCounter
counts "Hash"

def count
@params
end
end

class HashOptionsCounter < JSONAPI::Utils::Support::Pagination::RecordCounter::BaseCounter
counts "Hash"

def count
@options
end
end