Skip to content

Commit 00b63b3

Browse files
Merge pull request #236 from components-web-app/feature/234-inherited-live-collections
Filter route and page collections on the inherited liveAt (#234)
2 parents 82b467b + 309cffd commit 00b63b3

7 files changed

Lines changed: 361 additions & 10 deletions

File tree

‎CLAUDE.md‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,24 @@ A Route's *existence* is no longer the whole publication signal. `Route.liveAt`
104104

105105
**The effective date is resolved at request time, never stored.** `liveAt` is the only persisted value: what an admin authored on that route. The effective answer is derived by the walk above, memoised per request, and asked for by `RouteVoter` when it gates and by `MetadataNormalizer` when an admin needs to display it.
106106

107-
An earlier implementation denormalised it into a `Route.effectiveLiveAt` column maintained by an `onFlush` listener. That was removed: the codebase already answers "is this reachable" by traversing at request time (`ComponentVoter` has always done so), and a second, inconsistent mechanism for the same class of question was not worth the maintained state. The column's only real justification had been a flat SQL predicate for collection filtering — see the limitation below for what that actually cost.
107+
An earlier implementation denormalised it into a `Route.effectiveLiveAt` column maintained by an `onFlush` listener. That was removed: the codebase already answers "is this reachable" by traversing at request time (`ComponentVoter` has always done so), and a second, inconsistent mechanism for the same class of question was not worth the maintained state. The column's only real justification had been a flat SQL predicate for collection filtering, which the recursive CTE below supplies without any stored state.
108108

109109
**Admins read the effective date from `_metadata`, not a property.** `ResourceMetadata::$effectiveLiveAt` (group `cwa_resource:metadata`) carries it, resolved from the same memo the voter uses so the chain is walked once. It is derived state, so it is not a mapped column and not writable. It is admin-only by construction rather than by an added check: a non-admin cannot retrieve a non-live route at all.
110110

111-
> **Known limitation — collections filter on `liveAt` only.** Traversal is not expressible in DQL, so `RouteExtension` and `RoutableExtension` can only test a route's own date. An anonymous `GET /_/routes` therefore lists a route whose own date has passed but whose parent is still scheduled. The item stays correctly gated — the voter 404s it — so this is an existence leak in a listing, not an access leak. Pinned by a scenario in `features/main/route_schedule.feature` so it is documented behaviour rather than an accident.
111+
**Collections filter on the inherited date too (#234).** `RouteExtension` and `RoutableExtension` once tested a route's own date only, so an anonymous `GET /_/routes` listed a route whose own date had passed but whose parent was still scheduled. That mattered more than an ordinary listing inconsistency because `GET /_/routes` is the **sitemap source**: it does not fetch what it lists, it publishes it, so every affected URL went to search engines as a soft-404. The module could not filter them either — `liveAt` and `_metadata.effectiveLiveAt` are both admin-only, so an anonymous sitemap build has no signal at all.
112+
113+
`RouteAncestorGateResolver` (`src/Helper/Route/RouteAncestorGateResolver.php`) closes it with **one native recursive CTE** returning the ids of routes gated by an ancestor; `andWhereNotGated()` then applies `NOT IN` against that id set. This is `PublishableExtension`'s shape — exclude a set, so pagination and `totalItems` stay correct — with the id set coming from one extra query instead of a DQL subquery, because unbounded ancestor recursion is the one thing DQL cannot express.
114+
115+
- **The predicate is the simple form of the rule, not a re-derivation of the effective date.** Effective is the *latest* date in the chain, so it is active exactly when *every* date in the chain is non-null and past. The CTE therefore only has to find chains containing one non-active routed ancestor; the route's own date stays with `PublicationDate::andWhereActive()`.
116+
- **The CTE walks parent pointers, not routes.** Anchor row per route = its page's/page data's parent pointer; each iteration replaces the pointer with the grandparent's. A `LEFT JOIN` to both `page` and `abstract_page_data` with `COALESCE` keeps it to **one** self-reference, which PostgreSQL requires — two recursive branches (one per parent type) is the obvious shape and PostgreSQL rejects it.
117+
- **An ancestor with no Route is still skipped**, per #224: the final join only gates on ancestors that actually have a route. The `#[ORM\OneToOne]` is owned by `AbstractPage`, so the FK is `page.route_id` / `abstract_page_data.route_id` — there is no `page_id` on the `route` table.
118+
- **Cycles terminate** by `UNION` (distinct), not `UNION ALL` — a repeated pointer row is dropped and the recursion ends. Mirrors the visited-id set in `RouteLiveResolver`.
119+
- **No per-request memoisation and no cached state.** The resolver holds nothing between calls, so it needs no `kernel.reset` tag and cannot leak across requests under worker mode.
120+
- Table and column names come from `ClassMetadata` (`getTableName()`, `getSingleAssociationJoinColumnName()`), so the `_acb_` table prefix and any application override are honoured rather than hardcoded.
121+
122+
> **Portability was verified, not assumed.** The exact generated statement was run against **SQLite 3.43.2 / 3.53.4 (the harness), MySQL 8.0.46, MariaDB 10.11.19 and PostgreSQL 16.15** on identical fixtures covering flat, one-level, two-level, unrouted-ancestor, null-`liveAt`-ancestor and cyclic hierarchies. All four returned the identical gated set, so **no platform branch is needed** and none exists. Recursive CTEs require SQLite 3.8.3+, MySQL 8.0+, MariaDB 10.2+ or PostgreSQL — the bundle's whole supported range. **Not exercised:** MySQL 5.7 and MariaDB 10.0/10.1, which have no CTE support at all and on which this query cannot run.
123+
124+
> **Extension and voter answer different questions — the asymmetry is deliberate.** The query extension answers *which rows may this anonymous caller see*; the voter answers *may this caller read this resource*. The CTE does **not** replace the per-request traversal in `RouteVoter`, and must not try to: `RouteVoter` folds in `route_security`, which is per-token and path-matched and cannot go into SQL. Both are needed, they overlap on the `liveAt` chain, and that overlap is the cost of the split.
112125
113126
**The cache cap uses `MIN(liveAt)`** (`RouteRepository::findNextLiveAt()`). That is safe without the column: an effective date is always the *latest* date in a chain, so every effective transition is some route's own `liveAt`, and the minimum over own dates is never later than the earliest real transition. It can expire a cached response slightly early, never too late.
114127

@@ -123,7 +136,7 @@ A Route reaches its own page **and every ancestor of that page** through `parent
123136
Consumers:
124137
- `RoutableVoter` — grants when the resource's own route passes `RouteVoter`, or when the resolver finds a reaching route that does; for a `Page` it also checks the `AbstractPageData` instances using it as a template, since a template is reached through its page data rather than through the hierarchy.
125138
- `ComponentVoter::voteByRoute` — the same question for each page a component sits in.
126-
- `RoutableExtension` — own route plus the liveness predicate, unchanged from #224. A routeless page is absent from `GET /_/pages`, which is where it was before #225; see the collection limitation above, which has the same root cause.
139+
- `RoutableExtension` — the joined route's own liveness predicate, plus the inherited gate from #234. A routeless page is absent from `GET /_/pages`, which is where it was before #225: reachability-by-descendant is a voter answer, and it needs `route_security`, so it stays out of SQL.
127140

128141
**Why not denormalise it.** An "earliest live date among reaching routes" column would have made the collection filter trivial, but it cannot express `route_security`, which is per-token and path-matched — an anonymous visitor would be granted a page reachable only via `/user-area/...`. Storing the *edges* instead was tried and rejected for a different reason: it introduced a maintained join table, a rebuild command and an upgrade step for a question the existing architecture already answers by traversal.
129142

@@ -551,6 +564,16 @@ $topicBuilder->onRoutesCreated(function (array $childBuilders) use ($intro) {
551564

552565
## Open Issues — Context for Future Work
553566

567+
### #234 — Anonymous route and page collections ignored the inherited `liveAt` ✓ **DONE**
568+
569+
Follow-up to #224/#225. `GET /_/routes` is the sitemap source, so listing a route gated by a scheduled ancestor handed search engines a soft-404 to crawl — and the module had no way to filter it, because both `liveAt` and `_metadata.effectiveLiveAt` are admin-only. Fixed with `RouteAncestorGateResolver` and a native recursive CTE; see **Route publication** above for the mechanism, the one-self-reference constraint, and the verified portability matrix.
570+
571+
Built the way `PublishableExtension` already builds — exclude a set so pagination and `totalItems` stay correct. The bounded-depth pure-DQL alternative was rejected: each level branches two ways (`parentPage` / `parentPageData`), so the query doubles per level and it imposes a depth ceiling nothing else in the feature has. No denormalised state was reintroduced — a join table and a derived column were both built and rejected on #225, and the CTE makes neither necessary.
572+
573+
Behat in `features/main/route_schedule.feature` covers scheduled parent, draft parent, scheduled grandparent, the #224 unrouted-ancestor guard, a route with no parent, `totalItems` correctness, admin still seeing everything, both cycle directions, and the `GET /_/pages` and `GET /page_data/page_datas` halves. The scenario that pinned the old leak was inverted, not deleted.
574+
575+
> **Found while inverting it: `OrSearchFilter` defeats every extension predicate, including a route's own `liveAt`.** `addWhereByStrategy()` calls `$queryBuilder->orWhere(...)`, which ORs against the *entire* accumulated WHERE rather than only among the filter's own clauses. On `main`, an anonymous `GET /_/routes?path=launch` lists a route scheduled for 2999 — no ancestry involved. The old pinned scenario used `?path=`, so it was demonstrating this bug, not the inheritance one; the inverted scenarios query the unfiltered collection instead. **Not fixed here** — it is a separate defect in a public filter with its own blast radius. Needs its own issue.
576+
554577
### #225 — Nested child page whose parent has no Route: the parent was invisible to the public ✓ **DONE**
555578

556579
The voter chain never treated `parentPage`/`parentPageData` as a reachability edge, so it was wrong in **both** directions. Full mechanism in **Route reachability** above; this entry records the judgement calls.

‎features/bootstrap/DoctrineContext.php‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,107 @@ public function thereIsANestedPageResource(string $childPath, string $parentPath
14791479
$this->manager->flush();
14801480
}
14811481

1482+
/**
1483+
* @Given there is a Page resource with the route path :path whose parent page has no route
1484+
*/
1485+
public function thereIsAPageResourceUnderAnUnroutedParent(string $path): void
1486+
{
1487+
$parentPage = new Page();
1488+
$parentPage->isTemplate = true;
1489+
$parentPage->reference = 'unrouted parent page';
1490+
$this->timestampedHelper->persistTimestampedFields($parentPage, true);
1491+
$this->manager->persist($parentPage);
1492+
1493+
$childPage = new Page();
1494+
$childPage->isTemplate = true;
1495+
$childPage->reference = 'child page';
1496+
$childPage->setParentPage($parentPage);
1497+
$this->timestampedHelper->persistTimestampedFields($childPage, true);
1498+
$this->manager->persist($childPage);
1499+
1500+
$childRoute = new Route();
1501+
$childRoute->setPath($path)->setName($path)->setPage($childPage);
1502+
$this->timestampedHelper->persistTimestampedFields($childRoute, true);
1503+
$this->manager->persist($childRoute);
1504+
1505+
$this->restContext->resources['page'] = $this->iriConverter->getIriFromResource($childPage);
1506+
$this->restContext->resources['page_route'] = $this->iriConverter->getIriFromResource($childRoute);
1507+
1508+
$this->manager->flush();
1509+
}
1510+
1511+
/**
1512+
* @Given there is a PageData resource with the route path :childPath nested within the route :parentPath which is nested within the route :grandParentPath
1513+
*/
1514+
public function thereIsAThreeLevelNestedPageDataResource(string $childPath, string $parentPath, string $grandParentPath): void
1515+
{
1516+
$grandParentPageData = $this->createRoutedPageData($grandParentPath, 'grandparent page', null);
1517+
$this->restContext->resources['grandparent_route'] = $this->iriConverter->getIriFromResource($grandParentPageData->getRoute());
1518+
1519+
$parentPageData = $this->createRoutedPageData($parentPath, 'parent page', $grandParentPageData);
1520+
$this->restContext->resources['parent_route'] = $this->iriConverter->getIriFromResource($parentPageData->getRoute());
1521+
1522+
$childPageData = $this->createRoutedPageData($childPath, 'child page', $parentPageData);
1523+
$this->restContext->resources['page_data'] = $this->iriConverter->getIriFromResource($childPageData);
1524+
$this->restContext->resources['page_data_route'] = $this->iriConverter->getIriFromResource($childPageData->getRoute());
1525+
1526+
$this->manager->flush();
1527+
}
1528+
1529+
/**
1530+
* @Given there are two Pages which are each other's parent with the routes :pathOne and :pathTwo
1531+
*/
1532+
public function thereAreTwoPagesWhichAreEachOthersParent(string $pathOne, string $pathTwo): void
1533+
{
1534+
$pageOne = new Page();
1535+
$pageOne->isTemplate = true;
1536+
$pageOne->reference = 'cycle page one';
1537+
$this->timestampedHelper->persistTimestampedFields($pageOne, true);
1538+
$this->manager->persist($pageOne);
1539+
1540+
$pageTwo = new Page();
1541+
$pageTwo->isTemplate = true;
1542+
$pageTwo->reference = 'cycle page two';
1543+
$this->timestampedHelper->persistTimestampedFields($pageTwo, true);
1544+
$this->manager->persist($pageTwo);
1545+
1546+
$pageOne->setParentPage($pageTwo);
1547+
$pageTwo->setParentPage($pageOne);
1548+
1549+
foreach ([$pathOne => $pageOne, $pathTwo => $pageTwo] as $path => $page) {
1550+
$route = new Route();
1551+
$route->setPath($path)->setName($path)->setPage($page);
1552+
$this->timestampedHelper->persistTimestampedFields($route, true);
1553+
$this->manager->persist($route);
1554+
}
1555+
1556+
$this->manager->flush();
1557+
}
1558+
1559+
private function createRoutedPageData(string $path, string $pageReference, ?PageData $parentPageData): PageData
1560+
{
1561+
$page = new Page();
1562+
$page->isTemplate = true;
1563+
$page->reference = $pageReference;
1564+
$this->timestampedHelper->persistTimestampedFields($page, true);
1565+
$this->manager->persist($page);
1566+
1567+
$pageData = new PageData();
1568+
$pageData->page = $page;
1569+
if (null !== $parentPageData) {
1570+
$pageData->setParentPageData($parentPageData);
1571+
}
1572+
$this->timestampedHelper->persistTimestampedFields($pageData, true);
1573+
$this->manager->persist($pageData);
1574+
1575+
$route = new Route();
1576+
$route->setPath($path)->setName($path)->setPageData($pageData);
1577+
$this->timestampedHelper->persistTimestampedFields($route, true);
1578+
$this->manager->persist($route);
1579+
1580+
return $pageData;
1581+
}
1582+
14821583
/**
14831584
* @When I patch the page with the component group in the request body
14841585
*/

0 commit comments

Comments
 (0)