jpie 4.1.1.pre.202609280317 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +4 -1
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +10 -1
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +2 -20
- data/lib/json_api/controllers/concerns/resource_actions/include_scope_preloading.rb +181 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +2 -2
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +3 -4
- data/lib/json_api/controllers/concerns/resource_actions.rb +2 -0
- data/lib/json_api/serialization/include_filter_cache.rb +16 -0
- data/lib/json_api/support/collection_query.rb +3 -2
- data/lib/json_api/support/concerns/nested_filters.rb +9 -1
- data/lib/json_api/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0e43b1a16ff0ab1f8d1e2bed93eed667a7cdebbf83879dc7ba1da4cd4934eee
|
|
4
|
+
data.tar.gz: 3af66fff765a5f8b3efff0b4ca5866601c847e16c38492c08e6fd97b6eacbc95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d6d1da393d39f54ca154910a34b622bef89e8cf08aac4918fd9f796aeb38ba3e84b76afbf30b987fb7e85f7f7dfa53f0685fec274ce0bc9224ec52a7e0d4e42e
|
|
7
|
+
data.tar.gz: df49c567f4c64d98895ec528370591ff67760de7396a01dd15678af7a63d527d11e9bd11033ee90f76392dc48044749c8d6ec7c3019618f08b8ccdb06776dbd8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,31 @@ at release time.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.1] - 2026-10-10
|
|
11
|
+
|
|
10
12
|
### Fixed
|
|
11
13
|
|
|
14
|
+
- **Resolve a nested filter hop in the request namespace.** Filter validation
|
|
15
|
+
and `CollectionQuery` resolved each hop of a path such as
|
|
16
|
+
`filter[comments.author.email_eq]` from the model name alone. A namespaced
|
|
17
|
+
request then judged the path against the flat resources. A public namespace
|
|
18
|
+
accepted every filter and relationship its flat counterparts declare, and
|
|
19
|
+
refused the filters its own resources declare. Each hop now resolves through
|
|
20
|
+
`ResourceLoader.find_for_model(model, namespace:)`, the lookup an `?include=`
|
|
21
|
+
hop takes. A hop the namespace cannot answer is a 400 "Invalid Filter". A
|
|
22
|
+
flat request takes the same lookup, so a hop to an STI subclass now reaches
|
|
23
|
+
its base class's resource. Regression spec:
|
|
24
|
+
`nested_filter_namespace_bypass_spec.rb`.
|
|
25
|
+
|
|
26
|
+
- **Preload an `?include=` hop through the related resource's own scope.** The
|
|
27
|
+
tree preloaded through `acts_as_tenant` alone, so neither the related
|
|
28
|
+
resource's `records` scope nor the `authorization_scope` hook reached the
|
|
29
|
+
query, and `IncludeFilterCache` dropped the surplus rows afterwards. The gate
|
|
30
|
+
now sits in the query. A branch that narrows nowhere still reads in one call,
|
|
31
|
+
and every level it loads joins the set the branches after it match against, so
|
|
32
|
+
two include paths that converge on one association read it once. Specs:
|
|
33
|
+
`include_preload_applies_resource_scope_spec.rb` and
|
|
34
|
+
`include_converging_branches_spec.rb`.
|
|
12
35
|
- **Vet a partial where hash in the database, not in memory.**
|
|
13
36
|
`IncludeFilterCache#filter` matched sideloaded records against
|
|
14
37
|
`where_values_hash`. That hash holds only the conditions ActiveRecord
|
data/README.md
CHANGED
|
@@ -930,7 +930,10 @@ model is flat.
|
|
|
930
930
|
A namespace serves only what it declares. A namespaced request never resolves a
|
|
931
931
|
flat resource: a route whose namespace carries no resource fails at draw time
|
|
932
932
|
(the app does not boot), and an `?include=` hop the namespace cannot answer is
|
|
933
|
-
a 400 at validation, before anything loads.
|
|
933
|
+
a 400 at validation, before anything loads. A nested filter follows the same
|
|
934
|
+
rule. Each hop of `filter[comments.body_match]` resolves the namespace's
|
|
935
|
+
resource, and the filter passes only where that resource declares it. For a
|
|
936
|
+
namespaced model, both name
|
|
934
937
|
shapes answer — `Pub::ItemResource` and `Pub::DepotItemResource` for
|
|
935
938
|
`Depot::Item` — so the class a `MissingResourceClass` message names is always
|
|
936
939
|
a working declaration. To serve a flat resource inside a
|
|
@@ -45,12 +45,21 @@ module JSONAPI
|
|
|
45
45
|
|
|
46
46
|
def check_related_filter(parts, assoc)
|
|
47
47
|
rel_mod = assoc.klass
|
|
48
|
-
rel_res =
|
|
48
|
+
rel_res = filter_hop_resource(rel_mod)
|
|
49
49
|
return false unless rel_res
|
|
50
50
|
|
|
51
51
|
check_filter_parts(parts, rel_res, rel_mod)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The request namespace answers each hop, the lookup an include hop
|
|
55
|
+
# takes. A flat resource is another namespace's surface, with filters
|
|
56
|
+
# this one never declared.
|
|
57
|
+
def filter_hop_resource(model)
|
|
58
|
+
JSONAPI::ResourceLoader.find_for_model(model, namespace: jsonapi_namespace)
|
|
59
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
def filter_rel_allowed?(res_cls, rel)
|
|
55
64
|
permitted = res_cls.permitted_filters_through
|
|
56
65
|
permitted.include?(rel.to_sym) || permitted.map(&:to_s).include?(rel.to_s)
|
|
@@ -4,6 +4,7 @@ module JSONAPI
|
|
|
4
4
|
module ResourceActions
|
|
5
5
|
module IncludePreloading
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
|
+
include IncludeScopePreloading
|
|
7
8
|
|
|
8
9
|
private
|
|
9
10
|
|
|
@@ -18,25 +19,6 @@ module JSONAPI
|
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
# Preloads associations from the include param so the serializer avoids N+1 queries
|
|
22
|
-
# when walking include paths (association.loaded? is true). Used by both show and index.
|
|
23
|
-
def scope_with_includes(scope)
|
|
24
|
-
includes = parse_include_param
|
|
25
|
-
return scope unless includes.any?
|
|
26
|
-
return scope if scope.is_a?(Array)
|
|
27
|
-
|
|
28
|
-
# `preload`, never `includes`. `includes` resolves to one LEFT OUTER JOIN
|
|
29
|
-
# across the whole tree whenever the scope already answers
|
|
30
|
-
# `eager_loading?` with true, which one condition on an unjoined table is
|
|
31
|
-
# enough to cause. Every collection path in the tree then multiplies row
|
|
32
|
-
# for row: a measured page of 25 records loaded 75,712 rows and allocated
|
|
33
|
-
# 800MB, against 18MB for the same document through `preload`.
|
|
34
|
-
# Serialization filters on none of the included tables, so the tree never
|
|
35
|
-
# needs the join. A scope's own `includes` is untouched and still supplies
|
|
36
|
-
# whatever join its own conditions need.
|
|
37
|
-
scope.preload(includes_to_hash(includes))
|
|
38
|
-
end
|
|
39
|
-
|
|
40
22
|
def filter_includable(hash, klass)
|
|
41
23
|
hash.each_with_object({}) do |(key, value), filtered|
|
|
42
24
|
assoc = klass.reflect_on_association(key)
|
|
@@ -67,7 +49,7 @@ module JSONAPI
|
|
|
67
49
|
#
|
|
68
50
|
# The tree collapses shared prefixes, so a prefix is visited once however
|
|
69
51
|
# many paths run through it. Walking costs no association queries — every
|
|
70
|
-
# hop reads targets that `
|
|
52
|
+
# hop reads targets that `preload_include_tree` already loaded. The only
|
|
71
53
|
# queries are the batched resource-default preloads themselves, each of
|
|
72
54
|
# which replaces one query per record during serialization.
|
|
73
55
|
def preload_included_resource_associations(resources, includes)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
# Reads an include tree one hop at a time, each hop through the scope its own
|
|
6
|
+
# resource answers with, so a record that resource hides never loads.
|
|
7
|
+
module IncludeScopePreloading
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Preloads the include tree so the serializer walks it without an N+1, one
|
|
13
|
+
# hop at a time, each hop through the scope its own resource answers with.
|
|
14
|
+
# Rails takes no per-association scope on a whole-tree preload, so a single
|
|
15
|
+
# call reads every row the foreign key matches and leaves the narrowing to
|
|
16
|
+
# the serializer. Reading through the scope puts that gate in the query,
|
|
17
|
+
# where the database applies it once.
|
|
18
|
+
#
|
|
19
|
+
# `preload`, never `includes`. `includes` resolves to one LEFT OUTER JOIN
|
|
20
|
+
# across the whole tree whenever the scope already answers
|
|
21
|
+
# `eager_loading?` with true, which one condition on an unjoined table is
|
|
22
|
+
# enough to cause. Every collection path in the tree then multiplies row
|
|
23
|
+
# for row: a measured page of 25 records loaded 75,712 rows and allocated
|
|
24
|
+
# 800MB, against 18MB for the same document through `preload`. A scope's
|
|
25
|
+
# own `includes` is untouched and still supplies whatever join its own
|
|
26
|
+
# conditions need. Each hop being its own query is also what lets a hop
|
|
27
|
+
# narrow without dropping a parent.
|
|
28
|
+
def preload_include_tree(records, includes)
|
|
29
|
+
return if records.empty? || includes.empty?
|
|
30
|
+
|
|
31
|
+
tree = includes_to_hash(includes)
|
|
32
|
+
# Nothing in this tree narrows, so nothing needs its own read. One call
|
|
33
|
+
# reads the whole tree, which is what Rails batches best: it gathers the
|
|
34
|
+
# owners of a level across every branch and reads each association once.
|
|
35
|
+
# The hop walk cannot, because it holds one branch at a time, and two
|
|
36
|
+
# paths that converge on one association then read it twice.
|
|
37
|
+
return preload_association(records, tree, nil) unless tree_narrows?(model_class, tree)
|
|
38
|
+
|
|
39
|
+
preload_include_level(records, model_class, tree)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when any class the tree reaches answers with a scope that selects
|
|
43
|
+
# fewer rows than the association would. Costs no query: each answer is the
|
|
44
|
+
# entry the filter cache already builds per class.
|
|
45
|
+
def tree_narrows?(klass, tree)
|
|
46
|
+
tree.any? do |association_name, subtree|
|
|
47
|
+
reflection = klass.reflect_on_association(association_name)
|
|
48
|
+
next false unless reflection
|
|
49
|
+
next false if attachment_hop?(reflection)
|
|
50
|
+
|
|
51
|
+
hop_reads_through_scope?(reflection, subtree)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# This hop needs its own read when its own class narrows, or when something
|
|
56
|
+
# under it does and the walk has to reach that far to find it.
|
|
57
|
+
#
|
|
58
|
+
# A polymorphic hop names no class until its rows load, so no scope can
|
|
59
|
+
# reach its read. The serializer vets those rows, as it always has.
|
|
60
|
+
def hop_reads_through_scope?(reflection, subtree)
|
|
61
|
+
return false if reflection.polymorphic?
|
|
62
|
+
return true if include_scope_for(reflection.klass)
|
|
63
|
+
|
|
64
|
+
tree_narrows?(reflection.klass, subtree)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def preload_include_level(records, klass, tree)
|
|
68
|
+
tree.each do |association_name, subtree|
|
|
69
|
+
reflection = klass.reflect_on_association(association_name)
|
|
70
|
+
next unless reflection
|
|
71
|
+
|
|
72
|
+
preload_include_hop(records, association_name, subtree, reflection)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A branch that narrows nowhere reads whole, for the same reason the whole
|
|
77
|
+
# tree does when nothing in it narrows: one call batches the level and two
|
|
78
|
+
# paths that converge on one association read it once.
|
|
79
|
+
def preload_include_hop(records, association_name, subtree, reflection)
|
|
80
|
+
if attachment_hop?(reflection) || !hop_reads_through_scope?(reflection, subtree)
|
|
81
|
+
preload_association(records, { association_name => subtree }, nil)
|
|
82
|
+
return record_loaded_subtree(records, association_name, subtree)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
preload_scoped_hop(records, association_name, reflection)
|
|
86
|
+
targets = collect_include_targets(records, association_name)
|
|
87
|
+
return if targets.empty?
|
|
88
|
+
|
|
89
|
+
loaded_include_records.concat(targets)
|
|
90
|
+
descend_include_level(targets, reflection, subtree) unless subtree.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One call read every level of this branch, so every level joins the set the
|
|
94
|
+
# branches after it match against. Recording the first hop alone left the
|
|
95
|
+
# levels below it out, and a branch that converged there read them again.
|
|
96
|
+
def record_loaded_subtree(records, association_name, subtree)
|
|
97
|
+
targets = collect_include_targets(records, association_name)
|
|
98
|
+
return if targets.empty?
|
|
99
|
+
|
|
100
|
+
loaded_include_records.concat(targets)
|
|
101
|
+
return if subtree.empty?
|
|
102
|
+
|
|
103
|
+
targets.group_by(&:class).each do |target_class, group|
|
|
104
|
+
subtree.each do |child_name, child_subtree|
|
|
105
|
+
next unless target_class.reflect_on_association(child_name)
|
|
106
|
+
|
|
107
|
+
record_loaded_subtree(group, child_name, child_subtree)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A polymorphic hop has no single target class, so continue once per class
|
|
113
|
+
# actually present in the loaded targets.
|
|
114
|
+
def descend_include_level(targets, reflection, subtree)
|
|
115
|
+
if reflection.polymorphic?
|
|
116
|
+
targets.group_by(&:class).each { |target_class, recs| preload_include_level(recs, target_class, subtree) }
|
|
117
|
+
else
|
|
118
|
+
preload_include_level(targets, reflection.klass, subtree)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def preload_scoped_hop(records, association_name, reflection)
|
|
123
|
+
return preload_polymorphic_hop(records, association_name, reflection) if reflection.polymorphic?
|
|
124
|
+
|
|
125
|
+
preload_association(records, association_name, include_scope_for(reflection.klass))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One Preloader call carries one scope, and a polymorphic hop spans
|
|
129
|
+
# classes, so group the parents by the type they name and read each group
|
|
130
|
+
# through that class's own scope.
|
|
131
|
+
def preload_polymorphic_hop(records, association_name, reflection)
|
|
132
|
+
groups = records.group_by { |record| record.read_attribute(reflection.foreign_type) }
|
|
133
|
+
groups.each do |type_name, group|
|
|
134
|
+
next if type_name.blank?
|
|
135
|
+
|
|
136
|
+
preload_association(group, association_name, include_scope_for(type_name.constantize))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def preload_association(records, associations, scope)
|
|
141
|
+
ActiveRecord::Associations::Preloader.new(
|
|
142
|
+
records:, associations:, scope:, available_records: loaded_include_records,
|
|
143
|
+
).call
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Two include paths can converge on one association: `findings.samples.files`
|
|
147
|
+
# and `samples_direct.files` read the same files. Walking hop by hop, the
|
|
148
|
+
# second branch would read them again, because it holds its own instances of
|
|
149
|
+
# the rows the first branch already loaded. One whole-tree preload never did:
|
|
150
|
+
# it matched the rows it had. Hand the same set over and each row is read
|
|
151
|
+
# once, whichever branch reaches it first.
|
|
152
|
+
def loaded_include_records
|
|
153
|
+
@loaded_include_records ||= []
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# `filter_includable` rewrites an attachment include into the pair
|
|
157
|
+
# ActiveStorage defines for it. Those rows are jpie's own and answer to no
|
|
158
|
+
# resource, so the pair preloads whole and unscoped, the way the whole tree
|
|
159
|
+
# once did.
|
|
160
|
+
def attachment_hop?(reflection)
|
|
161
|
+
!reflection.polymorphic? && reflection.klass.name.start_with?("ActiveStorage::")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The resource's own scope, narrowed by the authorization hook. It comes
|
|
165
|
+
# from the request's filter cache, so the hook resolves once per class for
|
|
166
|
+
# the read and the vetting together.
|
|
167
|
+
def include_scope_for(klass)
|
|
168
|
+
include_filter_cache.authorized_scope_for(klass)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# One cache per request. The preloader reads each class's scope from it
|
|
172
|
+
# before the read, and the serializers read their verdicts from it after,
|
|
173
|
+
# so the authorization hook runs once for each included type.
|
|
174
|
+
def include_filter_cache
|
|
175
|
+
@include_filter_cache ||= JSONAPI::Serialization::IncludeFilterCache.new(
|
|
176
|
+
authorization_context: self, namespace: jsonapi_namespace,
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -37,8 +37,8 @@ module JSONAPI
|
|
|
37
37
|
return unless params[:id] && @resource_class
|
|
38
38
|
|
|
39
39
|
authorized = apply_authorization_scope(@resource_class.records, action: action_name.to_sym)
|
|
40
|
-
|
|
41
|
-
@resource
|
|
40
|
+
@resource = authorized.find(params[:id])
|
|
41
|
+
preload_include_tree([@resource], parse_include_param)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def render_record_not_found
|
|
@@ -9,7 +9,7 @@ module JSONAPI
|
|
|
9
9
|
def serialize_resource(resource)
|
|
10
10
|
includes = parse_include_param
|
|
11
11
|
# On show and update the record arrives through `load_resource_record`, which loaded
|
|
12
|
-
# the include tree via `
|
|
12
|
+
# the include tree via `preload_include_tree`. Each included resource's own `records`
|
|
13
13
|
# preloads still have to be applied, or every included record pays them one query at
|
|
14
14
|
# a time. On create the tree is unloaded, so this finds no targets and does nothing.
|
|
15
15
|
preload_included_resource_associations([resource], includes)
|
|
@@ -22,9 +22,9 @@ module JSONAPI
|
|
|
22
22
|
def serialize_collection(resources)
|
|
23
23
|
includes = parse_include_param
|
|
24
24
|
fields = parse_fields_param
|
|
25
|
-
resources = scope_with_includes(resources)
|
|
26
25
|
resources_array = resources.to_a
|
|
27
26
|
|
|
27
|
+
preload_include_tree(resources_array, includes)
|
|
28
28
|
preload_included_resource_associations(resources_array, includes)
|
|
29
29
|
|
|
30
30
|
data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
|
|
@@ -60,8 +60,7 @@ module JSONAPI
|
|
|
60
60
|
# every included record gets its scope verdict in one query per class,
|
|
61
61
|
# and the serializers answer their per-visit checks from memory.
|
|
62
62
|
def build_include_filter_cache(resources, includes)
|
|
63
|
-
cache =
|
|
64
|
-
namespace: jsonapi_namespace,)
|
|
63
|
+
cache = include_filter_cache
|
|
65
64
|
includes.each do |path|
|
|
66
65
|
current = resources
|
|
67
66
|
path.split(".").each do |part|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "resource_actions/filter_validation"
|
|
4
4
|
require_relative "resource_actions/field_validation"
|
|
5
5
|
require_relative "resource_actions/include_validation"
|
|
6
|
+
require_relative "resource_actions/include_scope_preloading"
|
|
6
7
|
require_relative "resource_actions/include_preloading"
|
|
7
8
|
require_relative "resource_actions/serialization"
|
|
8
9
|
require_relative "resource_actions/pagination"
|
|
@@ -104,6 +105,7 @@ module JSONAPI
|
|
|
104
105
|
scope,
|
|
105
106
|
definition: @resource_class,
|
|
106
107
|
model_class: model_class,
|
|
108
|
+
namespace: jsonapi_namespace,
|
|
107
109
|
filter_params: parse_filter_param,
|
|
108
110
|
sort_params: parse_sort_param,
|
|
109
111
|
page_params: parse_page_param,
|
|
@@ -36,6 +36,22 @@ module JSONAPI
|
|
|
36
36
|
records.select { |record| matches_where_hash?(record, entry.where_hash) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# The scope an include hop reads through, and nil when that scope narrows
|
|
40
|
+
# nothing. The preloader reads it from here so the hook resolves once per
|
|
41
|
+
# class per request, for the read and the vetting together.
|
|
42
|
+
#
|
|
43
|
+
# nil is what tells the walk that a hop needs no read of its own. A class
|
|
44
|
+
# whose resource adds no condition asks for every row the foreign key
|
|
45
|
+
# matches, which is what a plain preload already reads. The branch that hop
|
|
46
|
+
# sits on can then read in one call and keep the batching a whole-tree
|
|
47
|
+
# preload gives, instead of walking a level at a time for no gain.
|
|
48
|
+
def authorized_scope_for(klass)
|
|
49
|
+
entry = entry_for(klass)
|
|
50
|
+
return nil unless entry.narrowed || !entry.where_clause_empty
|
|
51
|
+
|
|
52
|
+
entry.authorized_scope
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
# Vets ids ahead of the walk, one query per class, so the per-visit
|
|
40
56
|
# checks answer from memory. Safe to skip: #filter queries lazily.
|
|
41
57
|
def warm(klass, ids)
|
|
@@ -17,10 +17,11 @@ module JSONAPI
|
|
|
17
17
|
|
|
18
18
|
attr_reader :scope, :total_count, :pagination_applied
|
|
19
19
|
|
|
20
|
-
def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:)
|
|
20
|
+
def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:, namespace: nil)
|
|
21
21
|
@scope = scope
|
|
22
22
|
@definition = definition
|
|
23
23
|
@model_class = model_class
|
|
24
|
+
@namespace = namespace
|
|
24
25
|
@filter_params = filter_params
|
|
25
26
|
@sort_params = sort_params
|
|
26
27
|
@page_params = page_params
|
|
@@ -38,7 +39,7 @@ module JSONAPI
|
|
|
38
39
|
|
|
39
40
|
private
|
|
40
41
|
|
|
41
|
-
attr_reader :definition, :model_class, :filter_params, :sort_params, :page_params
|
|
42
|
+
attr_reader :definition, :model_class, :namespace, :filter_params, :sort_params, :page_params
|
|
42
43
|
|
|
43
44
|
def compute_total_count_if_needed
|
|
44
45
|
return unless pagination_applied
|
|
@@ -54,12 +54,20 @@ module JSONAPI
|
|
|
54
54
|
early_return: true, }
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
next_definition =
|
|
57
|
+
next_definition = hop_definition(association.klass)
|
|
58
58
|
return { scope:, early_return: true } unless next_definition
|
|
59
59
|
|
|
60
60
|
{ model: association.klass, definition: next_definition, early_return: false }
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# The query's namespace answers each hop, as it does in filter
|
|
64
|
+
# validation. A hop it carries no resource for filters nothing.
|
|
65
|
+
def hop_definition(model)
|
|
66
|
+
JSONAPI::ResourceLoader.find_for_model(model, namespace:)
|
|
67
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
63
71
|
def apply_joined_filter(scope, result, relationship_chain:, leaf_filter:, filter_value:)
|
|
64
72
|
join_hash = build_join_hash_for_chain(relationship_chain)
|
|
65
73
|
scope = scope.joins(join_hash) if join_hash.present?
|
data/lib/json_api/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jpie
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.1.1
|
|
4
|
+
version: 4.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emil Kampp
|
|
@@ -120,6 +120,7 @@ files:
|
|
|
120
120
|
- lib/json_api/controllers/concerns/resource_actions/field_validation.rb
|
|
121
121
|
- lib/json_api/controllers/concerns/resource_actions/filter_validation.rb
|
|
122
122
|
- lib/json_api/controllers/concerns/resource_actions/include_preloading.rb
|
|
123
|
+
- lib/json_api/controllers/concerns/resource_actions/include_scope_preloading.rb
|
|
123
124
|
- lib/json_api/controllers/concerns/resource_actions/include_validation.rb
|
|
124
125
|
- lib/json_api/controllers/concerns/resource_actions/pagination.rb
|
|
125
126
|
- lib/json_api/controllers/concerns/resource_actions/resource_loading.rb
|