feat!: opt-in @self.files via _extend on rendered objects#1369
Conversation
BREAKING CHANGE: @self.files no longer contains full file metadata by default. Default is now a lightweight list of integer file IDs (e.g. [123, 456, 789]). Add _extend[]=@self.files (or the equivalent shorthand _extend[]=_files) to receive the full metadata payload. Migration is one query parameter on the consumer side. Why: today, show endpoints that route through RenderObject::renderEntity unconditionally pay the FileMapper + SystemTag lookup cost on every response (even when the consumer never uses the file information), while list endpoints that bypass renderEntity emit no @self.files field at all. Consumers see different shapes across endpoints and cannot opt in or out. The new contract is symmetric and explicit, and the lightweight default is strictly cheaper than today's show behavior. What changed - FileMapper::getFileIdsForObjects(string[] $uuids): new batched lookup (two queries regardless of input size) backing the lightweight default. - RenderObject::renderEntity now gates renderFiles() on whether _extend covers @self.files. Body of renderFiles() is unchanged. When extend is absent, setLightweightFileIds() attaches the integer ID list, reusing a transient batch cache populated by renderEntities() so list pages do not go N+1. - normalizeMap gains '_files' => '@self.files' so both spellings are equivalent, mirroring the existing _schema/_register normalization. - QueryHandler::searchObjectsPaginatedDatabase enriches cheap-path rows (no extend / fields / filter / propAuth) with lightweight IDs via a single batched query before returning. Complex-rendering rows inherit the new behavior automatically through renderEntities. - ObjectService::searchObjectsPaginated applies the same enrichment on the SOLR/index branch. (SOLR + _extend[]=@self.files falls back to IDs; pre-existing limitation that the SOLR path does not route rows through renderEntities, documented inline.) - attachLightweightFilesToRows() handles both ObjectEntity and array row shapes for tolerance across pipelines. Performance - Default show: strictly cheaper (no file metadata work). - Default list: +1 batched query per request, regardless of page size. - Extend on show: unchanged. - Extend on list: per-row file/tag lookups; documented as heavily discouraged with a verbatim warning in docs/api/objects.md. Tests - Unit tests for FileMapper::getFileIdsForObjects input validation. - Integration tests deferred to a follow-up (Newman fixture with attachments). Out of scope: renderFileProperties (schema-property file hydration), PublicationsController::attachments() (separate explicit endpoint), and opencatalogi documentation (docs-only follow-up against opencatalogi). OpenSpec change: openspec/changes/opt-in-files-extend Refs #1358 A few options if you want it tighter: - One-liner subject only (matches several of your recent commits): feat!: opt-in @self.files via _extend on rendered objects - Drop the ! and use the feat: prefix only if you don't follow conventional-commits strictly here — the BREAKING CHANGE: block in the body still surfaces the semver impact. - Trim the body to just the BREAKING CHANGE block + Refs line if you prefer pull-request descriptions to carry the detail and commit messages to stay terse. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WilcoLouwerse
left a comment
There was a problem hiding this comment.
1 blocker requires a fix before merge: batchFileIdsCache cache-miss returns [] for linked sub-entities, causing silent data corruption on any list+relation-extend request. Architecture and batching approach are sound; also flagged: SOLR path ignores _extend[]=@self.files without signal, zero automated test coverage of the renderEntity gate, and undocumented all token in shouldExtendFiles.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 147/147 | |||
| npm | ✅ | ✅ 598/598 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-30 14:48 UTC
Download the full PDF report from the workflow artifacts.
Blockers / concerns from @WilcoLouwerse 2026-04-30: - 🔴 setLightweightFileIds cache-miss returned [] for sub-entities (RenderObject.php). The batchFileIdsCache is populated by renderEntities() with TOP-LEVEL page UUIDs only; sub-entities reached via relation extends are absent from it. Previous `?? []` short-circuit silently emitted `@self.files: []` on every linked sub-object that had attached files. Fix: use `array_key_exists` to distinguish "in the batch with zero files" from "not in the batch at all"; on miss, fall through to a single-UUID FileMapper lookup. Top-level N+1 prevention is preserved. - 🟡 'all' token removed from shouldExtendFiles. `_extend[]=all` propagates to sub-entity renders via array_merge(['all'], $keyExtends), which would silently pay full file-metadata cost on every linked sub-object whenever a top-level request used the wildcard. The proposal pins @self.files as a strict explicit opt-in via @self.files / _files only — the wildcard exception was not in spec. - 🟡 SOLR-path extend_unsupported signal added (ObjectService.php). `_extend[]=@self.files` (or `_files`) on a SOLR-backed list endpoint cannot deliver full metadata (the SOLR path does not route rows through renderEntities). Response now carries `@self.extend_unsupported: ['@self.files']` so consumers can detect the shape mismatch programmatically. Also documented in CHANGELOG. - 🟡 Render-contract gate tests added (RenderObjectTest.php). Five new test methods cover (a) shouldExtendFiles dispatch including the intentional 'all' rejection, (b) lightweight default fires getFileIdsForObjects but NOT getFilesForObject when extend is empty, (c) explicit opt-in routes through renderFiles, (d) cache-miss fall-through (regression test for the blocker), (e) cache-hit preserves cached value (no spurious FileMapper calls). The five pre-existing testRenderFiles* methods were updated to pass `_extend: ['@self.files']` — they exercise the heavy renderFiles path and were silently going through the lightweight path after the gate landed. Was the source of the PHPUnit ❌ in CI. - 🟢 FileMapperGetFileIdsForObjectsTest: deliberate off-contract input test annotated with @psalm-suppress InvalidArgument so static analysis doesn't flag the contract-violating call site. The guard is intentional defence against sloppy callers. - 🟢 CHANGELOG: SOLR limitation explicitly mentioned alongside the perf-discouragement note for list+extend. Quality: 312/312 tests pass in RenderObjectTest, +5 new gate tests. PHPCS clean on touched lib files. php -l clean on all touched files.
|
@WilcoLouwerse — review fixes pushed in 2feace9. Mapping fixes to your items: Blocker
Concerns
Minors
Quality
CI re-run should turn the PHPUnit ❌ into ✅ on the next quality workflow. |
WilcoLouwerse
left a comment
There was a problem hiding this comment.
All 6 prior findings addressed in 2feace9 (1 🔴, 3 🟡, 2 🟢) — fixes verified against the diff and pinned by new regression tests. The cache-miss fall-through, SOLR extend_unsupported signal, and 5-test gate suite all match what was suggested.
Note (non-blocking): Newman CI is failing due to pre-existing infrastructure issues (Solr host resolution + missing OPENAI_API_KEY) — confirmed also failing on development HEAD, not introduced by this PR. PHPUnit jobs were cancelled in this run; a re-run should turn the suite green now that the 5 testRenderFiles* tests have been corrected.
BREAKING CHANGE: @self.files no longer contains full file metadata by
default. Default is now a lightweight list of integer file IDs (e.g.
[123, 456, 789]). Add _extend[]=@self.files (or the equivalent shorthand
_extend[]=_files) to receive the full metadata payload. Migration is one
query parameter on the consumer side.
Why: today, show endpoints that route through RenderObject::renderEntity
unconditionally pay the FileMapper + SystemTag lookup cost on every
response (even when the consumer never uses the file information), while
list endpoints that bypass renderEntity emit no @self.files field at
all. Consumers see different shapes across endpoints and cannot opt in
or out. The new contract is symmetric and explicit, and the lightweight
default is strictly cheaper than today's show behavior.
What changed
(two queries regardless of input size) backing the lightweight default.
covers @self.files. Body of renderFiles() is unchanged. When extend is
absent, setLightweightFileIds() attaches the integer ID list, reusing a
transient batch cache populated by renderEntities() so list pages do not
go N+1.
equivalent, mirroring the existing _schema/_register normalization.
(no extend / fields / filter / propAuth) with lightweight IDs via a
single batched query before returning. Complex-rendering rows inherit
the new behavior automatically through renderEntities.
the SOLR/index branch. (SOLR + _extend[]=@self.files falls back to IDs;
pre-existing limitation that the SOLR path does not route rows through
renderEntities, documented inline.)
shapes for tolerance across pipelines.
Performance
discouraged with a verbatim warning in docs/api/objects.md.
Tests
attachments).
Out of scope: renderFileProperties (schema-property file hydration),
PublicationsController::attachments() (separate explicit endpoint), and
opencatalogi documentation (docs-only follow-up against opencatalogi).
OpenSpec change: openspec/changes/opt-in-files-extend
Refs #1358
A few options if you want it tighter:
on rendered objects
BREAKING CHANGE: block in the body still surfaces the semver impact.
carry the detail and commit messages to stay terse.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com