jpie 3.8.1 → 3.8.3

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: d9e7099c27c6aec0de41a095baa563dbc63eb934b44ab8195a9e3a51f5152c4e
4
- data.tar.gz: 86ee7fbdc3cf8414d9829bbf4f42ac7e016603f8a884a0b674d8e9b869cf1b8d
3
+ metadata.gz: 5fee8d7ae2ce0016c8c6b1d0740cf9448d7b932c9d71fc0f74a553ae5bd15d17
4
+ data.tar.gz: 8d8151d6323ad4c3538abb0bb95f23347162d047006af7c9e7d03da638d4f955
5
5
  SHA512:
6
- metadata.gz: a013761543aba53b56e575f8b2f51d3004d1cae53e53570bf506bd744cb2d38891e43a5188ad11ee338923aacdd5c8bc4cf107e1772f5cb73173aafb590d1859
7
- data.tar.gz: 105cbbcb454083ebbfae7db54d9fbec5e76c39d3c1d1823c16b34de071abeae47e4cdf5923aadeb3224e609ee85c07712ed37651caa1122719501ac1c479ae07
6
+ metadata.gz: e8e86875e6b6c582640e9f7c03355a8dd271ecab3c1e96186a304f0927b28cccda7fb5009f013b37bf0bd5c778c280e29a03b765033c2e7fe1babba2bd24576a
7
+ data.tar.gz: 44feac1f5b20d4cb6c95f05773e06fefedb7cea9b254fb55a98af26a0b58f5dec60dae425f55bda8c10185f57099c7bd2450729a940c7a31eab8636acbdc1348
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (3.8.1)
4
+ jpie (3.8.3)
5
5
  actionpack (~> 8.1, >= 8.1.0)
6
6
  pg_query (>= 4)
7
7
  prosopite (>= 1)
@@ -5,9 +5,12 @@ problem: controller actions with large `include=` trees fire hundreds to
5
5
  thousands of SQL queries and allocate hundreds of MB per request. Production
6
6
  examples: `/workstreams` with 25 include paths (~300 queries at page 10, up to
7
7
  `page[size]=100`), `/policies` with 17 include paths at `page[size]=1` (kills
8
- the app on AI-conversation data; one index request allocated ~600MB), and
8
+ the app on AI-conversation data; one index request allocated ~600MB),
9
9
  `/evidence_checks` with a 45-id batch filter and files at depth 3 (~1,250
10
- queries).
10
+ queries), `/incidents` with 18 include paths at page 20 (577 ActiveRecord
11
+ calls, 2,794ms), and `/suggested_risk_scenarios` with only two include paths
12
+ (541 ActiveRecord calls, 2,059ms). The last two fail for opposite reasons; see
13
+ "Two shapes, two causes" below.
11
14
 
12
15
  The numbers come from the benchmark suite in `spec/benchmarks/`. The evaluation
13
16
  behind them ran 32 independent code-path analyses; a second wave verified the
@@ -26,15 +29,19 @@ shapes, wall time, allocated/retained bytes, allocation sites, RSS). Diff that
26
29
  file against this baseline after any optimization. `DEBUG_QUERIES=1` also dumps
27
30
  raw SQL logs to `tmp/benchmarks/query_logs/`.
28
31
 
29
- The suite seeds a stand-in domain (`Assignment`, `Gadget`, `Edition`, `Remark`,
30
- and others) that copies the production include-tree topology: wide + deep trees,
31
- polymorphic hops, shared prefixes, STI-free but otherwise faithful. The
32
- `assignments_full` tree mirrors the production `/workstreams` request
32
+ The suite seeds a stand-in domain (`Assignment`, `Gadget`, `Fault`, `Edition`,
33
+ `Remark`, and others) that copies the production include-tree topology: wide +
34
+ deep trees, polymorphic hops, shared prefixes, STI-free but otherwise faithful.
35
+ The `assignments_full` tree mirrors the production `/workstreams` request
33
36
  path-for-path; `assignments_single_conversation` mirrors the `/policies` killer
34
37
  (fat document bodies, a 600-remark conversation, ActiveStorage files, per-remark
35
38
  children); `gadgets_evidence_shape` mirrors `/evidence_checks` (a 45-id `id_in`
36
39
  batch, two branches converging on ActiveStorage files, polymorphic leaves at
37
- depth 2-3).
40
+ depth 2-3); `faults_index` mirrors `/incidents` (link joins to depth-3 gadget
41
+ and appliance targets whose resources carry resource-default preloads and
42
+ per-record meta, plus a full discussion thread); `suggestions_polymorphic`
43
+ mirrors `/suggested_risk_scenarios` (two depth-1 paths, one of them
44
+ polymorphic, onto target resources that under-declare their preloads).
38
45
 
39
46
  ## Baseline (large dataset, sqlite, 2026-08-14)
40
47
 
@@ -98,18 +105,55 @@ active — the domain shape alone is not the problem.
98
105
  `{files_attachments: {blob: {}}}` in the preload hash and reads blobs
99
106
  through the loaded associations. See "Optimizations landed" below.
100
107
 
101
- 4. **Per-record meta queries, any depth.** Instance `meta` that touches an
102
- association or runs a finder fires per serialized record. The batch pass
103
- (`preload_included_resource_associations`) only applies resource-default
104
- preloads to depth-1 targets, and only from `records` scope values. Probe: 1
105
- profile query per serialized user at depth 1 and depth 2; putting the path in
106
- `include=` collapses it to one batched query. Production:
107
- `PolicyResource#meta` runs 3 version lookups per policy;
108
- `PolicyVersionResource#meta` touches `resource.policy` per version.
109
- Baseline signature: 40× draft-edition and 40× published-edition lookups in
110
- `assignments_full`.
111
-
112
- 5. **Virtual sort disables everything.** A virtual (resource-method) sort field
108
+ 4. **Resource-default preloads reached depth 1 only FIXED** (see
109
+ "Optimizations landed" #3). Two separate passes load data, and they did not
110
+ cover the same ground:
111
+ - `scope_with_includes` preloads the include tree at **every** depth. It
112
+ builds one nested hash from all paths, so a 4-level path costs 4 queries,
113
+ not 4 per record. Control: `users_deep_include` serializes 400 records in
114
+ 3 queries; `assignments_single_conversation` serializes 2,191 records at
115
+ depth 4 in 22 queries.
116
+ - `preload_included_resource_associations` applies each resource's own
117
+ `records` preloads to include targets. It used to read
118
+ `include_path.split(".").first`, so only **depth-1** targets got them.
119
+
120
+ A resource at depth 2 or deeper therefore serialized with its own
121
+ associations unloaded. Every `meta` method that branches on `loaded?` took
122
+ the fallback branch and queried per record. Production:
123
+ `SelectedControlResource.records` preloads `evidence_checks`,
124
+ `selected_control_checks`, and `framework_criteria`, but `/incidents`
125
+ reaches `SelectedControl` at depth 2 through
126
+ `related_controls.selected_control`, so none of the three apply.
127
+ Anchor: `include_preloading.rb` (`preload_included_resource_associations`).
128
+ Baseline signature: 390 of 415 queries in `faults_index` are per-record;
129
+ 40× draft-edition and 40× published-edition lookups in `assignments_full`.
130
+
131
+ The depth-1 pass itself is sound, including through a polymorphic hop. Probe:
132
+ `GET /assignments?include=subject` (polymorphic to `Gadget`) batches all five
133
+ of `GadgetResource`'s declared preloads into one query each, and the count
134
+ stays flat as records grow. Regression spec:
135
+ `polymorphic_resource_preload_spec.rb`. Read the two facts together before
136
+ blaming jpie for an N+1: at depth 1 a declared preload is applied, so a
137
+ per-record query there means the resource never declared it.
138
+
139
+ 5. **Resources that never declare the preloads their own reads need.** A
140
+ `meta` method or attribute that walks an association costs one query per
141
+ serialized record when the resource's `records` scope does not preload it.
142
+ jpie cannot infer the need; nothing in the include tree names the
143
+ association. This is an application fault, not a library limit, and it is
144
+ the whole cost of a shallow request. Production `/suggested_risk_scenarios`
145
+ sends two depth-1 paths and still runs 541 ActiveRecord calls:
146
+ `RiskScenarioTemplateResource#meta` walks `inherent_risk_template.category`
147
+ with no `records` override (2 queries per template);
148
+ `SelectedControlResource#baseline` reads `control_template`, which its
149
+ preload list omits; `ComplianceScopeableResource` exposes `in_scope` and
150
+ `in_scope_justification`, which probe and then load
151
+ `resource_framework_scopes`, and no resource preloads them.
152
+ Baseline signature: `suggestions_polymorphic` runs one `baseline_estimates`
153
+ and one `estimate_categories` query per serialized repair plan, while every
154
+ association a resource does declare loads in a single batched query.
155
+
156
+ 6. **Virtual sort disables everything.** A virtual (resource-method) sort field
113
157
  materializes the whole filtered table as an Array before pagination, and
114
158
  `scope_with_includes` bails on Arrays, so nothing preloads. Probe: 10 users,
115
159
  `include=posts.comments`: 3 queries with a column sort, 41 with a virtual
@@ -259,3 +303,159 @@ Cumulative against the original baseline: the /policies shape is down from
259
303
  1,728 queries to 22; the /evidence_checks shape from 2,550 to 146; page 100
260
304
  from 5,102 to 1,233. The remaining residue is mostly mechanism 4 (per-record
261
305
  meta lookups: 120 of the 153 queries in assignments_full).
306
+
307
+ ### 3. Apply resource-default preloads at every depth (mechanism 4)
308
+
309
+ `preload_included_resource_associations` read only the first segment of each
310
+ include path, so a resource reached at depth 2 or deeper never received the
311
+ preloads its own `records` scope declares. It now nests the paths into one tree
312
+ and walks it, applying each target class's preloads at every hop. Shared
313
+ prefixes collapse, so a prefix is visited once however many paths cross it, and
314
+ a polymorphic hop continues once per class present in the loaded targets.
315
+
316
+ The walk costs no association queries: every hop reads targets that
317
+ `scope_with_includes` already loaded. Pure batching — included counts are
318
+ identical, scenario for scenario, and the whole suite passes unchanged.
319
+ Regression spec: `deep_resource_preload_spec.rb` pins a depth-2 target at a
320
+ flat query count (36 now, 141 before).
321
+
322
+ Large dataset, before and after:
323
+
324
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
325
+ |---|---|---|---|---|
326
+ | faults_index | 415 | 81 (−80%) | 117.0 | 114.4 |
327
+ | assignments_full | 292 | 183 (−37%) | 83.7 | 82.4 |
328
+ | assignments_full + authz | 315 | 206 (−35%) | 86.0 | 84.7 |
329
+ | gadgets_single | 87 | 34 (−61%) | 3.09 | 2.57 |
330
+ | assignments_page_1 | 108 | 55 (−49%) | 40.2 | 39.5 |
331
+ | assignments_page_25 | 582 | 385 (−34%) | 156.3 | 154.1 |
332
+ | assignments_page_50 | 1,027 | 693 (−33%) | 280.8 | 277.2 |
333
+ | assignments_page_100 | 1,830 | 1,293 (−29%) | 526.2 | 520.5 |
334
+ | gadgets_evidence_shape (control) | 150 | 150 | 59.2 | 59.3 |
335
+ | assignments_single_conversation (control) | 22 | 22 | 79.7 | 80.0 |
336
+ | suggestions_polymorphic (control) | 84 | 84 | 9.37 | 9.37 |
337
+ | users_deep_include (control) | 3 | 3 | 9.83 | 9.84 |
338
+
339
+ The controls carry the argument. `suggestions_polymorphic` does not move,
340
+ because its targets already sat at depth 1 — that request's cost is
341
+ mechanism 5, in the application, and no jpie change touches it.
342
+ `gadgets_evidence_shape` and `users_deep_include` do not move either: neither
343
+ reaches a preload-declaring resource below depth 1.
344
+
345
+ Allocations barely move because the saving is query count, not object count.
346
+ Wall time on sqlite is too noisy between runs to attribute — the same scenario
347
+ varied by 2x across runs with identical query counts. Against production
348
+ postgres, where per-query latency is real, a 334-query drop on `faults_index`
349
+ is the whole point.
350
+
351
+ ## Two shapes, two causes (2026-08-17)
352
+
353
+ Two staging requests look alike from the outside: both are index pages, both
354
+ spend about 2 seconds, both fire hundreds of ActiveRecord calls. They fail for
355
+ opposite reasons, and the fix for one does nothing for the other.
356
+
357
+ | | `/incidents` | `/suggested_risk_scenarios` |
358
+ |---|---|---|
359
+ | Include paths | 18, up to depth 3 | 2, both depth 1 |
360
+ | Where targets sit | depth 2-3 | depth 1 |
361
+ | Did jpie apply the target's preloads? | No — depth-1 pass missed them | Yes |
362
+ | Cause | jpie: mechanism 4 | application: mechanism 5 |
363
+ | Fix belongs in | jpie | kiln resources |
364
+ | Benchmark scenario | `faults_index` | `suggestions_polymorphic` |
365
+ | Status | fixed: 415 → 81 queries | open, needs kiln preloads |
366
+
367
+ ### The /incidents shape: mechanism 4
368
+
369
+ `faults_index` reproduces the production `/incidents` request: 18 include
370
+ paths, `page[size]=20`, `sort=-created_at`, `filter[archived]=false`. Three
371
+ link branches reach `Gadget` and `Appliance` at depth 2-3. Both resources now
372
+ carry what production carries: a `records` scope that preloads what `meta`
373
+ reads, and `meta` methods that fall back to a query when nothing preloaded.
374
+
375
+ **This change moved the older scenarios too.** `Gadget` and `Appliance` sit in
376
+ the `assignments_full` tree as well, so that scenario went from 153 queries to
377
+ 292 on the same code. No optimization regressed; the benchmark domain became
378
+ more faithful. Compare future runs against the table below, not against the
379
+ 2026-08-14 recordings.
380
+
381
+ | Scenario | Primary | Included | Queries | ms (median) | Alloc MB |
382
+ |---|---|---|---|---|---|
383
+ | faults_index (page 20, 18 paths) | 20 | 4,290 | 415 | 2,430 | 117.0 |
384
+ | assignments_full (re-recorded) | 10 | 2,701 | 292 | 1,548 | 83.7 |
385
+ | assignments_full + authz (re-recorded) | 10 | 2,701 | 315 | 1,088 | 86.0 |
386
+ | gadgets_evidence_shape (re-recorded) | 45 | 1,656 | 150 | 767 | 59.2 |
387
+ | assignments_single_conversation (unchanged) | 1 | 2,191 | 22 | 432 | 79.7 |
388
+ | users_deep_include (control) | 20 | 400 | 3 | 95 | 9.8 |
389
+
390
+ Adding the suggestion domain changed none of these counts. Every scenario above
391
+ reproduced its query count exactly on the run that first measured
392
+ `suggestions_polymorphic`, which confirms the new tables and resources sit
393
+ outside the other include trees.
394
+
395
+ 390 of the 415 `faults_index` queries are per-record. The include tree itself
396
+ costs about 25. The breakdown, against 45 gadgets and 25 appliances reached at
397
+ depth 2-3:
398
+
399
+ | Count | Query | Source | After the depth fix |
400
+ |---|---|---|---|
401
+ | 70 | `archivals` by owner | `records` preload, unreachable at depth 2 | 2 |
402
+ | 45 | `criteria.id` pluck | `criterium_ids` attribute | 1 |
403
+ | 45 + 45 | `inspections` order-limit, then exists | inspection history in `meta` | 1 |
404
+ | 45 + 45 | `standard_scopes` rows, then exists | scope verdict in `meta` | 1 |
405
+ | 45 | `gadget_checks` exists | checklist verdict in `meta` | 1 |
406
+ | 25 + 25 | `stream_entries` by stream | append-only read, no association | 50 |
407
+
408
+ Only the last row was beyond jpie's reach: a raw stream read has no association
409
+ for any preloader to batch. Everything above it collapsed to a handful of
410
+ batched queries once resource-default preloads applied at every depth, taking
411
+ the scenario from 415 queries to 81. The 50 stream reads are now the largest
412
+ single item, and they need a change in the application.
413
+
414
+ ### The /suggested_risk_scenarios shape: mechanism 5
415
+
416
+ `suggestions_polymorphic` reproduces the production request:
417
+ `filter[account_level]=true`, `sort=-priority`, and two include paths —
418
+ `repair_plan` (plain) and `suggestable` (polymorphic, onto `Gadget` and
419
+ `Appliance`). `SuggestionResource.records` copies the production scope, with
420
+ the plain hop on `includes` and the polymorphic hop on `preload`.
421
+
422
+ Both targets sit at depth 1, so jpie applies their `records` preloads. The
423
+ split in the results is the whole point:
424
+
425
+ - `Gadget` declares its preloads. All five load in one batched query each,
426
+ however many gadgets the page holds.
427
+ - `RepairPlan` declares none, and its `meta` walks
428
+ `baseline_estimate.estimate_category`. That costs two queries per serialized
429
+ plan — the exact `Risk::InherentRiskTemplate` and `Risk::Category` bands in
430
+ the staging trace.
431
+ - `Appliance` reads an append-only stream, which has no association to preload
432
+ at any depth.
433
+
434
+ | Dataset | Primary | Included | Queries | ms (median) | Alloc MB |
435
+ |---|---|---|---|---|---|
436
+ | small | 5 | 10 | 22 | 14 | - |
437
+ | medium | 25 | 37 | 46 | 31 | 2.9 |
438
+ | large | 100 | 112 | 84 | 203 | 9.4 |
439
+
440
+ Queries grow far slower than the page, because the repair-plan pool caps at 12
441
+ and only 25 of the 100 suggestables are appliances. Production behaves the same
442
+ way: 18 suggestions reached 13 distinct templates. The large run splits like
443
+ this:
444
+
445
+ | Count | Query | Verdict |
446
+ |---|---|---|
447
+ | 25 + 25 | `stream_entries` by stream | unfixable by preloading |
448
+ | 12 + 12 | `baseline_estimates`, then `estimate_categories` | resource never declared it |
449
+ | 2 | `archivals` by owner type | correct: one per polymorphic class |
450
+ | 8 | primary, both target classes, plan, declared preloads | correct: batched |
451
+
452
+ Only 8 of the 84 queries are structural. Every association a resource declares
453
+ is batched, including through the polymorphic hop, and the 24 `RepairPlan`
454
+ queries are the shape a one-line preload removes.
455
+
456
+ No change to jpie removes the `RepairPlan` queries. The resource has to ask for
457
+ the preload. In production the three to fix are
458
+ `RiskScenarioTemplateResource` (add `inherent_risk_template: :category`),
459
+ `SelectedControlResource` (add `control_template` to its existing preload
460
+ list), and `ComplianceScopeableResource` (add `resource_framework_scopes`,
461
+ which alone accounts for 82 of the 541 calls).
data/README.md CHANGED
@@ -875,6 +875,13 @@ This will:
875
875
  - Load `API::V1::WidgetResource` (falls back to `WidgetResource` if `namespace_fallback: true`)
876
876
  - Pass `jsonapi_namespace: "api/v1"` to the controller
877
877
 
878
+ The request namespace also selects the resource class during serialization: for
879
+ the attributes, the include filter, the links, and the relationship linkage.
880
+ `GET /portal/posts` serializes with `Portal::PostResource` even when the `Post`
881
+ model is flat. When the namespaced resource does not exist, the loader falls
882
+ back to the model's own namespace, then to the flat resource. Set
883
+ `config.namespace_fallback = false` to refuse the flat fallback.
884
+
878
885
  #### Namespaced Resources
879
886
 
880
887
  Define resources within namespaces that match your route structure:
@@ -8,10 +8,14 @@ module JSONAPI
8
8
  private
9
9
 
10
10
  def includes_to_hash(paths)
11
- hash = paths.each_with_object({}) do |path, h|
12
- path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
11
+ filter_includable(includes_to_tree(paths), model_class)
12
+ end
13
+
14
+ # Nests dotted include paths into one tree, collapsing shared prefixes.
15
+ def includes_to_tree(paths)
16
+ paths.each_with_object({}) do |path, tree|
17
+ path.split(".").reduce(tree) { |node, part| node[part.to_sym] ||= {} }
13
18
  end
14
- filter_includable(hash, model_class)
15
19
  end
16
20
 
17
21
  # Preloads associations from the include param so the serializer avoids N+1 queries
@@ -82,18 +86,45 @@ module JSONAPI
82
86
  assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
83
87
  end
84
88
 
89
+ # Applies each included resource's own `records` preloads to the records
90
+ # of that resource's class. Walks the whole include tree: a resource
91
+ # reached at depth 3 gets its preloads just as one reached at depth 1.
92
+ #
93
+ # The tree collapses shared prefixes, so a prefix is visited once however
94
+ # many paths run through it. Walking costs no association queries — every
95
+ # hop reads targets that `scope_with_includes` already loaded. The only
96
+ # queries are the batched resource-default preloads themselves, each of
97
+ # which replaces one query per record during serialization.
85
98
  def preload_included_resource_associations(resources, includes)
86
99
  return if includes.empty? || resources.empty?
87
100
 
88
- includes.each do |include_path|
89
- association_name = include_path.split(".").first.to_sym
90
- assoc_reflection = model_class.reflect_on_association(association_name)
91
- next unless assoc_reflection
101
+ preload_resource_tree(resources, model_class, includes_to_tree(includes))
102
+ end
92
103
 
93
- targets = collect_include_targets(resources, association_name)
104
+ def preload_resource_tree(records, klass, tree)
105
+ tree.each do |association_name, subtree|
106
+ reflection = klass.reflect_on_association(association_name)
107
+ next unless reflection
108
+
109
+ targets = collect_include_targets(records, association_name)
94
110
  next if targets.empty?
95
111
 
96
- apply_preloads_for_targets(targets, assoc_reflection.polymorphic?)
112
+ apply_preloads_for_targets(targets, reflection.polymorphic?)
113
+ next if subtree.empty?
114
+
115
+ descend_resource_tree(targets, reflection, subtree)
116
+ end
117
+ end
118
+
119
+ # A polymorphic hop has no single target class, so continue once per
120
+ # class actually present in the loaded targets.
121
+ def descend_resource_tree(targets, reflection, subtree)
122
+ if reflection.polymorphic?
123
+ targets.group_by(&:class).each do |target_class, records|
124
+ preload_resource_tree(records, target_class, subtree)
125
+ end
126
+ else
127
+ preload_resource_tree(targets, reflection.klass, subtree)
97
128
  end
98
129
  end
99
130
 
@@ -116,7 +147,7 @@ module JSONAPI
116
147
  return if records.empty?
117
148
 
118
149
  klass = records.first.class
119
- resource_scope = ResourceLoader.find_for_model(klass).records
150
+ resource_scope = ResourceLoader.find_for_model(klass, namespace: jsonapi_namespace).records
120
151
  preload_values = resource_scope.preload_values + resource_scope.includes_values
121
152
  return if preload_values.empty?
122
153
 
@@ -9,11 +9,9 @@ module JSONAPI
9
9
  def serialize_resource(resource)
10
10
  includes = parse_include_param
11
11
  cache = build_include_filter_cache([resource], includes)
12
- JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache).to_hash(
13
- include: includes,
14
- fields: parse_fields_param,
15
- document_meta: jsonapi_document_meta,
16
- )
12
+ serializer = JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache,
13
+ namespace: jsonapi_namespace,)
14
+ serializer.to_hash(include: includes, fields: parse_fields_param, document_meta: jsonapi_document_meta)
17
15
  end
