Skip to content

Commit b4ffd27

Browse files
author
Carl Thuringer
committed
Implement Included Resource Filtering
Inspirational Credit and original code: @beniutek
1 parent ec118b3 commit b4ffd27

12 files changed

Lines changed: 318 additions & 14 deletions

File tree

lib/jsonapi/include_directives.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ def paths
4040
delve_paths(get_includes(@include_directives_hash, false))
4141
end
4242

43+
def merge_filter(relation, filter)
44+
config = include_config(relation.to_sym)
45+
config[:include_filters] ||= {}
46+
config[:include_filters].merge!(filter)
47+
end
48+
49+
def include_config(relation)
50+
@include_directives_hash[:include_related][relation]
51+
end
52+
4353
private
4454

4555
def get_related(current_path)

lib/jsonapi/request_parser.rb

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def setup_get_related_resources_action(params)
7676
def setup_show_action(params)
7777
parse_fields(params[:fields])
7878
parse_include_directives(params[:include])
79+
parse_filters(params[:filter])
80+
7981
@id = params[:id]
8082
add_show_operation
8183
end
@@ -232,7 +234,7 @@ def parse_include_directives(raw_include)
232234
@include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, result)
233235
rescue JSONAPI::Exceptions::InvalidInclude => e
234236
@errors.concat(e.errors)
235-
@include_directives = {}
237+
@include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, [])
236238
end
237239
end
238240

@@ -249,11 +251,34 @@ def parse_filters(filters)
249251
end
250252

251253
filters.each do |key, value|
252-
filter = unformat_key(key)
253-
if @resource_klass._allowed_filter?(filter)
254-
@filters[filter] = value
254+
filter_method, included_resource_name =
255+
key.to_s.split('.').map { |k| unformat_key(k) }.reverse
256+
257+
if included_resource_name
258+
relationship = resource_klass._relationship(included_resource_name || '')
259+
260+
261+
unless relationship
262+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
263+
end
264+
265+
unless relationship.resource_klass._allowed_filter?(filter_method)
266+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
267+
end
268+
269+
unless @include_directives.model_includes.include?(relationship.name.to_sym)
270+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
271+
end
272+
273+
verified_filter = relationship.resource_klass.verify_filters(filter_method => value)
274+
@include_directives.merge_filter(relationship.name, verified_filter)
275+
next
255276
else
256-
fail JSONAPI::Exceptions::FilterNotAllowed.new(filter)
277+
unless resource_klass._allowed_filter?(filter_method)
278+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
279+
end
280+
281+
@filters[filter_method] = value
257282
end
258283
end
259284
end

lib/jsonapi/resource.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,9 +732,27 @@ def apply_filters(records, filters, options = {})
732732
records
733733
end
734734

735+
def apply_included_resources_filters(records, options = {})
736+
include_directives = options[:include_directives]
737+
return records unless include_directives
738+
related_directives = include_directives.include_directives.fetch(:include_related)
739+
related_directives.reduce(records) do |memo, (relationship_name, config)|
740+
relationship = _relationship(relationship_name)
741+
next memo unless relationship && relationship.is_a?(JSONAPI::Relationship::ToMany)
742+
filtering_resource = relationship.resource_klass
743+
744+
filters = config[:include_filters]
745+
next memo unless filters
746+
747+
rel_records = filtering_resource.apply_filters(filtering_resource.records(options), filters, options).references(relationship_name)
748+
memo.merge(rel_records)
749+
end
750+
end
751+
735752
def filter_records(filters, options, records = records(options))
736753
records = apply_filters(records, filters, options)
737-
apply_includes(records, options)
754+
records = apply_includes(records, options)
755+
apply_included_resources_filters(records, options)
738756
end
739757

740758
def sort_records(records, order_options, context = {})

lib/jsonapi/resource_serializer.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ def relationships_hash(source, fetchable_fields, include_directives = {})
287287
include_linkage = ia && ia[:include]
288288
include_linked_children = ia && !ia[:include_related].empty?
289289

290+
options = { filters: ia && ia[:include_filters] || {} }
290291
if field_set.include?(name)
291292
hash[format_key(name)] = link_object(source, relationship, include_linkage)
292293
end
@@ -298,7 +299,7 @@ def relationships_hash(source, fetchable_fields, include_directives = {})
298299
resources = if source.preloaded_fragments.has_key?(format_key(name))
299300
source.preloaded_fragments[format_key(name)].values
300301
else
301-
[source.public_send(name)].flatten(1).compact
302+
[source.public_send(name, options)].flatten(1).compact
302303
end
303304
resources.each do |resource|
304305
next if self_referential_and_already_in_source(resource)
@@ -410,7 +411,8 @@ def to_many_linkage(source, relationship)
410411
end
411412
end
412413
else
413-
source.public_send(relationship.name).map do |value|
414+
options = { filters: include_directives.include_directives.dig(:include_related, relationship.name.to_sym, :include_filters) || {} }
415+
source.public_send(relationship.name, options).map do |value|
414416
[relationship.type, value.id]
415417
end
416418
end

test/controllers/controller_test.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,6 +2577,50 @@ def test_index_with_caching_enabled_uses_context
25772577
end
25782578
end
25792579

2580+
class Api::V5::PaintersControllerTest < ActionController::TestCase
2581+
def test_index_with_included_resources_with_filters
2582+
# There are two painters, but by filtering the included relationship, the
2583+
# painters are limited due to the join, thus only the painter with oil
2584+
# paintings is returned.
2585+
get :index, params: { include: 'paintings', filter: { 'paintings.category' => 'oil' } }
2586+
assert_response :success
2587+
assert_equal 1, json_response['data'].size, 'Size of data is wrong'
2588+
assert_equal '1', json_response['data'][0]['id']
2589+
assert_equal 2, json_response['included'].size, 'Size of included data is wrong'
2590+
assert_equal '4', json_response['included'][0]['id']
2591+
assert_equal '5', json_response['included'][1]['id']
2592+
end
2593+
2594+
def test_index_with_filters_and_included_resources_with_filters
2595+
get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil' } }
2596+
2597+
assert_response :success
2598+
assert_equal 1, json_response['data'].size
2599+
assert_equal '1', json_response['data'][0]['id']
2600+
assert_equal 2, json_response['included'].size
2601+
assert_equal '4', json_response['included'][0]['id']
2602+
end
2603+
2604+
def test_index_with_filters_and_included_resources_with_multiple_filters
2605+
# Painting 5 is the genuine, but painting 6 is a fake. Verify that multiple nested filters are merged and only the oil painting is returned.
2606+
get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil', 'paintings.title' => 'Motherhood' } }
2607+
2608+
assert_response :success
2609+
assert_equal 1, json_response['data'].size
2610+
assert_equal '1', json_response['data'][0]['id']
2611+
assert_equal 1, json_response['included'].size
2612+
assert_equal '5', json_response['included'][0]['id']
2613+
end
2614+
2615+
def test_show_with_filters_and_included_resources_with_filters
2616+
get :show, params: { id: 1, include: 'paintings', filter: { 'paintings.category' => 'oil' } }
2617+
assert_response :success
2618+
assert_equal '1', json_response['data']['id']
2619+
assert_equal 2, json_response['included'].size
2620+
assert_equal '4', json_response['included'][0]['id']
2621+
end
2622+
end
2623+
25802624
class Api::V5::AuthorsControllerTest < ActionController::TestCase
25812625
def test_get_person_as_author
25822626
assert_cacheable_get :index, params: {filter: {id: '1'}}

test/fixtures/active_record.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,24 @@
310310
t.string :name
311311
end
312312

313+
create_table :painters, force: true do |t|
314+
t.string :name
315+
316+
t.timestamps null: false
317+
end
318+
319+
create_table :paintings, force: true do |t|
320+
t.string :title
321+
t.string :category
322+
t.belongs_to :painter
323+
324+
t.timestamps null: false
325+
end
326+
327+
create_table :collectors, force: true do |t|
328+
t.string :name
329+
t.belongs_to :painting
330+
end
313331
# special cases
314332
end
315333

@@ -655,6 +673,18 @@ class Customer < Customer
655673
end
656674
end
657675

676+
class Painter < ActiveRecord::Base
677+
has_many :paintings
678+
end
679+
680+
class Painting < ActiveRecord::Base
681+
belongs_to :painter
682+
has_many :collectors
683+
end
684+
685+
class Collector < ActiveRecord::Base
686+
belongs_to :painting
687+
end
658688
### CONTROLLERS
659689
class AuthorsController < JSONAPI::ResourceControllerMetal
660690
end
@@ -865,6 +895,9 @@ class ExpenseEntriesController < JSONAPI::ResourceController
865895

866896
class IsoCurrenciesController < JSONAPI::ResourceController
867897
end
898+
899+
class PaintersController < JSONAPI::ResourceController
900+
end
868901
end
869902

870903
module V6
@@ -1610,6 +1643,42 @@ class AuthorDetailResource < JSONAPI::Resource
16101643
attributes :author_stuff
16111644
end
16121645

1646+
class PaintingResource < JSONAPI::Resource
1647+
model_name 'Painting'
1648+
attributes :title, :category, :collector_roster
1649+
has_one :painter
1650+
has_many :collectors
1651+
1652+
filter :title
1653+
filter :category
1654+
1655+
def collector_roster
1656+
collectors.map(&:name)
1657+
end
1658+
end
1659+
1660+
class CollectorResource < JSONAPI::Resource
1661+
attributes :name
1662+
has_one :painting
1663+
end
1664+
1665+
class PainterResource < JSONAPI::Resource
1666+
model_name 'Painter'
1667+
attributes :name
1668+
has_many :paintings
1669+
1670+
filter :name, apply: lambda { |records, value, options|
1671+
records.where('name LIKE ?', value)
1672+
}
1673+
1674+
def records_for(relation_name)
1675+
records = super(relation_name)
1676+
1677+
return records unless relation_name == :paintings
1678+
records.includes(:collectors)
1679+
end
1680+
end
1681+
16131682
class PersonResource < PersonResource; end
16141683
class PostResource < PostResource; end
16151684
class TagResource < TagResource; end

test/fixtures/collectors.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
collector_1:
2+
id: 1
3+
name: "Alice"
4+
painting_id: 4
5+
6+
collector_2:
7+
id: 2
8+
name: "Bob"
9+
painting_id: 4

test/fixtures/painters.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
painter_1:
2+
id: 1
3+
name: "Wyspianski"
4+
5+
painter_2:
6+
id: 2
7+
name: "Matejko"

test/fixtures/paintings.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
painting_1:
2+
id: 1
3+
title: "Rejtan"
4+
category: "historic"
5+
painter_id: 2
6+
7+
painting_2:
8+
id: 2
9+
title: "Stanczyk"
10+
category: "fantasy"
11+
painter_id: 2
12+
13+
painting_3:
14+
id: 3
15+
title: "Macierzynstwo"
16+
category: "pastel"
17+
painter_id: 1
18+
19+
painting_4:
20+
id: 4
21+
title: "Helenka"
22+
category: "oil"
23+
painter_id: 1
24+
25+
painting_5:
26+
id: 5
27+
title: "Motherhood"
28+
category: "oil"
29+
painter_id: 1
30+
31+
painting_6:
32+
id: 6
33+
title: "Motherhood"
34+
category: "fake"
35+
painter_id: 1

test/test_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ class CatResource < JSONAPI::Resource
324324
namespace :v5 do
325325
jsonapi_resources :posts do
326326
end
327-
327+
jsonapi_resources :painters
328328
jsonapi_resources :authors
329329
jsonapi_resources :expense_entries
330330
jsonapi_resources :iso_currencies

0 commit comments

Comments
 (0)