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 +4 -4
- data/.rubocop.yml +58 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/PERFORMANCE_BASELINE.md +258 -99
- data/README.md +3 -3
- data/Rakefile +5 -0
- data/lib/json_api/active_storage/serialization.rb +23 -6
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +18 -3
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +18 -3
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +55 -13
- data/lib/json_api/railtie.rb +6 -1
- data/lib/json_api/resources/resource_loader.rb +35 -2
- data/lib/json_api/routing.rb +10 -0
- data/lib/json_api/serialization/concerns/include_filtering.rb +7 -41
- data/lib/json_api/serialization/concerns/includes_serialization.rb +7 -15
- data/lib/json_api/serialization/concerns/meta_serialization.rb +8 -5
- data/lib/json_api/serialization/include_filter_cache.rb +113 -0
- data/lib/json_api/serialization/serializer.rb +15 -3
- data/lib/json_api/support/type_conversion.rb +24 -6
- data/lib/json_api/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9e7099c27c6aec0de41a095baa563dbc63eb934b44ab8195a9e3a51f5152c4e
|
|
4
|
+
data.tar.gz: 86ee7fbdc3cf8414d9829bbf4f42ac7e016603f8a884a0b674d8e9b869cf1b8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
jpie (3.
|
|
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)
|
data/PERFORMANCE_BASELINE.md
CHANGED
|
@@ -1,102 +1,261 @@
|
|
|
1
|
-
# jpie
|
|
2
|
-
|
|
3
|
-
This document
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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:
|
|
582
|
-
"updated_at": "2024-01-15T10:30:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
attachment.attached? ? attachment.blobs.to_a : []
|
|
23
38
|
end
|
|
24
39
|
|
|
25
|
-
def
|
|
26
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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)
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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)
|
data/lib/json_api/routing.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
48
|
-
meta[: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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/json_api/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jpie
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
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
|