diff --git a/README.md b/README.md index bc2a712..429025e 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/jsonapi-utils.gemspec b/jsonapi-utils.gemspec index a8c63d5..33bcebd 100644 --- a/jsonapi-utils.gemspec +++ b/jsonapi-utils.gemspec @@ -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 diff --git a/lib/jsonapi/utils/support/pagination.rb b/lib/jsonapi/utils/support/pagination.rb index c8f1eaf..925a9c8 100644 --- a/lib/jsonapi/utils/support/pagination.rb +++ b/lib/jsonapi/utils/support/pagination.rb @@ -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. @@ -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. @@ -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 diff --git a/lib/jsonapi/utils/support/pagination/record_counter.rb b/lib/jsonapi/utils/support/pagination/record_counter.rb new file mode 100644 index 0000000..6f5357e --- /dev/null +++ b/lib/jsonapi/utils/support/pagination/record_counter.rb @@ -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 \ No newline at end of file diff --git a/lib/jsonapi/utils/support/sort.rb b/lib/jsonapi/utils/support/sort.rb index 520e10a..abac2fb 100644 --- a/lib/jsonapi/utils/support/sort.rb +++ b/lib/jsonapi/utils/support/sort.rb @@ -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 diff --git a/spec/jsonapi/utils/support/pagination_spec.rb b/spec/jsonapi/utils/support/pagination_spec.rb index c1b2632..cb613b9 100644 --- a/spec/jsonapi/utils/support/pagination_spec.rb +++ b/spec/jsonapi/utils/support/pagination_spec.rb @@ -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 @@ -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 @@ -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) @@ -126,7 +131,7 @@ .and_return(User.all) .exactly(0) .times - subject.send(:count_records_from_database, records, options) + subject.send(:count) end end @@ -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 @@ -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 \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d6c565f..f4310ff 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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 diff --git a/spec/support/paginators.rb b/spec/support/paginators.rb index 608a4b4..e68c1dd 100644 --- a/spec/support/paginators.rb +++ b/spec/support/paginators.rb @@ -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 \ No newline at end of file