Skip to content

Commit a925eff

Browse files
authored
Merge pull request cerebris#1183 from hatchloyalty/pr-0-9-3-filter-included-resources
Implement Included Resource Filtering
2 parents 0672e28 + 10b4bf8 commit a925eff

File tree

12 files changed

+339
-14
lines changed

12 files changed

+339
-14
lines changed

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: 29 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,33 @@ 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+
unless relationship
261+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
262+
end
263+
264+
unless relationship.resource_klass._allowed_filter?(filter_method)
265+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
266+
end
267+
268+
unless @include_directives.include_config(relationship.name.to_sym).present?
269+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
270+
end
271+
272+
verified_filter = relationship.resource_klass.verify_filters(filter_method => value)
273+
@include_directives.merge_filter(relationship.name, verified_filter)
274+
next
255275
else
256-
fail JSONAPI::Exceptions::FilterNotAllowed.new(filter)
276+
unless resource_klass._allowed_filter?(filter_method)
277+
return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors)
278+
end
279+
280+
@filters[filter_method] = value
257281
end
258282
end
259283
end

lib/jsonapi/resource.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,9 +732,30 @@ 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+
# Don't try to merge where clauses when relation isn't already being joined to query.
745+
next memo unless config[:include_in_join]
746+
747+
filters = config[:include_filters]
748+
next memo unless filters
749+
750+
rel_records = filtering_resource.apply_filters(filtering_resource.records(options), filters, options).references(relationship_name)
751+
memo.merge(rel_records)
752+
end
753+
end
754+
735755
def filter_records(filters, options, records = records(options))
736756
records = apply_filters(records, filters, options)
737-
apply_includes(records, options)
757+
records = apply_includes(records, options)
758+
apply_included_resources_filters(records, options)
738759
end
739760

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

lib/jsonapi/resource_serializer.rb

Lines changed: 6 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,10 @@ def to_many_linkage(source, relationship)
410411
end
411412
end
412413
else
413-
source.public_send(relationship.name).map do |value|
414+
include_config = include_directives.include_config(relationship.name.to_sym) if include_directives
415+
include_filters = include_config[:include_filters] if include_config
416+
options = { filters: include_filters || {} }
417+
source.public_send(relationship.name, options).map do |value|
414418
[relationship.type, value.id]
415419
end
416420
end

test/controllers/controller_test.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,22 @@ def test_show_single_with_includes
509509
assert_equal 2, json_response['included'].size
510510
end
511511

512+
def test_show_with_filtered_includes_when_not_eager_loaded
513+
# tags are not eagerly loaded on a post but may still be filtered
514+
get :show, params: { id: '1', include: 'tags', filter: { 'tags.name' => ['whiny'] } }
515+
assert_response :success
516+
assert_equal 1, json_response['included'].size
517+
end
518+
519+
def test_show_with_filtered_includes_when_not_eager_loaded_and_all_filtered_out
520+
get :show, params: { id: '1', include: 'tags', filter: { 'tags.name' => ['no-tag-with-this-name'] } }
521+
assert_response :success
522+
assert json_response['data'].is_a?(Hash)
523+
assert_equal 'New post', json_response['data']['attributes']['title']
524+
assert_equal 'A body!!!', json_response['data']['attributes']['body']
525+
assert_nil json_response['included']
526+
end
527+
512528
def test_show_single_with_include_disallowed
513529
JSONAPI.configuration.allow_include = false
514530
assert_cacheable_get :show, params: {id: '1', include: 'comments'}
@@ -2577,6 +2593,50 @@ def test_index_with_caching_enabled_uses_context
25772593
end
25782594
end
25792595

2596+
class Api::V5::PaintersControllerTest < ActionController::TestCase
2597+
def test_index_with_included_resources_with_filters
2598+
# There are two painters, but by filtering the included relationship, the
2599+
# painters are limited due to the join, thus only the painter with oil
2600+
# paintings is returned.
2601+
get :index, params: { include: 'paintings', filter: { 'paintings.category' => 'oil' } }
2602+
assert_response :success
2603+
assert_equal 1, json_response['data'].size, 'Size of data is wrong'
2604+
assert_equal '1', json_response['data'][0]['id']
2605+
assert_equal 2, json_response['included'].size, 'Size of included data is wrong'
2606+
assert_equal '4', json_response['included'][0]['id']
2607+
assert_equal '5', json_response['included'][1]['id']
2608+
end
2609+
2610+
def test_index_with_filters_and_included_resources_with_filters
2611+
get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil' } }
2612+
2613+
assert_response :success
2614+
assert_equal 1, json_response['data'].size
2615+
assert_equal '1', json_response['data'][0]['id']
2616+
assert_equal 2, json_response['included'].size
2617+
assert_equal '4', json_response['included'][0]['id']
2618+
end
2619+
2620+
def test_index_with_filters_and_included_resources_with_multiple_filters
2621+
# 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.
2622+
get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil', 'paintings.title' => 'Motherhood' } }
2623+
2624+
assert_response :success
2625+
assert_equal 1, json_response['data'].size
2626+
assert_equal '1', json_response['data'][0]['id']
2627+
assert_equal 1, json_response['included'].size
2628+
assert_equal '5', json_response['included'][0]['id']
2629+
end
2630+
2631+
def test_show_with_filters_and_included_resources_with_filters
2632+
get :show, params: { id: 1, include: 'paintings', filter: { 'paintings.category' => 'oil' } }
2633+
assert_response :success
2634+
assert_equal '1', json_response['data']['id']
2635+
assert_equal 2, json_response['included'].size
2636+
assert_equal '4', json_response['included'][0]['id']
2637+
end
2638+
end
2639+
25802640
class Api::V5::AuthorsControllerTest < ActionController::TestCase
25812641
def test_get_person_as_author
25822642
assert_cacheable_get :index, params: {filter: {id: '1'}}

test/fixtures/active_record.rb

Lines changed: 70 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
@@ -1022,6 +1055,7 @@ class TagResource < JSONAPI::Resource
10221055
attributes :name
10231056

10241057
has_many :posts
1058+
filter :name
10251059
# Not including the planets relationship so they don't get output
10261060
#has_many :planets
10271061
end
@@ -1610,6 +1644,42 @@ class AuthorDetailResource < JSONAPI::Resource
16101644
attributes :author_stuff
16111645
end
16121646

1647+
class PaintingResource < JSONAPI::Resource
1648+
model_name 'Painting'
1649+
attributes :title, :category, :collector_roster
1650+
has_one :painter
1651+
has_many :collectors
1652+
1653+
filter :title
1654+
filter :category
1655+
1656+
def collector_roster
1657+
collectors.map(&:name)
1658+
end
1659+
end
1660+
1661+
class CollectorResource < JSONAPI::Resource
1662+
attributes :name
1663+
has_one :painting
1664+
end
1665+
1666+
class PainterResource < JSONAPI::Resource
1667+
model_name 'Painter'
1668+
attributes :name
1669+
has_many :paintings
1670+
1671+
filter :name, apply: lambda { |records, value, options|
1672+
records.where('name LIKE ?', value)
1673+
}
1674+
1675+
def records_for(relation_name)
1676+
records = super(relation_name)
1677+
1678+
return records unless relation_name == :paintings
1679+
records.includes(:collectors)
1680+
end
1681+
end
1682+
16131683
class PersonResource < PersonResource; end
16141684
class PostResource < PostResource; end
16151685
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
@@ -327,7 +327,7 @@ class CatResource < JSONAPI::Resource
327327
namespace :v5 do
328328
jsonapi_resources :posts do
329329
end
330-
330+
jsonapi_resources :painters
331331
jsonapi_resources :authors
332332
jsonapi_resources :expense_entries
333333
jsonapi_resources :iso_currencies

0 commit comments

Comments
 (0)