18
16
 
19
17
  def serialize_collection(resources)
@@ -48,7 +46,8 @@ module JSONAPI
48
46
  end
49
47
 
50
48
  def serialize_single(resource, includes, fields, include_context = nil, cache = nil)
51
- JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache)
49
+ JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache,
50
+ namespace: jsonapi_namespace,)
52
51
  .to_hash(include: includes, fields:, document_meta: nil, include_context:)
53
52
  end
54
53
 
@@ -56,7 +55,8 @@ module JSONAPI
56
55
  # every included record gets its scope verdict in one query per class,
57
56
  # and the serializers answer their per-visit checks from memory.
58
57
  def build_include_filter_cache(resources, includes)
59
- cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self)
58
+ cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self,
59
+ namespace: jsonapi_namespace,)
60
60
  includes.each do |path|
61
61
  current = resources
62
62
  path.split(".").each do |part|
@@ -59,20 +59,42 @@ module JSONAPI
59
59
  def self.find_for_model(model_class, namespace: nil)
60
60
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
61
61
 
62
- # Key format "::ModelName" is public-ish: applications pre-populate
63
- # @model_cache with these keys to pin STI subclass resources.
64
- key = namespace ? "#{namespace}|::#{model_class.name}" : "::#{model_class.name}"
62
+ key = model_cache_key(model_class, namespace)
65
63
  cached = @model_cache[key]
66
64
  return cached if cached
67
65
 
68
- effective_namespace = namespace || extract_namespace_from_model(model_class)
69
- candidates = model_candidates(model_class, effective_namespace)
70
-
71
- klass = resolve(candidates) ||
72
- raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
66
+ model_namespace = extract_namespace_from_model(model_class)
67
+ klass = resolve(find_for_model_candidates(model_class, namespace, model_namespace)) ||
68
+ raise(MissingResourceClass.new(model_class.name, namespace: namespace || model_namespace))
73
69
  @model_cache[key] = klass
74
70
  end
75
71
 
72
+ def self.find_for_model_candidates(model_class, namespace, model_namespace)
73
+ return model_candidates(model_class, model_namespace) unless namespace
74
+
75
+ request_namespace_candidates(model_class, namespace, model_namespace)
76
+ end
77
+
78
+ # Key format "::ModelName" is public-ish: applications pre-populate
79
+ # @model_cache with these keys to pin STI subclass resources.
80
+ def self.model_cache_key(model_class, namespace)
81
+ return "::#{model_class.name}" unless namespace
82
+
83
+ "#{namespace}|::#{model_class.name}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
84
+ end
85
+
86
+ # The request namespace outranks the model's own namespace, which stays a
87
+ # fallback so a namespaced model still reaches its own resource. The flat
88
+ # name comes last, and only while the configuration allows the fallback.
89
+ def self.request_namespace_candidates(model_class, namespace, model_namespace)
90
+ resource_type = model_class.name.demodulize.underscore.pluralize
91
+ candidates = [build_resource_class_name(resource_type, namespace)]
92
+ candidates |= model_candidates(model_class, model_namespace)
93
+ return candidates if JSONAPI.configuration.namespace_fallback
94
+
95
+ candidates - [build_resource_class_name(resource_type, nil)]
96
+ end
97
+
76
98
  def self.build_resource_class_name(resource_type, namespace)
