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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89f25c649293b22832412f92bcf29361b881deaf953d6b2db661736e226abd8d
4
- data.tar.gz: 4256bc8778bf8968c852fb901f753fe433b3ed21776a02ea2445f86f179a05c2
3
+ metadata.gz: f0e43b1a16ff0ab1f8d1e2bed93eed667a7cdebbf83879dc7ba1da4cd4934eee
4
+ data.tar.gz: 3af66fff765a5f8b3efff0b4ca5866601c847e16c38492c08e6fd97b6eacbc95
5
5
  SHA512:
6
- metadata.gz: 5384c6655800b556886f7ba54510c982fb2432fdcdde2439c1ce90f5d77bd10de6094973e0f6bed8c01f22a32c064de5c255b133c87c2395cbc4c12f9948e9b4
7
- data.tar.gz: 9b72004eb39131a37d9e289e9c0103f88f17ebffd48df26a5ff0ee93337af855e7932c1965468a2d8ca0a4300180dba6e37897964cc124e26d2076cfe2aff536
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. For a namespaced model, both name
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 = JSONAPI::Resource.resource_for_model(rel_mod)
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 `scope_with_includes` already loaded. The only
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
- scope = scope_with_includes(authorized)
41
- @resource = scope.find(params[:id])
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 `scope_with_includes`. Each included resource's own `records`
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 = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self,
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 = JSONAPI::Resource.resource_for_model(association.klass)
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "4.1.1.pre.202609280317"
4
+ VERSION = "4.1.1"
5
5
  end
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.pre.202609280317
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