Skip to content

[DO NOT MERGE] - ADR-014 end state#4098

Draft
richardTowers wants to merge 9 commits into
alphagov:mainfrom
richardTowers:adr-014-consolidate-link-expansion
Draft

[DO NOT MERGE] - ADR-014 end state#4098
richardTowers wants to merge 9 commits into
alphagov:mainfrom
richardTowers:adr-014-consolidate-link-expansion

Conversation

@richardTowers

Copy link
Copy Markdown

Because I can't push branches to alphagov anymore (😭), I've had to create the intermediate PRs on my fork.

We have:

This represents the entire stack of changes as one PR, so you can see overall summaries in terms of lines added / removed, or look through the commits one by one if you prefer. It would be reckless to merge this in one go though - you should pause after the second PR and do some manual testing that the implementations behave the same as each other.

richardTowers and others added 9 commits June 10, 2026 12:22
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assert that when multiple links share a link_type and position, the
expanded array is ordered by link_id DESC, for both direct and reverse
links. This mirrors the SQL-level coverage in the (to-be-removed)
spec/queries/links_spec.rb and guards the behaviour before the BFS
rewrite begins. Green on the depth-first implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the two batch SQL queries (and their README) out of the GraphQL
tree to app/queries/sql/, and introduce Queries::LinkedToEditions and
Queries::ReverseLinkedToEditions as thin primitives that own the
query_input JSON, sql_params, find_by_sql, dedup and grouping. They
return a hash keyed by [content_id, link_type], pre-seeded with [] for
every input key so missing results yield [] and ordering matches input.

The GraphQL Sources::* dataloaders now delegate to these primitives and
return result_hash.values, preserving the Dataloader contract. No
behaviour change; the GraphQL parity suite stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LinkExpansion::BreadthFirstExpander reproduces links_with_content using
the shared batch SQL queries, walking the link graph breadth-first (one
forward + one reverse query per level) instead of the current depth-first
one-query-per-node traversal. LinkExpansion#links_with_content dispatches
to it when LINK_EXPANSION_IMPLEMENTATION=breadth-first (default: depth-first).

Reproduces existing behaviour exactly: per-path ancestor cycle pruning
(not a global visited set), root link-type discovery, edition links only
at the root (edition_id: NULL at child levels), reverse re-keying
including the role_appointments -> person/role fan-out, auto_reverse_link,
and the withdrawn override for SQL-sourced editions.

Adds a link_source discriminator column to both SQL queries so the expander can
identify editions reached via edition links: existing code never expands their
children ("no nested edition links") and excludes edition-sourced reverse links
at child levels. The column is additive and ignored by the GraphQL dataloaders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DependencyResolution::BreadthFirstResolver resolves dependent content_ids
breadth-first, mirroring the link expander with directions swapped.
DependencyResolution#dependencies dispatches to it when
LINK_EXPANSION_IMPLEMENTATION=breadth-first (default: depth-first), the
same flag and values the expander uses.

Unlike link expansion, dependency resolution returns a dependent's
content_id whether or not it has a renderable edition, so the resolver
reads the links table directly rather than the edition-joining batch SQL.
Edition links only matter at the root (child levels follow link set links
only, as the depth-first implementation does), so the root reuses the
existing Queries::Links / Queries::EditionLinks and the recursive levels
batch plain links-table reads (one incoming + one outgoing query per
level). This keeps spec/integration/dependency_resolution_spec.rb passing
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip LINK_EXPANSION_IMPLEMENTATION's default from depth-first to
breadth-first, so link expansion and dependency resolution use the
batch-SQL breadth-first implementation by default. The depth-first
LinkGraph traversal can still be restored with
LINK_EXPANSION_IMPLEMENTATION=depth-first; it is removed in the next
commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete the LinkGraph traversal now that the breadth-first batch-SQL
implementation is the default, and drop the LINK_EXPANSION_IMPLEMENTATION
flag so LinkExpansion and DependencyResolution are thin entry points to
the new expander/resolver.

Removed: app/models/link_graph{,/node,/node_collection_factory}.rb,
lib/link_expansion/{link_reference,content_cache}.rb,
lib/dependency_resolution/link_reference.rb, and their specs.

Retained: app/queries/links.rb and app/queries/edition_links.rb, which
the new dependency resolver uses at the root; and
lib/link_expansion/edition_hash.rb.

Docs updated to describe the breadth-first / two-batch-SQL model and the
new console-debugging entry points.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Now that the breadth-first resolver is the only caller of Queries::Links,
strip the has_own_links / is_linked_to EXISTS optimisation and its
next_allowed_link_types_from/to and parent_content_ids parameters.

These existed solely to let the old depth-first traversal prune which
nodes were worth visiting and so minimise its one-query-per-node count.
The breadth-first resolver batches a whole level per query, never passes
those parameters, and only reads each link's content_id, so the whole
machinery was computed-or-skipped but never consumed. This takes the
class from ~240 lines to ~70 and drops the EXISTS subqueries the ADR
flagged as significant SQL complexity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LinkExpansion and DependencyResolution had become effectively delegation shells
around LinkExpansion::BreadthFirstExpander and
DependencyResolution::BreadthFirstResolver. Now that the depth-first
implementation is gone there is nothing to select between, and the
"breadth-first" class names encoded migration history rather than information,
so the implementations become the entry-point classes themselves.

LinkExpansion is now the expander (still namespacing AutoReverseLinker,
RootEditionResolver, EditionHash and EditionDiff); DependencyResolution
is now the resolver, absorbing the wrapper's default locale. Callers are
unchanged: LinkExpansion.by_edition / by_content_id and
DependencyResolution.new(...).dependencies were already the public API.

The expander unit spec merges into the existing spec/lib/
link_expansion_spec.rb; the resolver spec moves to
spec/lib/dependency_resolution_spec.rb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant