jpie 3.7.0 → 3.8.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: 7afd23e7e9ffb26be73d59ec5776b05d0a1375edc172c8faf8ee8675c1a9e681
4
- data.tar.gz: d0b2ac2e8bb3035829524faabc24d2eb787cb0f716e66e3f008043b42a3c31fb
3
+ metadata.gz: d9e7099c27c6aec0de41a095baa563dbc63eb934b44ab8195a9e3a51f5152c4e
4
+ data.tar.gz: 86ee7fbdc3cf8414d9829bbf4f42ac7e016603f8a884a0b674d8e9b869cf1b8d
5
5
  SHA512:
6
- metadata.gz: de7034c8a695bb8a27501f8c00129a8b4465c3ac7963da3a3327c634b5c85064b29dfbd232841a27a84e15c1b2ef477e1ecf24bae4752ab704f8b76919dba806
7
- data.tar.gz: b3474c65fcd26816af5f8f02766f8a52e850112919e29f14b1deff31f5bbc785bbf12dd24d331af4cc321f086f45c06d7ed47b068fd90158d3765b0eda5a631f
6
+ metadata.gz: a013761543aba53b56e575f8b2f51d3004d1cae53e53570bf506bd744cb2d38891e43a5188ad11ee338923aacdd5c8bc4cf107e1772f5cb73173aafb590d1859
7
+ data.tar.gz: 105cbbcb454083ebbfae7db54d9fbec5e76c39d3c1d1823c16b34de071abeae47e4cdf5923aadeb3224e609ee85c07712ed37651caa1122719501ac1c479ae07
data/.rubocop.yml CHANGED
@@ -47,6 +47,10 @@ Metrics/BlockLength:
47
47
 
48
48
  Metrics/ParameterLists:
49
49
  CountKeywordArgs: false
50
+ Exclude:
51
+ # Seed helpers thread id pools positionally; a params object would add
52
+ # indirection to throwaway scaffolding.
53
+ - "spec/benchmarks/**/*"
50
54
 
51
55
  # Naming - allow DSL methods
52
56
  Naming/PredicatePrefix:
@@ -95,6 +99,9 @@ Lint/UnusedBlockArgument:
95
99
  # Security PoC specs configure authorization handlers via keyword lambdas
96
100
  # that intentionally ignore some of the required keyword arguments.
97
101
  - "spec/requests/regression/*_spec.rb"
102
+ # The benchmark suite and filter-batching specs configure the same hooks.
103
+ - "spec/benchmarks/**/*"
104
+ - "spec/jsonapi_spec/query_parameters/include_filter_batching_spec.rb"
98
105
 
99
106
  # Security PoC regression specs read as a single sequential narrative
100
107
  # (sanity check -> attack -> assert), so a handful of expectations per example
@@ -102,10 +109,61 @@ Lint/UnusedBlockArgument:
102
109
  RSpec/MultipleExpectations:
103
110
  Exclude:
104
111
  - "spec/requests/regression/*_spec.rb"
112
+ - "spec/benchmarks/**/*"
105
113
 
106
114
  RSpec/ExampleLength:
107
115
  Exclude:
108
116
  - "spec/requests/regression/*_spec.rb"
117
+ - "spec/benchmarks/**/*"
118
+
119
+ # The benchmark suite reads as data tables (seed rows, scenario matrices,
120
+ # result formatting). Splitting those methods obscures the data shapes, and
121
+ # the suite is measurement scaffolding, not library code.
122
+ Metrics/MethodLength:
123
+ Exclude:
124
+ - "spec/benchmarks/**/*"
125
+
126
+ Metrics/AbcSize:
127
+ Exclude:
128
+ - "spec/benchmarks/**/*"
129
+ # Mirrors a production resource: meta computes several version lookups.
130
+ - "spec/dummy/app/resources/assignment_resource.rb"
131
+
132
+ Metrics/ModuleLength:
133
+ Exclude:
134
+ - "spec/benchmarks/**/*"
135
+
136
+ Metrics/CyclomaticComplexity:
137
+ Exclude:
138
+ - "spec/benchmarks/**/*"
139
+
140
+ Metrics/PerceivedComplexity:
141
+ Exclude:
142
+ - "spec/benchmarks/**/*"
143
+
144
+ Style/FormatStringToken:
145
+ Exclude:
146
+ - "spec/benchmarks/**/*"
147
+
148
+ # Benchmark seeds load once per size context; per-example transactions are
149
+ # deliberately off there.
150
+ RSpec/BeforeAfterAll:
151
+ Exclude:
152
+ - "spec/benchmarks/**/*"
153
+
154
+ RSpec/InstanceVariable:
155
+ Exclude:
156
+ - "spec/benchmarks/**/*"
157
+
158
+ # Include-tree constants in the benchmark spec document the production
159
+ # request shapes; they never leak outside the suite run.
160
+ Lint/ConstantDefinitionInBlock:
161
+ Exclude:
162
+ - "spec/benchmarks/**/*"
163
+
164
+ RSpec/LeakyConstantDeclaration:
165
+ Exclude:
166
+ - "spec/benchmarks/**/*"
109
167
 
110
168
  # RSpec - file-specific exclusions
111
169
  RSpec/DescribedClass:
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ group :development, :test do
11
11
  gem "appraisal", "~> 2.4"
12
12
  gem "bundler", "~> 2.0"
13
13
  gem "json_schemer", "~> 2.4"
14
+ gem "memory_profiler", "~> 1.1"
14
15
  gem "pundit", "~> 2.3"
15
16
  gem "rake", "~> 13.0"
16
17
  gem "rspec", "~> 3.12"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (3.6.0)
4
+ jpie (3.8.1)
5
5
  actionpack (~> 8.1, >= 8.1.0)
6
6
  pg_query (>= 4)
7
7
  prosopite (>= 1)
@@ -151,6 +151,7 @@ GEM
151
151
  net-pop
152
152
  net-smtp
153
153
  marcel (1.1.0)
154
+ memory_profiler (1.1.0)
154
155
  mini_mime (1.1.5)
155
156
  minitest (5.26.2)
156
157
  net-imap (0.5.12)
@@ -340,6 +341,7 @@ DEPENDENCIES
340
341
  bundler (~> 2.0)
341
342
  jpie!
342
343
  json_schemer (~> 2.4)
344
+ memory_profiler (~> 1.1)
343
345
  pundit (~> 2.3)
344
346
  rake (~> 13.0)
345
347
  rspec (~> 3.12)
@@ -1,102 +1,261 @@
1
- # jpie Performance Baseline
2
-
3
- This document outlines the performance metrics for key operations within the `jpie` gem, comparing the initial baseline with the results after implementing all optimizations.
4
-
5
- ## Performance Comparison
6
-
7
- | Metric | Original Baseline | After Optimizations | Improvement |
8
- |--------|-------------------|---------------------|-------------|
9
- | `resource_loader_find_avg_ms` | 0.0182 | 0.0003 | **60x faster** |
10
- | `resource_loader_find_for_model_avg_ms` | 0.036 | 0.0003 | **120x faster** |
11
- | `jsonapi_object_avg_ms` | 0.0003 | 0.0001 | **3x faster** |
12
- | `serialize_single_user_avg_ms` | 0.2168 | 0.0982 | **2.2x faster** |
13
- | `serialize_10_users_avg_ms` | 1.4318 | 0.975 | **1.5x faster** |
14
- | `serialize_single_user_query_count` | 4 | 4 | N/A |
15
- | `serialize_10_users_query_count` | 40 | 40 | N/A |
16
- | `serialize_single_user_allocations` | 997 | 977 | **2% reduction** |
17
- | `serialize_10_users_allocations` | 9934 | 9716 | **2% reduction** |
18
- | `relationship_definitions_avg_ms` | 0.0036 | 0.0002 | **18x faster** |
19
- | `resource_instantiation_avg_ms` | 0.0003 | 0.0004 | Similar |
20
-
21
- ## Implemented Optimizations
22
-
23
- ### 1. ResourceLoader Caching (Phase 4)
24
- - Thread-safe caching for `find` and `find_for_model` methods
25
- - Eliminates repeated `constantize` calls
26
- - **60-120x improvement** in resource class lookups
27
-
28
- ### 2. jsonapi_object Memoization (Phase 5)
29
- - Memoized the static JSON:API object
30
- - Returns frozen object to prevent mutations
31
- - **3x improvement** in object generation
32
-
33
- ### 3. Relationship Definitions Caching (Phase 6)
34
- - Memoized `relationship_definitions` computation
35
- - Returns frozen array to prevent mutations
36
- - **18x improvement** in relationship metadata access
37
-
38
- ### 4. Optional Count Query (Phase 7)
39
- - `total_count` only computed when pagination is applied
40
- - Avoids unnecessary COUNT queries for non-paginated requests
41
- - Reduces database load
42
-
43
- ### 5. Eager Loading DSL (Phase 8)
44
- - New `eager_load` class method for resources
45
- - Automatically included in preloading without explicit `include` param
46
- - Helps eliminate N+1 queries at the resource level
47
-
48
- ### 6. Preload for Serialization Hook (Phase 9)
49
- - New `preload_for_serialization` class method
50
- - Called before serialization with all records
51
- - Enables batch-loading of data needed by `meta` methods
52
- - Thread-local storage for preloaded data
53
-
54
- ## Usage Examples
55
-
56
- ### Eager Loading DSL
57
-
58
- ```ruby
59
- class WorkstreamResource < ApplicationResource
60
- eager_load :conversation, :owners
61
-
62
- # These associations will be automatically eager-loaded
63
- # even without an explicit ?include= param
64
- end
65
- ```
1
+ # jpie performance baseline: N+1 queries and memory bloat
2
+
3
+ This document records the measured baseline for the request-level performance
4
+ problem: controller actions with large `include=` trees fire hundreds to
5
+ thousands of SQL queries and allocate hundreds of MB per request. Production
6
+ examples: `/workstreams` with 25 include paths (~300 queries at page 10, up to
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
9
+ `/evidence_checks` with a 45-id batch filter and files at depth 3 (~1,250
10
+ queries).
11
+
12
+ The numbers come from the benchmark suite in `spec/benchmarks/`. The evaluation
13
+ behind them ran 32 independent code-path analyses; a second wave verified the
14
+ top findings empirically with probe specs. Every mechanism below carries a
15
+ measured probe result, not an estimate.
66
16
 
67
- ### Preload for Serialization Hook
68
-
69
- ```ruby
70
- class WorkstreamResource < ApplicationResource
71
- def self.preload_for_serialization(records, context = {})
72
- # Batch-load stats for all records
73
- stats = Preloaders::StatsPreloader.call(records: records)
74
- stats # Store in preloaded_data for access in meta
75
- end
76
-
77
- def meta(...)
78
- stats = self.class.preloaded_data[record.id] || record.loading_stats
79
- super.merge(loading: stats)
80
- end
81
- end
17
+ ## How to run
18
+
19
+ ```bash
20
+ rake benchmark
82
21
  ```
83
22
 
84
- ## Metric Interpretation
85
-
86
- - **`resource_loader_find_avg_ms`**: Average time to find a resource class by type
87
- - **`resource_loader_find_for_model_avg_ms`**: Average time to find a resource class by model
88
- - **`jsonapi_object_avg_ms`**: Average time to generate the static `jsonapi` object
89
- - **`serialize_single_user_avg_ms`**: Average time to serialize a single `User` record
90
- - **`serialize_10_users_avg_ms`**: Average time to serialize a collection of 10 `User` records
91
- - **`serialize_single_user_query_count`**: Number of SQL queries for single `User` serialization
92
- - **`serialize_10_users_query_count`**: Number of SQL queries for 10 `User` records serialization
93
- - **`serialize_single_user_allocations`**: Object allocations for single `User` serialization
94
- - **`serialize_10_users_allocations`**: Object allocations for 10 `User` records serialization
95
- - **`relationship_definitions_avg_ms`**: Average time to compute relationship definitions
96
- - **`resource_instantiation_avg_ms`**: Average time to instantiate a resource
97
-
98
- ## Notes
99
-
100
- - Query counts remain the same in benchmarks as they test the serializer directly
101
- - Real-world N+1 reduction comes from the `eager_load` DSL and `preload_for_serialization` hook
102
- - Allocation reduction is modest but consistent across serialization
23
+ This runs `spec/benchmarks/` with `PERFORMANCE=1`, prints a results table, and
24
+ writes `tmp/request_benchmark_results.json` (per-scenario query counts, query
25
+ shapes, wall time, allocated/retained bytes, allocation sites, RSS). Diff that
26
+ file against this baseline after any optimization. `DEBUG_QUERIES=1` also dumps
27
+ raw SQL logs to `tmp/benchmarks/query_logs/`.
28
+
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
33
+ path-for-path; `assignments_single_conversation` mirrors the `/policies` killer
34
+ (fat document bodies, a 600-remark conversation, ActiveStorage files, per-remark
35
+ children); `gadgets_evidence_shape` mirrors `/evidence_checks` (a 45-id `id_in`
36
+ batch, two branches converging on ActiveStorage files, polymorphic leaves at
37
+ depth 2-3).
38
+
39
+ ## Baseline (large dataset, sqlite, 2026-08-14)
40
+
41
+ The seeds sit above the worst production reports on purpose: a 600-remark AI
42
+ conversation, 40 editions with ~64KB document bodies, ~4KB remark bodies,
43
+ fanout 10 on subject inspections plus link-owned inspections. The heaviest
44
+ scenarios overshoot the observed ceilings (600MB allocated, 1,250 queries) so
45
+ the bottleneck curves stay visible past them.
46
+
47
+ | Scenario | Primary | Included | Queries | ms (median) | Alloc MB | Retained MB |
48
+ |---|---|---|---|---|---|---|
49
+ | assignments_full (page 10, 25 paths) | 10 | 2,701 | 782 | 1,499 | 133.9 | 14.1 |
50
+ | assignments_full + authz hook | 10 | 2,701 | 3,403 | 2,200 | 168.2 | 14.6 |
51
+ | assignments_single_conversation (page 1, 17 paths) | 1 | 2,191 | 1,728 | 1,376 | 147.4 | 24.6 |
52
+ | assignments_single_conversation + authz hook | 1 | 2,191 | 4,029 | 2,079 | 176.8 | 25.0 |
53
+ | gadgets_evidence_shape (45-id batch, 9 paths) | 45 | 1,656 | 2,550 | 1,386 | 132.3 | 8.1 |
54
+ | assignments page 1 / 25 / 50 / 100 (fixed tree) | 1-100 | 1,317-16,382 | 345/1,502/2,702/5,102 | 634-11,148 | 60.0/257.2/466.9/884.5 | 6.9-102.1 |
55
+ | assignments tree depth 1 / 2 / full (fixed page 10) | 10 | 67/1,470/2,701 | 129/1,294/782 | 66/639/1,499 | 8.4/64.5/133.9 | 1.7/13.7/14.1 |
56
+ | users_deep_include (control: clean resources) | 20 | 400 | 3 | 97 | 12.0 | 1.6 |
57
+
58
+ The reproduction exceeds every production report. Queries: 782 at the
59
+ `/workstreams` page-10 shape, 2,550 at the `/evidence_checks` shape (vs 1,250
60
+ observed), 1,728 for ONE primary record at the `/policies` shape, 5,102 at
61
+ page 100. Memory: 884.5MB allocated at page 100 (vs ~600MB observed), 133.9MB
62
+ at page 10. The authorization-hook variants add a narrowing Pundit-style scope
63
+ on every model; production sits between the plain and authz numbers because
64
+ only some models carry narrowing scopes. The control scenario shows the same
65
+ serializer doing 400 included records in 3 queries when no mechanism below is
66
+ active — the domain shape alone is not the problem.
67
+
68
+ ## Verified mechanisms
69
+
70
+ ### N+1 query drivers
71
+
72
+ 1. **Per-visit authorization re-check — FIXED** (with mechanism 2, see
73
+ "Optimizations landed"). The include walk runs the
74
+ `authorization_scope` hook once per parent record, per hop, per include path.
75
+ When the hook changes the scope's SQL, each visit fires one filter query.
76
+ Probe: 10 posts, `include=comments,comments.author`: 3 queries without a
77
+ hook, 73 with a narrowing hook. No memoization: 10 distinct users were
78
+ re-authorized 50 times. Shared prefixes multiply visits (each path re-visits
79
+ its whole prefix). Scaling: queries ≈ parents × path-visits.
80
+ Anchors: `include_filtering.rb` (`filter_loaded_records`, `narrowed?`),
81
+ `includes_serialization.rb`.
82
+ Baseline signature: 1,170× single-row authorization plucks in
83
+ `assignments_full_authz`.
84
+
85
+ 2. **Narrowing resource-default scopes re-check per visit — FIXED** (same
86
+ change as mechanism 1). A resource whose
87
+ `records` narrows the model scope (production: `MessageResource.records =
88
+ super.client_visible`) triggers the same per-visit re-check without any
89
+ authorization hook.
90
+ Baseline signature: 570× `SELECT remarks.id WHERE body IS NOT NULL AND id = ?`
91
+ in `assignments_full`; 3,270× at page 100.
92
+
93
+ 3. **ActiveStorage includes never preload — FIXED.** `filter_includable` kept
94
+ only include keys with an AR reflection; `has_many_attached :files` defines
95
+ `files_attachments`, not `files`, so the key dropped silently. Serialization
96
+ then probed attachments per record (2N+1 queries; 2,340 of the 2,550
97
+ queries in `gadgets_evidence_shape`). The fix maps the attachment key to
98
+ `{files_attachments: {blob: {}}}` in the preload hash and reads blobs
99
+ through the loaded associations. See "Optimizations landed" below.
100
+
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
113
+ materializes the whole filtered table as an Array before pagination, and
114
+ `scope_with_includes` bails on Arrays, so nothing preloads. Probe: 10 users,
115
+ `include=posts.comments`: 3 queries with a column sort, 41 with a virtual
116
+ sort; the primary SELECT has no LIMIT. Not in the sample production requests,
117
+ but a cliff any client can trigger.
118
+
119
+ ### Memory drivers
120
+
121
+ Measured attribution for `assignments_full` (133.9MB allocated): the top
122
+ allocation sites are ActiveSupport inflector string churn (24.9MB, driven by
123
+ uncached type-name inflection and `ResourceLoader` resolution per record and per
124
+ identifier), the include walk itself (`includes_serialization.rb`, 10.9MB), and
125
+ row materialization. At page 100 (884.5MB) the inflector alone allocates
126
+ 163MB and the include walk 68MB.
127
+
128
+ 1. **Shared included records serialize once per primary.** The dedupe by
129
+ `type-id` runs after full serialization, so a page of 50 primaries sharing
130
+ one included record serializes it 50 times and discards 49. Probe: the
131
+ shared-record request allocates 90% of what 50 distinct records allocate,
132
+ with 1 record in `included`. Multiplies every other per-record cost by page
133
+ size on shared subtrees.
134
+
135
+ 2. **`record.attributes` churn.** Exactly 3 fresh full-column hash
136
+ materializations per model-backed attribute per serialized record
137
+ (`model_has_attribute?` twice, value read once). Probe: 420 serialized
138
+ records produced 1,680 `#attributes` calls; the AR attribute machinery
139
+ accounts for ~21% of request allocations.
140
+
141
+ 3. **Uncached name resolution.** `ResourceLoader.find_for_model` and type-name
142
+ inflection run per record, per identifier, and per include-hop visit —
143
+ thousands of calls per request; the inflector is the single largest
144
+ allocation site in every heavy scenario.
145
+
146
+ 4. **Full-column AR materialization.** Fat text columns (document bodies,
147
+ remark bodies) load for every included record and dominate retained-during-
148
+ request memory; the response then holds the document again, and meta can hold
149
+ it a third time (production `PolicyVersionResource` serializes `html` in
150
+ attributes and meta; `PolicyResource#meta` adds a `preview` copy).
151
+
152
+ Retained-after-request memory stays under ~102MB in every scenario (25MB
153
+ outside the page-100 stress point): the bloat is
154
+ transient garbage plus heap high-water marks, not a leak.
155
+
156
+ ### Checked and cleared
157
+
158
+ The evaluation also refuted several suspicions, with measurements: pagination
159
+ LIMIT does not break include batching (the count query costs 1); the
160
+ `preload`-vs-`includes` decision is query-neutral; polymorphic and STI hops
161
+ batch correctly at load time; the meta resource instance is cheap (~2MB); the
162
+ attribute-transform dup chain never runs on GET; query tracking retains only
163
+ per-request SQL references and is off in production.
164
+
165
+ ## Optimizations applied
166
+
167
+ - **Name-resolution caching** (`ResourceLoader` + `TypeConversion`): memory
168
+ driver 3 above. Allocations drop 15-22% and median wall time drops 40-55%
169
+ across every scenario, with identical query counts. Page 100: 884.5MB ->
170
+ 694.1MB and 11.1s -> 5.2s. The inflector leaves the top allocation sites;
171
+ the include walk is now the largest jpie-owned allocator.
172
+ - **Shared include context** (dedupe before serialization): memory driver 1
173
+ above. A record reached from several primaries serializes once, not once per
174
+ primary. Allocations drop 4-10% on multi-primary pages (page 100: 884.5MB ->
175
+ 797.4MB) and stay flat on single-primary scenarios, where nothing is shared.
176
+ Query counts are identical. Pages with heavy cross-primary overlap (many
177
+ primaries sharing the same reference records) gain far more: the probe case
178
+ of 50 primaries sharing one record cut its included-serialization work 50x.
179
+ These figures come from the 884.5MB baseline, before name-resolution caching
180
+ landed. Re-run the benchmark on top of that change for the combined number.
181
+
182
+ ## Reading the numbers
183
+
184
+ - Query count is the primary regression metric. It transfers from sqlite to
185
+ postgres; wall time does not (sqlite has near-zero per-query latency, so the
186
+ same request is far slower against production postgres).
187
+ - `allocated_mb` (memory_profiler) is the primary memory metric. RSS deltas are
188
+ indicative only.
189
+ - The scaling scenarios pin the shape: queries and bytes grow linearly with
190
+ page size at a fixed tree, and with tree size at a fixed page. Cost ≈
191
+ parents × path-visits × per-visit mechanisms.
192
+
193
+ ## Optimizations landed
194
+
195
+ ### 1. Preload attachment includes (mechanism 3)
196
+
197
+ `filter_includable` now maps an attachment include key to the association pair
198
+ ActiveStorage defines for it, and `blobs_for` reads blobs through the loaded
199
+ associations. Regression spec: `spec/jsonapi_spec/query_parameters/`
200
+ `attachment_include_preload_spec.rb` pins `GET /users?include=avatar` at 3
201
+ queries for any record count (was 2N+1).
202
+
203
+ Before/after (large dataset; scenarios without attachment includes are
204
+ query-identical, confirming no behavior change outside the mechanism):
205
+
206
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
207
+ |---|---|---|---|---|
208
+ | gadgets_evidence_shape | 2,550 | 146 (−94%) | 132.3 | 89.5 |
209
+ | assignments_single_conversation | 1,728 | 330 (−81%) | 147.4 | 119.5 |
210
+ | assignments_single_conversation + authz | 4,029 | 2,631 | 176.8 | 148.9 |
211
+ | assignments_full (no attachment path) | 782 | 782 | 133.8 | 133.8 |
212
+
213
+ The 330 remaining queries in the `/policies` shape are mostly the narrowing
214
+ resource-scope re-check (mechanism 2) — the next target.
215
+
216
+ ### 2. Vet include-filter verdicts once per request (mechanisms 1 and 2)
217
+
218
+ A request-scoped `IncludeFilterCache` computes each related class's filter
219
+ scopes once, gives each record id one verdict per (class, scope kind), and is
220
+ warmed from the already-loaded include tree: one vetting query per class
221
+ instead of one per parent record per path visit. Pure memoization — the
222
+ allowed set is identical, id for id; the IDOR regression specs hold
223
+ unchanged. Regression spec: `include_filter_batching_spec.rb`.
224
+
225
+ #### First recording (2026-08-14, base: the attachment-preload branch)
226
+
227
+ Measured before the dedupe-included change (#66) landed on main. Kept for the
228
+ record — the query counts still hold, the allocation figures do not.
229
+
230
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
231
+ |---|---|---|---|---|
232
+ | assignments_full | 782 | 153 (−80%) | 133.8 | 111.8 |
233
+ | assignments_full + authz | 3,403 | 176 (−95%) | 168.2 | 114.0 |
234
+ | assignments_single_conversation | 330 | 22 (−93%) | 119.5 | 102.8 |
235
+ | assignments_single_conversation + authz | 2,631 | 34 (−99%) | 148.9 | 104.7 |
236
+ | assignments_page_100 | 5,102 | 1,233 (−76%) | 884.5 | 741.0 |
237
+ | gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 89.5 | 79.2 |
238
+
239
+ #### Second recording (2026-08-17, base: main at 8bff12d, after #64 and #66)
240
+
241
+ Re-measured after the rebase onto main. Both columns come from one sequential
242
+ run of `rake benchmark` on the same machine, large dataset.
243
+
244
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after | ms(med) before | ms(med) after |
245
+ |---|---|---|---|---|---|---|
246
+ | assignments_full | 782 | 153 (−80%) | 98.8 | 80.4 | 708.6 | 522.3 |
247
+ | assignments_full + authz | 3,403 | 176 (−95%) | 133.6 | 82.8 | 1,208.1 | 473.9 |
248
+ | assignments_single_conversation | 330 | 22 (−93%) | 92.6 | 79.6 | 534.1 | 376.4 |
249
+ | assignments_single_conversation + authz | 2,631 | 34 (−99%) | 122.4 | 81.6 | 997.8 | 402.4 |
250
+ | assignments_page_100 | 5,102 | 1,233 (−76%) | 632.2 | 512.3 | 4,800.1 | 3,128.2 |
251
+ | gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 64.9 | 57.9 | 443.6 | 340.2 |
252
+
253
+ Every query count reproduces the first recording exactly, which confirms #66
254
+ changed no query counts in these scenarios. The allocation figures dropped on
255
+ both sides, because #66 cut allocations independently — so the earlier "before"
256
+ column overstated the saving this change alone delivers.
257
+
258
+ Cumulative against the original baseline: the /policies shape is down from
259
+ 1,728 queries to 22; the /evidence_checks shape from 2,550 to 146; page 100
260
+ from 5,102 to 1,233. The remaining residue is mostly mechanism 4 (per-record
261
+ meta lookups: 120 of the 153 queries in assignments_full).
data/README.md CHANGED
@@ -532,7 +532,7 @@ end
532
532
 
533
533
  ### Resource-Level Meta
534
534
 
535
- Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps in ISO8601 format if the model responds to these methods.
535
+ Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps if the model responds to these methods. The gem passes the raw `Time` to the JSON encoder, so Rails renders ISO8601 at the precision `ActiveSupport::JSON::Encoding.time_precision` sets — three sub-second digits by default. A timestamp you also expose as an attribute therefore reads the same in both places.
536
536
 
537
537
  You can also define custom meta in two ways:
538
538
 
@@ -578,8 +578,8 @@ The instance method has access to the model instance via `resource`. Custom meta
578
578
  "email": "john@example.com"
579
579
  },
580
580
  "meta": {
581
- "created_at": "2024-01-15T10:30:00Z",
582
- "updated_at": "2024-01-15T10:30:00Z",
581
+ "created_at": "2024-01-15T10:30:00.000Z",
582
+ "updated_at": "2024-01-15T10:30:00.000Z",
583
583
  "name_length": 8,
584
584
  "custom_field": "value"
585
585
  },
data/Rakefile CHANGED
@@ -7,6 +7,11 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  task default: :spec
9
9
 
10
+ desc "Run the performance benchmark suite (writes tmp/request_benchmark_results.json)"
11
+ task :benchmark do
12
+ sh({ "PERFORMANCE" => "1" }, "bundle", "exec", "rspec", "spec/benchmarks", "--format", "progress")
13
+ end
14
+
10
15
  # Override release task to require OTP code
11
16
  # Usage: GEM_HOST_OTP_CODE=123456 rake release
12
17
  Rake::Task["release"].enhance do
@@ -11,19 +11,36 @@ module JSONAPI
11
11
  attachment = record.public_send(attachment_name)
12
12
  return nil unless attachment.respond_to?(:attached?)
13
13
 
14
- attachment.attached? ? serialize_attached(attachment) : empty_attachment_value(attachment)
14
+ blobs = blobs_for(attachment_name, record)
15
+ return blobs.map { |blob| serialize_blob_identifier(blob) } if attachment.is_a?(::ActiveStorage::Attached::Many)
16
+
17
+ blobs.first ? serialize_blob_identifier(blobs.first) : nil
15
18
  end
16
19
 
17
- def serialize_attached(attachment)
20
+ # Blobs for an attachment. Reads through the ActiveStorage associations
21
+ # when the include preloader loaded them (zero queries); otherwise falls
22
+ # back to the probing reader (attached? + blobs, one query each).
23
+ def blobs_for(attachment_name, record)
24
+ attachment = record.public_send(attachment_name)
25
+ return [] unless attachment.respond_to?(:attached?)
26
+
18
27
  if attachment.is_a?(::ActiveStorage::Attached::Many)
19
- return attachment.blobs.map { |blob| serialize_blob_identifier(blob) }
28
+ many_blobs(attachment, record.association(:"#{attachment_name}_attachments"))
29
+ else
30
+ one_blob(attachment, record.association(:"#{attachment_name}_attachment"))
20
31
  end
32
+ end
33
+
34
+ def many_blobs(attachment, association)
35
+ return association.target.filter_map(&:blob) if association.loaded?
21
36
 
22
- serialize_blob_identifier(attachment.blob)
37
+ attachment.attached? ? attachment.blobs.to_a : []
23
38
  end
24
39
 
25
- def empty_attachment_value(attachment)
26
- attachment.is_a?(::ActiveStorage::Attached::Many) ? [] : nil
40
+ def one_blob(attachment, association)
41
+ return [association.target&.blob].compact if association.loaded?
42
+
43
+ attachment.attached? ? [attachment.blob].compact : []
27
44
  end
28
45
 
29
46
  def serialize_blob_identifier(blob)
@@ -38,7 +38,8 @@ module JSONAPI
38
38
 
39
39
  related_model_class = type_value.constantize
40
40
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
41
- record = related_resource_class.records.find(id_value)
41
+ base = apply_relationship_authorization(related_resource_class.records, related_model_class)
42
+ record = base.find(id_value)
42
43
 
43
44
  association.collection? ? Array(record) : record
44
45
  end
@@ -52,17 +53,31 @@ module JSONAPI
52
53
  def fetch_polymorphic_has_many_through_resource_records(association)
53
54
  association_instance = @resource.association(@relationship_name)
54
55
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
55
- related_resource_class.records.merge(association_instance.scope)
56
+ base = apply_relationship_authorization(related_resource_class.records, association.klass)
57
+ association_instance.scope.merge(base)
56
58
  end
57
59
 
58
60
  def fetch_non_polymorphic_related_through_resource_records(association)
59
61
  association_instance = @resource.association(@relationship_name)
60
62
  related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
61
- scope = related_resource_class.records.merge(association_instance.scope)
63
+ base = apply_relationship_authorization(related_resource_class.records, association.klass)
64
+ scope = association_instance.scope.merge(base)
62
65
 
63
66
  association.collection? ? scope : scope.first
64
67
  end
65
68
 
69
+ # A relationships response (`/:type/:id/relationships/:rel`) lists the related
70
+ # records identified by linkage. Route the related base scope through the
71
+ # configured authorization_scope hook, exactly as the primary collection and
72
+ # `?include=` sideloads already do, so linkage never names a record the reader
73
+ # cannot fetch. No configured hook leaves the scope untouched.
74
+ def apply_relationship_authorization(scope, model_class)
75
+ handler = JSONAPI.configuration.authorization_scope
76
+ return scope unless handler
77
+
78
+ handler.call(controller: self, scope:, action: :index, model_class:)
79
+ end
80
+
66
81
  def serialize_related(related, association)
67
82
  return serialize_collection_relationship(related, association) if association.collection?
68
83
 
@@ -32,12 +32,27 @@ module JSONAPI
32
32
  def filter_includable(hash, klass)
33
33
  hash.each_with_object({}) do |(key, value), filtered|
34
34
  assoc = klass.reflect_on_association(key)
35
- next unless assoc
36
-
37
- filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
35
+ if assoc
36
+ filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
37
+ elsif (attachment_assoc = attachment_association_name(klass, key))
38
+ # An attachment include carries no AR reflection under its own name
39
+ # (has_many_attached :files defines files_attachments, not files).
40
+ # Preload the association pair ActiveStorage defines for it, so the
41
+ # serializer reads blobs without one probe query per record.
42
+ filtered[attachment_assoc] = { blob: {} }
43
+ end
38
44
  end
39
45
  end
40
46
 
47
+ def attachment_association_name(klass, key)
48
+ return nil unless defined?(::ActiveStorage) && klass.respond_to?(:reflect_on_attachment)
49
+
50
+ reflection = klass.reflect_on_attachment(key)
51
+ return nil unless reflection
52
+
53
+ reflection.macro == :has_one_attached ? :"#{key}_attachment" : :"#{key}_attachments"
54
+ end
55
+
41
56
  def preload_required?(hash, klass)
42
57
  hash.any? do |key, value|
43
58
  assoc = klass.reflect_on_association(key)
@@ -7,8 +7,10 @@ module JSONAPI
7
7
  include IncludePreloading
8
8
 
9
9
  def serialize_resource(resource)
10
- JSONAPI::Serializer.new(resource, authorization_context: self).to_hash(
11
- include: parse_include_param,
10
+ includes = parse_include_param
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,
12
14
  fields: parse_fields_param,
13
15
  document_meta: jsonapi_document_meta,
14
16
  )
@@ -28,28 +30,68 @@ module JSONAPI
28
30
 
29
31
  private
30
32
 
33
+ # One IncludeContext spans the whole page, so a record reached from
34
+ # several primaries serializes once, not once per primary. The final
35
+ # type-id pass keeps the response contract: it drops the rare collision
36
+ # where two model classes serialize to the same type and id.
31
37
  def serialize_resources_with_includes(resources, includes, fields)
32
- all_included = []
33
- processed = Set.new
38
+ shared_context = JSONAPI::Serialization::IncludeContext.new(
39
+ fields: fields, included_records: [], processed: Set.new, all_includes: nil,
40
+ )
41
+ cache = build_include_filter_cache(resources, includes)
34
42
 
35
43
  data = resources.map do |r|
36
- result = serialize_single(r, includes, fields)
37
- collect_included(result, all_included, processed)
38
- result[:data]
44
+ serialize_single(r, includes, fields, shared_context, cache)[:data]
39
45
  end
40
46
 
41
- [data, all_included]
47
+ [data, dedupe_included(shared_context.included_records)]
42
48
  end
43
49
 
44
- def serialize_single(resource, includes, fields)
45
- JSONAPI::Serializer.new(resource, authorization_context: self).to_hash(include: includes, fields:,
46
- document_meta: nil,)
50
+ def serialize_single(resource, includes, fields, include_context = nil, cache = nil)
51
+ JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache)
52
+ .to_hash(include: includes, fields:, document_meta: nil, include_context:)
47
53
  end
48
54
 
49
- def collect_included(result, all_included, processed)
50
- (result[:included] || []).each do |inc|
55
+ # One filter cache per request, warmed from the loaded include tree:
56
+ # every included record gets its scope verdict in one query per class,
57
+ # and the serializers answer their per-visit checks from memory.
58
+ def build_include_filter_cache(resources, includes)
59
+ cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self)
60
+ includes.each do |path|
61
+ current = resources
62
+ path.split(".").each do |part|
63
+ current = warm_include_level(cache, current, part.to_sym)
64
+ break if current.empty?
65
+ end
66
+ end
67
+ cache
68
+ end
69
+
70
+ def warm_include_level(cache, records, association_name)
71
+ targets = records.flat_map { |record| loaded_association_targets(record, association_name) }
72
+ targets.group_by(&:class).each { |klass, recs| cache.warm(klass, recs.filter_map(&:id)) }
73
+ targets
74
+ end
75
+
76
+ # Attachment names carry no AR reflection, and unloaded associations go
77
+ # through the scoped-relation fallback instead of filtering: both skip.
78
+ def loaded_association_targets(record, association_name)
79
+ return [] unless record.class.reflect_on_association(association_name)
80
+
81
+ association = record.association(association_name)
82
+ return [] unless association.loaded?
83
+
84
+ target = association.target
85
+ target.respond_to?(:to_a) ? target.to_a.compact : Array(target).compact
86
+ end
87
+
88
+ def dedupe_included(included_records)
89
+ all_included = []
90
+ processed = Set.new
91
+ included_records.each do |inc|
51
92
  add_unique_included(inc, all_included, processed)
52
93
  end
94
+ all_included
53
95
  end
54
96
 
55
97
  def add_unique_included(inc, all_included, processed)
@@ -100,8 +100,13 @@ module JSONAPI
100
100
  # before app controllers are available, causing FrozenError or NameError
101
101
  # 3. We register with reloader.to_prepare for code reloading in development
102
102
  config.after_initialize do |app|
103
- # Register for code reloading in development
103
+ # Register for code reloading in development. Clear the name-keyed
104
+ # resolution caches first so reloaded classes re-resolve; applications
105
+ # that pre-populate ResourceLoader caches in their own to_prepare blocks
106
+ # run after this and re-apply their entries.
104
107
  app.reloader.to_prepare do
108
+ JSONAPI::ResourceLoader.reset_cache!
109
+ JSONAPI::TypeConversion.reset_cache!
105
110
  Railtie.setup_base_controllers
106
111
  end
107
112
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  class ResourceLoader
5
7
  class MissingResourceClass < JSONAPI::Error
@@ -16,7 +18,30 @@ module JSONAPI
16
18
  end
17
19
  end
18
20
 
21
+ # Resolution runs the inflector and safe_constantize on every call, and the
22
+ # serializer calls it once per record, per identifier, and per include-hop
23
+ # visit. Cache by name, never by class object, so a reloaded class cannot be
24
+ # retained. Only successful resolutions are cached. The railtie clears both
25
+ # caches on code reload.
26
+ @find_cache = Concurrent::Map.new
27
+ @model_cache = Concurrent::Map.new
28
+
29
+ def self.reset_cache!
30
+ @find_cache.clear
31
+ @model_cache.clear
32
+ end
33
+
19
34
  def self.find(resource_type, namespace: nil)
35
+ key = "#{namespace}|#{resource_type}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
36
+ cached = @find_cache[key]
37
+ return cached if cached
38
+
39
+ klass = resolve(find_candidates(resource_type, namespace)) ||
40
+ raise(MissingResourceClass.new(resource_type, namespace:))
41
+ @find_cache[key] = klass
42
+ end
43
+
44
+ def self.find_candidates(resource_type, namespace)
20
45
  candidates = []
21
46
 
22
47
  # Namespaced resource, e.g. "widgets" with namespace "api/v1" → API::V1::WidgetResource
@@ -28,16 +53,24 @@ module JSONAPI
28
53
  nil,)
29
54
  end
30
55
 
31
- resolve(candidates) || raise(MissingResourceClass.new(resource_type, namespace:))
56
+ candidates
32
57
  end
33
58
 
34
59
  def self.find_for_model(model_class, namespace: nil)
35
60
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
36
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}"
65
+ cached = @model_cache[key]
66
+ return cached if cached
67
+
37
68
  effective_namespace = namespace || extract_namespace_from_model(model_class)
38
69
  candidates = model_candidates(model_class, effective_namespace)
39
70
 
40
- resolve(candidates) || raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
71
+ klass = resolve(candidates) ||
72
+ raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
73
+ @model_cache[key] = klass
41
74
  end
42
75
 
43
76
  def self.build_resource_class_name(resource_type, namespace)
@@ -8,6 +8,7 @@ module JSONAPI
8
8
  controller ||= detect_controller(resource_name, namespace)
9
9
  defaults = build_jsonapi_defaults(defaults, resource_name, namespace)
10
10
  options[:only] = :index if sti
11
+ options = without_form_actions(options)
11
12
 
12
13
  JSONAPI::ResourceLoader.find(resource_name, namespace:)
13
14
  define_resource_routes(resource, controller, defaults, options, &)
@@ -20,6 +21,15 @@ module JSONAPI
20
21
 
21
22
  private
22
23
 
24
+ # `new` and `edit` render the HTML forms of a scaffold. A JSON:API endpoint
25
+ # answers neither, so the routes only pad the route table and any document
26
+ # generated from it. A caller that names `only:` keeps full control.
27
+ def without_form_actions(options)
28
+ return options if options.key?(:only)
29
+
30
+ options.merge(except: Array(options[:except]) | %i[new edit])
31
+ end
32
+
23
33
  def extract_namespace_from_scope
24
34
  @scope[:module]&.to_s.presence
25
35
  end
@@ -3,51 +3,17 @@
3
3
  module JSONAPI
4
4
  module Serialization
5
5
  module IncludeFiltering
6
+ # When the authorization_scope hook or the resource's records scope
7
+ # narrows the relation, the eager-loaded set — preloaded through
8
+ # acts_as_tenant alone — must be re-checked against the narrowed scope,
9
+ # so a relationship never surfaces a record the related endpoint
10
+ # denies. The request-scoped cache memoizes the scopes and the per-id
11
+ # verdicts, so each record is vetted at most once per request.
6
12
  def filter_loaded_records(association, related_klass)
7
13
  loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
8
14
  return [] if loaded_array.empty?
9
15
 
10
- base_scope = ResourceLoader.find_for_model(related_klass).records
11
- authorized_scope = apply_include_authorization(base_scope, related_klass)
12
-
13
- # When the authorization_scope hook narrows the relation (joins/subqueries a
14
- # where-hash can't express), the eager-loaded set — preloaded through acts_as_tenant
15
- # alone — must be re-checked against the authorized scope in the database, so a
16
- # relationship never surfaces a record the related endpoint denies. An unchanged
17
- # scope keeps the in-memory, query-free records-only filtering.
18
- return filter_by_query(loaded_array, authorized_scope) if narrowed?(base_scope, authorized_scope)
19
- return loaded_array if base_scope.where_clause.empty?
20
-
21
- filter_by_where_hash(loaded_array, base_scope, related_klass)
22
- end
23
-
24
- def narrowed?(base_scope, authorized_scope)
25
- authorized_scope.to_sql != base_scope.to_sql
26
- end
27
-
28
- def filter_by_where_hash(loaded_array, resource_scope, related_klass)
29
- hash = where_values_hash_for_scope(resource_scope, related_klass)
30
- return filter_by_query(loaded_array, resource_scope) if hash.blank?
31
-
32
- loaded_array.select { |r| record_matches_where_hash?(r, hash) }
33
- end
34
-
35
- def where_values_hash_for_scope(resource_scope, related_klass)
36
- resource_scope.where_values_hash(related_klass.table_name)
37
- rescue StandardError
38
- {}
39
- end
40
-
41
- def record_matches_where_hash?(record, hash)
42
- hash.all? do |attr, val|
43
- r_val = record.read_attribute(attr)
44
- val.is_a?(Array) ? val.include?(r_val) : r_val == val
45
- end
46
- end
47
-
48
- def filter_by_query(loaded_array, resource_scope)
49
- valid_ids = resource_scope.where(id: loaded_array.filter_map(&:id)).pluck(:id).to_set
50
- loaded_array.select { |r| valid_ids.include?(r.id) }
16
+ include_filter_cache.filter(loaded_array, related_klass)
51
17
  end
52
18
  end
53
19
  end
@@ -13,22 +13,18 @@ module JSONAPI
13
13
  include IncludePathHelpers
14
14
  include IncludeFiltering
15
15
 
16
- def serialize_included(includes, fields = {})
16
+ def serialize_included(includes, fields = {}, context: nil)
17
17
  all_includes = normalize_include_paths(includes)
18
- return [] if all_includes.empty?
18
+ return context&.included_records || [] if all_includes.empty?
19
19
 
20
- ctx = build_include_context(fields, all_includes)
20
+ ctx = context || build_include_context(fields, all_includes)
21
+ ctx.all_includes ||= all_includes
21
22
  all_includes.each { |path| serialize_include_path(record, path, ctx, path_from_root: "") }
22
23
  ctx.included_records
23
24
  end
24
25
 
25
26
  def build_include_context(fields, all_includes)
26
- IncludeContext.new(
27
- fields: fields,
28
- included_records: [],
29
- processed: Set.new,
30
- all_includes: all_includes,
31
- )
27
+ IncludeContext.new(fields:, included_records: [], processed: Set.new, all_includes:)
32
28
  end
33
29
 
34
30
  private
@@ -116,11 +112,7 @@ module JSONAPI
116
112
  end
117
113
 
118
114
  def get_active_storage_records(current_record, association_name)
119
- attachment = current_record.public_send(association_name)
120
- return [] unless attachment.respond_to?(:attached?) && attachment.attached?
121
- return attachment.blobs.to_a if attachment.is_a?(::ActiveStorage::Attached::Many)
122
-
123
- [attachment.blob].compact
115
+ JSONAPI::ActiveStorage::Serialization.blobs_for(association_name, current_record)
124
116
  end
125
117
 
126
118
  def serialize_and_process_record(related_record, path_to_record, ctx, parent_record: nil, association_name: nil)
@@ -128,7 +120,7 @@ module JSONAPI
128
120
 
129
121
  requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
130
122
  serializer = self.class.new(related_record, parent_record:, association_name:,
131
- authorization_context:,)
123
+ authorization_context:, include_filter_cache:,)
132
124
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
133
125
  ctx.processed.add(build_record_key(related_record))
134
126
  end
@@ -44,16 +44,19 @@ module JSONAPI
44
44
 
45
45
  def default_timestamp_meta
46
46
  {}.tap do |meta|
47
- meta[:created_at] = format_timestamp(:created_at)
48
- meta[:updated_at] = format_timestamp(:updated_at)
47
+ meta[:created_at] = timestamp(:created_at)
48
+ meta[:updated_at] = timestamp(:updated_at)
49
49
  end.compact
50
50
  end
51
51
 
52
- def format_timestamp(attr)
52
+ # Returns the raw Time so the JSON encoder formats it. Formatting here with
53
+ # `iso8601` drops the sub-second digits the encoder keeps, which puts two
54
+ # spellings of one instant in a single document whenever a resource also
55
+ # exposes the timestamp as an attribute.
56
+ def timestamp(attr)
53
57
  return unless record.respond_to?(attr)
54
58
 
55
- value = record.public_send(attr)
56
- value&.iso8601
59
+ record.public_send(attr)
57
60
  end
58
61
  end
59
62
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Serialization
5
+ # Request-scoped memoization for include filtering.
6
+ #
7
+ # The include walk re-checks the related resource's scope (and the
8
+ # authorization hook) against the loaded records once per parent record,
9
+ # per hop, per include path. Within one request those verdicts cannot
10
+ # change: the scopes are functions of the related class and the
11
+ # controller. So compute each class's filter data once, and vet each
12
+ # record id at most once. Pure memoization — the allowed set is
13
+ # identical, id for id, to the per-visit filtering it replaces.
14
+ class IncludeFilterCache
15
+ Entry = Struct.new(:base_scope, :authorized_scope, :narrowed, :where_clause_empty, :where_hash,
16
+ keyword_init: true,)
17
+
18
+ def initialize(authorization_context: nil)
19
+ @authorization_context = authorization_context
20
+ @entries = {}
21
+ @verdicts = {}
22
+ end
23
+
24
+ # Filters loaded records with the same rules as the per-visit filtering:
25
+ # a narrowed authorization scope vets ids in the database; a resource
26
+ # scope with no where clause keeps everything; a where-hash-expressible
27
+ # scope matches in memory; anything else vets ids in the database
28
+ # against the resource scope.
29
+ def filter(records, klass)
30
+ entry = entry_for(klass)
31
+ return filter_by_ids(records, klass, :authorized, entry.authorized_scope) if entry.narrowed
32
+ return records if entry.where_clause_empty
33
+ return filter_by_ids(records, klass, :base, entry.base_scope) if entry.where_hash.blank?
34
+
35
+ records.select { |record| matches_where_hash?(record, entry.where_hash) }
36
+ end
37
+
38
+ # Vets ids ahead of the walk, one query per class, so the per-visit
39
+ # checks answer from memory. Safe to skip: #filter queries lazily.
40
+ def warm(klass, ids)
41
+ entry = entry_for(klass)
42
+ if entry.narrowed
43
+ vet_ids(klass, :authorized, entry.authorized_scope, ids)
44
+ elsif !entry.where_clause_empty && entry.where_hash.blank?
45
+ vet_ids(klass, :base, entry.base_scope, ids)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def entry_for(klass)
52
+ @entries[klass] ||= build_entry(klass)
53
+ end
54
+
55
+ def build_entry(klass)
56
+ base_scope = ResourceLoader.find_for_model(klass).records
57
+ authorized_scope = apply_authorization(base_scope, klass)
58
+ Entry.new(
59
+ base_scope: base_scope,
60
+ authorized_scope: authorized_scope,
61
+ narrowed: authorized_scope.to_sql != base_scope.to_sql,
62
+ where_clause_empty: base_scope.where_clause.empty?,
63
+ where_hash: where_hash_for(base_scope, klass),
64
+ )
65
+ end
66
+
67
+ # Mirrors the serializer's include authorization: no context (serializer
68
+ # used outside a request) or no configured hook leaves the scope untouched.
69
+ def apply_authorization(scope, klass)
70
+ return scope unless @authorization_context
71
+
72
+ handler = JSONAPI.configuration.authorization_scope
73
+ return scope unless handler
74
+
75
+ handler.call(controller: @authorization_context, scope:, action: :index, model_class: klass)
76
+ end
77
+
78
+ def where_hash_for(scope, klass)
79
+ scope.where_values_hash(klass.table_name)
80
+ rescue StandardError
81
+ {}
82
+ end
83
+
84
+ def matches_where_hash?(record, hash)
85
+ hash.all? do |attr, val|
86
+ r_val = record.read_attribute(attr)
87
+ val.is_a?(Array) ? val.include?(r_val) : r_val == val
88
+ end
89
+ end
90
+
91
+ def filter_by_ids(records, klass, kind, scope)
92
+ allowed = vet_ids(klass, kind, scope, records.filter_map(&:id))
93
+ records.select { |record| allowed.include?(record.id) }
94
+ end
95
+
96
+ # One verdict per id per (class, scope kind). Queries only ids that have
97
+ # no verdict yet; repeats answer from memory.
98
+ def vet_ids(klass, kind, scope, ids)
99
+ verdicts = (@verdicts[[klass, kind]] ||= {})
100
+ record_new_verdicts(verdicts, scope, ids)
101
+ ids.each_with_object(Set.new) { |id, set| set << id if verdicts[id] }
102
+ end
103
+
104
+ def record_new_verdicts(verdicts, scope, ids)
105
+ unseen = ids.reject { |id| verdicts.key?(id) }
106
+ return if unseen.empty?
107
+
108
+ valid = scope.where(id: unseen).pluck(:id).to_set
109
+ unseen.each { |id| verdicts[id] = valid.include?(id) }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "include_filter_cache"
3
4
  require_relative "concerns/attributes_serialization"
4
5
  require_relative "concerns/relationships_serialization"
5
6
  require_relative "concerns/links_serialization"
@@ -35,23 +36,28 @@ module JSONAPI
35
36
  end
36
37
 
37
38
  def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil,
38
- authorization_context: nil)
39
+ authorization_context: nil, include_filter_cache: nil)
39
40
  @record = record
40
41
  @definition = definition || ResourceLoader.find_for_model(record.class)
41
42
  @base_definition = base_definition
42
43
  @parent_record = parent_record
43
44
  @association_name = association_name
44
45
  @authorization_context = authorization_context
46
+ @include_filter_cache = include_filter_cache
45
47
  @sti_subclass = nil
46
48
  end
47
49
 
48
- def to_hash(include: [], fields: {}, document_meta: nil)
50
+ # include_context shares one IncludeContext across a whole collection: a
51
+ # record reached from several primaries then serializes once instead of
52
+ # once per primary. Callers that pass it read the accumulated records from
53
+ # the context, not from each result's :included.
54
+ def to_hash(include: [], fields: {}, document_meta: nil, include_context: nil)
49
55
  include_paths = normalize_include_paths(include)
50
56
  top_level_relationships = include_paths_to_relationship_names(include_paths, "")
51
57
  {
52
58
  jsonapi: jsonapi_object,
53
59
  data: serialize_record(fields, requested_relationships: top_level_relationships),
54
- included: serialize_included(include_paths, fields),
60
+ included: serialize_included(include_paths, fields, context: include_context),
55
61
  meta: document_meta,
56
62
  }.compact
57
63
  end
@@ -71,6 +77,12 @@ module JSONAPI
71
77
 
72
78
  attr_reader :record, :definition, :parent_record, :association_name, :authorization_context
73
79
 
80
+ # Shared per request when the controller passes one in; a standalone
81
+ # serializer builds its own, which still dedupes within its own walk.
82
+ def include_filter_cache
83
+ @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context: authorization_context)
84
+ end
85
+
74
86
  def base_definition
75
87
  @base_definition ||= definition
76
88
  end
@@ -1,7 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  module TypeConversion
7
+ # Type names derive only from a class name plus a resolved format, so the
8
+ # serializer re-computes identical strings once per record and per
9
+ # relationship identifier. Cache frozen results keyed by name and format.
10
+ # The railtie clears the cache on code reload.
11
+ @type_name_cache = Concurrent::Map.new
12
+
13
+ def self.reset_cache!
14
+ @type_name_cache.clear
15
+ end
16
+
17
+ def self.type_name_cache
18
+ @type_name_cache
19
+ end
20
+
5
21
  module_function
6
22
 
7
23
  def type_to_class_name(type, namespace: nil)
@@ -19,18 +35,20 @@ module JSONAPI
19
35
 
20
36
  def model_type_name(model_class, format: nil)
21
37
  format ||= JSONAPI.configuration.namespace_type_format
38
+ name = model_class.name
39
+ return format_type_name(name.underscore.pluralize, format) if name.nil?
22
40
 
23
- full_name = model_class.name.underscore.pluralize
24
-
25
- format_type_name(full_name, format)
41
+ cache = TypeConversion.type_name_cache
42
+ cache["m|#{name}|#{format}"] ||= format_type_name(name.underscore.pluralize, format).freeze
26
43
  end
27
44
 
28
45
  def resource_type_name(definition_class, format: nil)
29
46
  format ||= resolve_type_format(definition_class)
47
+ name = definition_class.name
48
+ return format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format) if name.nil?
30
49
 
31
- full_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
32
-
33
- format_type_name(full_name, format)
50
+ cache = TypeConversion.type_name_cache
51
+ cache["r|#{name}|#{format}"] ||= format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format).freeze
34
52
  end
35
53
 
36
54
  def format_type_name(full_name, format)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.7.0"
4
+ VERSION = "3.8.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: 3.7.0
4
+ version: 3.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -166,6 +166,7 @@ files:
166
166
  - lib/json_api/serialization/concerns/relationships_deserialization.rb
167
167
  - lib/json_api/serialization/concerns/relationships_serialization.rb
168
168
  - lib/json_api/serialization/deserializer.rb
169
+ - lib/json_api/serialization/include_filter_cache.rb
169
170
  - lib/json_api/serialization/include_path_helpers.rb
170
171
  - lib/json_api/serialization/serializer.rb
171
172
  - lib/json_api/support/active_storage_support.rb