77
99
  base = "#{resource_type.singularize.classify}Resource"
78
100
  return base unless namespace.present?
@@ -61,7 +61,7 @@ module JSONAPI
61
61
  end
62
62
 
63
63
  def valid_include_association?(current_record, association_name)
64
- current_definition = ResourceLoader.find_for_model(current_record.class)
64
+ current_definition = ResourceLoader.find_for_model(current_record.class, namespace:)
65
65
  relationship_def = RelationshipHelpers.find_relationship_definition(current_definition, association_name)
66
66
  return false unless relationship_def
67
67
  return true if self.class.active_storage_attachment?(association_name, current_record.class)
@@ -91,7 +91,7 @@ module JSONAPI
91
91
  end
92
92
 
93
93
  def build_scoped_relation(related_klass, association)
94
- related_base_scope = ResourceLoader.find_for_model(related_klass).records
94
+ related_base_scope = ResourceLoader.find_for_model(related_klass, namespace:).records
95
95
  association.scope.merge(apply_include_authorization(related_base_scope, related_klass))
96
96
  end
97
97
 
@@ -119,12 +119,19 @@ module JSONAPI
119
119
  return if ctx.processed.include?(build_record_key(related_record))
120
120
 
121
121
  requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
122
- serializer = self.class.new(related_record, parent_record:, association_name:,
123
- authorization_context:, include_filter_cache:,)
122
+ serializer = child_serializer(related_record, parent_record:, association_name:)
124
123
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
125
124
  ctx.processed.add(build_record_key(related_record))
126
125
  end
127
126
 
127
+ # An included record serializes with the same request context as its
128
+ # parent: the authorization context, the filter cache, and the namespace
129
+ # that selects the resource class.
130
+ def child_serializer(related_record, parent_record:, association_name:)
131
+ self.class.new(related_record, parent_record:, association_name:,
132
+ authorization_context:, include_filter_cache:, namespace:,)
133
+ end
134
+
128
135
  def build_record_key(related_record)
129
136
  "#{related_record.class.name}-#{related_record.id}"
130
137
  end
@@ -43,7 +43,7 @@ module JSONAPI
43
43
  end
44
44
 
45
45
  def variant_options_for_parent_association
46
- parent_definition = ResourceLoader.find_for_model(parent_record.class)
46
+ parent_definition = ResourceLoader.find_for_model(parent_record.class, namespace:)
47
47
  rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
48
48
  rel_def&.dig(:options, :variant)
49
49
  end
@@ -74,6 +74,7 @@ module JSONAPI
74
74
  resource_class: definition,
75
75
  use_instance_class:,
76
76
  base_resource_class: base_def_for_related,
77
+ namespace:,
77
78
  )
78
79
  end
79
80
 
@@ -15,8 +15,9 @@ module JSONAPI
15
15
  Entry = Struct.new(:base_scope, :authorized_scope, :narrowed, :where_clause_empty, :where_hash,
16
16
  keyword_init: true,)
17
17
 
18
- def initialize(authorization_context: nil)
18
+ def initialize(authorization_context: nil, namespace: nil)
19
19
  @authorization_context = authorization_context
20
+ @namespace = namespace
20
21
  @entries = {}
21
22
  @verdicts = {}
22
23
  end
@@ -53,7 +54,7 @@ module JSONAPI
53
54
  end
54
55
 
55
56
  def build_entry(klass)
56
- base_scope = ResourceLoader.find_for_model(klass).records
57
+ base_scope = ResourceLoader.find_for_model(klass, namespace: @namespace).records
57
58
  authorized_scope = apply_authorization(base_scope, klass)
58
59
  Entry.new(
59
60
  base_scope: base_scope,
@@ -35,10 +35,16 @@ module JSONAPI
35
35
  @jsonapi_object = nil
36
36
  end
37
37
 
38
+ # namespace is the request's JSON:API namespace. A namespaced endpoint over
39
+ # a flat model (a public portal that re-exposes an internal model) resolves
40
+ # to its own resource only when the namespace reaches this far: resolution
41
+ # by model alone derives the namespace from the model, so a flat model can
42
+ # never reach a namespaced resource.
38
43
  def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil,
39
- authorization_context: nil, include_filter_cache: nil)
44
+ authorization_context: nil, include_filter_cache: nil, namespace: nil)
40
45
  @record = record
41
- @definition = definition || ResourceLoader.find_for_model(record.class)
46
+ @namespace = namespace
47
+ @definition = definition || ResourceLoader.find_for_model(record.class, namespace:)
42
48
  @base_definition = base_definition
43
49
  @parent_record = parent_record
44
50
  @association_name = association_name
@@ -75,12 +81,12 @@ module JSONAPI
75
81
 
76
82
  private
77
83
 
78
- attr_reader :record, :definition, :parent_record, :association_name, :authorization_context
84
+ attr_reader :record, :definition, :parent_record, :association_name, :authorization_context, :namespace
79
85
 
80
86
  # Shared per request when the controller passes one in; a standalone
81
87
  # serializer builds its own, which still dedupes within its own walk.
82
88
  def include_filter_cache
83
- @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context: authorization_context)
89
+ @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context:, namespace:)
84
90
  end
85
91
 
86
92
  def base_definition
@@ -20,7 +20,8 @@ module JSONAPI
20
20
  # Delegate to ResourceIdentifier
21
21
  # rubocop:disable Lint/UnusedMethodArgument
22
22
  def serialize_resource_identifier(
23
- record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil
23
+ record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil,
24
+ namespace: nil
24
25
  )
25
26
  # rubocop:enable Lint/UnusedMethodArgument
26
27
  ResourceIdentifier.serialize_identifier(
@@ -28,6 +29,7 @@ module JSONAPI
28
29
  association:,
29
30
  definition: resource_class,
30
31
  use_instance_class:,
32
+ namespace:,
31
33
  )
32
34
  end
33
35
 
@@ -4,10 +4,10 @@ module JSONAPI
4
4
  module ResourceIdentifier
5
5
  module_function
6
6
 
7
- def serialize_identifier(record, association:, definition:, use_instance_class: false)
7
+ def serialize_identifier(record, association:, definition:, use_instance_class: false, namespace: nil)
8
8
  model_class = determine_model_class(record, association:, definition:,
9
9
  use_instance_class:,)
10
- related_definition = JSONAPI::ResourceLoader.find_for_model(model_class)
10
+ related_definition = JSONAPI::ResourceLoader.find_for_model(model_class, namespace:)
11
11
  related_type = TypeConversion.resource_type_name(related_definition)
12
12
 
13
13
  { type: related_type, id: record.id.to_s }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.8.1"
4
+ VERSION = "3.8.3"
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: 3.8.1
4
+ version: 3.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp