diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..d48d370 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.6.9 diff --git a/lib/jsonapi/filtering.rb b/lib/jsonapi/filtering.rb index 70d47db..018fda6 100644 --- a/lib/jsonapi/filtering.rb +++ b/lib/jsonapi/filtering.rb @@ -8,16 +8,19 @@ module Filtering # # @param requested_field [String] the field to parse # @return [Array] with the fields and the predicate - def self.extract_attributes_and_predicates(requested_field) + def self.extract_attributes_and_predicates(requested_field, allowed_fields) predicates = [] field_name = requested_field.to_s.dup while Ransack::Predicate.detect_from_string(field_name).present? do + # break if we have an exect match with an allowed_fields + # we do not want to pick apart the string further + break if allowed_fields.include?(field_name) + predicate = Ransack::Predicate .detect_and_strip_from_string!(field_name) predicates << Ransack::Predicate.named(predicate) end - [field_name.split(/_and_|_or_/), predicates.reverse] end @@ -32,9 +35,11 @@ def self.extract_attributes_and_predicates(requested_field) # @param allowed_fields [Array] a list of allowed fields to be filtered # @param options [Hash] extra flags to enable/disable features # @return [ActiveRecord::Base] a collection of resources - def jsonapi_filter(resources, allowed_fields, options = {}) + def jsonapi_filter(resources, allowed_fields, *allowed_scopes, options) + options = options || {} + allowed_scopes = (allowed_scopes || []).flatten.map(&:to_s) allowed_fields = allowed_fields.map(&:to_s) - extracted_params = jsonapi_filter_params(allowed_fields) + extracted_params = jsonapi_filter_params(allowed_fields, allowed_scopes) extracted_params[:sorts] = jsonapi_sort_params(allowed_fields, options) resources = resources.ransack(extracted_params) block_given? ? yield(resources) : resources @@ -47,14 +52,14 @@ def jsonapi_filter(resources, allowed_fields, options = {}) # # @param allowed_fields [Array] a list of allowed fields to be filtered # @return [Hash] to be passed to [ActiveRecord::Base#order] - def jsonapi_filter_params(allowed_fields) + def jsonapi_filter_params(allowed_fields, allowed_scopes) filtered = {} requested = params[:filter] || {} allowed_fields = allowed_fields.map(&:to_s) requested.each_pair do |requested_field, to_filter| field_names, predicates = JSONAPI::Filtering - .extract_attributes_and_predicates(requested_field) + .extract_attributes_and_predicates(requested_field, allowed_fields) wants_array = predicates.any? && predicates.map(&:wants_array).any? @@ -62,6 +67,20 @@ def jsonapi_filter_params(allowed_fields) to_filter = to_filter.split(',') end + # filter by scopes expects an exact match + # with the `allowed_scopes`. Predicates can be a part of named scopes + # and should be handled first + # Make sure to move to the next after a match + # {"created_before"=>"2013-02-01"} + # {"created_before_gt"=>"2013-02-01"} + if allowed_scopes.include?(requested_field) + filtered[requested_field] = to_filter + next + end + + + # filter by attributes + # {"first_name_eq"=>"Beau"} if predicates.any? && (field_names - allowed_fields).empty? filtered[requested_field] = to_filter end @@ -88,7 +107,7 @@ def jsonapi_sort_params(allowed_fields, options = {}) end field_names, predicates = JSONAPI::Filtering - .extract_attributes_and_predicates(requested_field) + .extract_attributes_and_predicates(requested_field, allowed_fields) next unless (field_names - allowed_fields).empty? next if !options[:sort_with_expressions] && predicates.any? diff --git a/lib/jsonapi/rails.rb b/lib/jsonapi/rails.rb index 8d29d17..c221268 100644 --- a/lib/jsonapi/rails.rb +++ b/lib/jsonapi/rails.rb @@ -37,7 +37,7 @@ def self.install! # @return [NilClass] def self.add_errors_renderer! ActionController::Renderers.add(:jsonapi_errors) do |resource, options| - self.content_type ||= Mime[:jsonapi] + self.content_type = Mime[:jsonapi] if self.media_type.nil? many = JSONAPI::Rails.is_collection?(resource, options[:is_collection]) resource = [resource] unless many @@ -47,7 +47,7 @@ def self.add_errors_renderer! ) unless resource.is_a?(ActiveModel::Errors) errors = [] - model = resource.instance_variable_get('@base') + model = resource.instance_variable_get(:@base) if respond_to?(:jsonapi_serializer_class, true) model_serializer = jsonapi_serializer_class(model, false) @@ -90,7 +90,7 @@ def self.add_errors_renderer! # @return [NilClass] def self.add_renderer! ActionController::Renderers.add(:jsonapi) do |resource, options| - self.content_type ||= Mime[:jsonapi] + self.content_type = Mime[:jsonapi] if self.media_type.nil? JSONAPI_METHODS_MAPPING.to_a[0..1].each do |opt, method_name| next unless respond_to?(method_name, true) diff --git a/spec/dummy.rb b/spec/dummy.rb index 6f0c434..d273f81 100644 --- a/spec/dummy.rb +++ b/spec/dummy.rb @@ -18,6 +18,7 @@ create_table :users, force: true do |t| t.string :first_name t.string :last_name + t.integer :notes_count, default: 0 t.timestamps end @@ -31,6 +32,11 @@ class User < ActiveRecord::Base has_many :notes + scope :created_before, ->(date) { where('created_at < ?', date) } + + def self.ransackable_scopes(auth_object = nil) + %i(created_before) + end end class Note < ActiveRecord::Base @@ -83,11 +89,18 @@ class UsersController < ActionController::Base def index allowed_fields = [ :first_name, :last_name, :created_at, - :notes_created_at, :notes_quantity + :notes_created_at, :notes_quantity, + :notes_count + ] + allowed_scopes = [ + :created_before ] options = { sort_with_expressions: true } - jsonapi_filter(User.all, allowed_fields, options) do |filtered| + jsonapi_filter(User.all, + allowed_fields, + allowed_scopes, + options) do |filtered| result = filtered.result if params[:sort].to_s.include?('notes_quantity') diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index da3d0f5..38cf978 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -31,6 +31,7 @@ it do expect(response).to have_http_status(:unprocessable_entity) + expect(response.media_type).to eq('application/vnd.api+json') expect(response_json['errors'].size).to eq(1) expect(response_json['errors'][0]['status']).to eq('422') expect(response_json['errors'][0]['title']) diff --git a/spec/fetching_spec.rb b/spec/fetching_spec.rb index a5382e7..513f185 100644 --- a/spec/fetching_spec.rb +++ b/spec/fetching_spec.rb @@ -25,6 +25,7 @@ it do expect(response).to have_http_status(:ok) expect(response_json['data'].size).to eq(users.size) + expect(response.media_type).to eq('application/vnd.api+json') response_json['data'].each do |item| user = users.detect { |u| u.id == item['id'].to_i } diff --git a/spec/filtering_spec.rb b/spec/filtering_spec.rb index f3bd80a..ce43907 100644 --- a/spec/filtering_spec.rb +++ b/spec/filtering_spec.rb @@ -4,18 +4,31 @@ describe '#extract_attributes_and_predicate' do context 'mixed attributes (and/or)' do it 'extracts ANDs' do - attributes, predicates = JSONAPI::Filtering - .extract_attributes_and_predicates('attr1_and_attr2_eq') + attributes, predicates = + JSONAPI::Filtering.extract_attributes_and_predicates( + 'attr1_and_attr2_eq', + ['attr1', 'attr2'] + ) expect(attributes).to eq(['attr1', 'attr2']) expect(predicates.size).to eq(1) expect(predicates[0].name).to eq('eq') end end + context 'handle attributes with underscore in name' do + it 'detect _' do + attributes, predicates = JSONAPI::Filtering + .extract_attributes_and_predicates('notes_count_eq', ['notes_count']) + expect(attributes).to eq(['notes_count']) + expect(predicates.size).to eq(1) + expect(predicates[0].name).to eq('eq') + end + end + context 'mixed predicates' do it 'extracts in order' do attributes, predicates = JSONAPI::Filtering - .extract_attributes_and_predicates('attr1_sum_eq') + .extract_attributes_and_predicates('attr1_sum_eq', ['attr1']) expect(attributes).to eq(['attr1']) expect(predicates.size).to eq(2) expect(predicates[0].name).to eq('sum') @@ -85,6 +98,32 @@ end end + context 'returns users by counter_cache' do + let(:params) do + second_user.update(notes_count: 1) + { + filter: { notes_count_eq: 1 } + } + end + + it 'ensures ransack scopes work properly' do + ransack = User.ransack(notes_count_eq: 1) + expected_sql = 'SELECT "users".* FROM "users" WHERE '\ + '"users"."notes_count" = 1' + expect(ransack.result.to_sql).to eq(expected_sql) + end + + it do + expect(first_user.notes_count).to eq(0) + expect(second_user.notes_count).to eq(1) + expect(third_user.notes_count).to eq(0) + + expect(response).to have_http_status(:ok) + expect(response_json['data'].size).to eq(1) + expect(response_json['data'][0]).to have_id(second_user.id.to_s) + end + end + context 'returns sorted users by notes' do let(:params) do { sort: '-notes_created_at' } @@ -96,6 +135,29 @@ expect(response_json['data'][0]).to have_id(second_user.id.to_s) end end + + context 'returns users filtered by scope' do + let(:params) do + third_user.update(created_at: '2013-01-01') + + { + filter: { created_before: '2013-02-01' } + } + end + + it 'ensures ransack scopes are working properly' do + ransack = User.ransack({ created_before: '2013-02-01' }) + expected_sql = 'SELECT "users".* FROM "users" WHERE '\ + '(created_at < \'2013-02-01\')' + expect(ransack.result.to_sql).to eq(expected_sql) + end + + it 'should return only' do + expect(response).to have_http_status(:ok) + expect(response_json['data'].size).to eq(1) + expect(response_json['data'][0]).to have_id(third_user.id.to_s) + end + end end end end