Skip to content

feat(search)!: replace labelOnly with a lookup strategy naming its target - #739

Open
ddeboer wants to merge 12 commits into
mainfrom
feat/search-lookup-strategy
Open

feat(search)!: replace labelOnly with a lookup strategy naming its target#739
ddeboer wants to merge 12 commits into
mainfrom
feat/search-lookup-strategy

Conversation

@ddeboer

@ddeboer ddeboer commented Aug 14, 2026

Copy link
Copy Markdown
Member

A labelOnly reference carried { id, label } and nothing else, so any other
field of the referent cost a second query keyed off the id just received. The
round-trip that would carry those fields is already made: the label lookup
dedupes the page's referenced IRIs, groups them by collection, fires one batched
multi_search – then keeps the label and discards the document.

labelOnly is replaced by lookup, which names its target once and carries
that target's fields. Design and rejected alternatives are in
ADR 20;
it differs from the proposal in this issue in one way, argued there: no
per-reference fields list
, because two references on one target with
different lists would both want to be ‹Target›Reference with different shapes.
What is fetched is named per query instead.

{ name: 'dataset', kind: 'reference', output: true,
  ref: { strategy: 'lookup', target: 'Dataset' } }

The model

Three strategies on one axis – where a reference's fields come from:

Strategy Carries Surfaces as
idOnly the IRI a bare IRI
lookup fields read from the target's own indexed document a nested object
inline fields denormalised from the parent's framing a nested object

target names the Root Type once – the collection its fields and labels are
read from, and the name its emitted type derives from. It replaces labelSource
and ref.typeName, which for every real labelOnly were the same type
declared twice. labelSource survives only on idOnly, which emits no type but
still labels its facet buckets.

What a query asks for

SearchQuery gains a resolve projection, and the GraphQL surface builds it
from the client's selection set:

creativeWorks { items { dataset { license publisher { label } } } }

resolve: { dataset: { fields: ['license'], resolve: { publisher: {…} } } }

The Typesense adapter answers that with one batched round-trip per level:
each level's IRIs are deduped across the whole page, grouped by collection, and
fetched with include_fields holding exactly what the level named. A level that
cannot be fetched degrades its references to bare ids rather than failing the
search. Referents reconstruct through the same path a hit does, read through the
target's own declaration, so a consumer cannot tell a projected referent from an
inline one.

Omitted, a lookup carries its target's label alone – what every reference
carried before. Facet buckets are untouched: a bucket is a value, a count and
one label, so it stays on the cacheable label lookup.

validateQuery/assertValidQuery now take the schema and check a projection at
every level – each level's fields belong to the target the level above names,
which only the schema resolves.

Consequences worth reviewing

  • No reference emits an id-plus-label object any more. With idOnly a bare
    IRI (feat(search)!: type a filter by what its field keys on #728) and labelOnly gone, labelKeyOf and the label-word agreement
    check added in feat(search): let a search type name its label field #729 have no subject left, and are removed.
  • A labelOnly reference to a type nothing indexes was serving a label no
    engine could fill.
    The merged model cannot express that – a label needs a
    collection, so it needs a root target. Such fixtures became bare IRIs, which
    the SDL snapshot shows.
  • missing-ref-type-name is narrowed to inline alone: a lookup derives its
    name from target, and an idOnly emits no type to name.
  • Three coverage thresholds are re-anchored, each with a comment naming the
    branches that went.

Rebased through #741, so it carries #728, #719 and #740.

Fix #731

@ddeboer ddeboer closed this Aug 14, 2026
@ddeboer ddeboer reopened this Aug 14, 2026
@ddeboer
ddeboer marked this pull request as ready for review August 14, 2026 12:14
@ddeboer ddeboer closed this Aug 14, 2026
@ddeboer ddeboer reopened this Aug 14, 2026
@ddeboer
ddeboer force-pushed the feat/search-lookup-strategy branch 2 times, most recently from f3718b9 to 354e912 Compare August 14, 2026 13:34
ddeboer added 11 commits August 14, 2026 15:42
…ction

Sets the axis the three reference strategies sit on, replaces labelOnly
with lookup, and puts the per-query projection in the decision rather
than deferring it: without one there is no bound on how deep a nested
lookup resolves.
…rget

- ReferenceStrategy is a union per strategy: idOnly carries nothing,
  lookup names its target Root Type, inline keeps its reference typeName
- labelSource is restricted to idOnly, where it labels facet buckets;
  labelSourceNameOf reads target or labelSource as one
- SearchQuery gains an optional resolve projection, validated for this
  type's own level; deeper levels are the engine's, which has the schema

BREAKING CHANGE: strategy 'labelOnly' is now 'lookup', taking 'target'
instead of 'labelSource' plus 'ref.typeName'.
- labelSourceNameOf across lookup, idOnly and a reference resolving none
- labelSource rejected on a lookup; a lookup target that serves no labels
- projection validated at this type's level: unknown and non-lookup fields
validateQuery and assertValidQuery take the schema, so each projection
level is checked against the target the level above names – the guard's
promise held a hole exactly where the newest feature is. Both production
call sites are schema-bound already.

BREAKING CHANGE: validateQuery and assertValidQuery take the SearchSchema
as a third argument.
…NameOf

One reading for a lookup's target and an idOnly's labelSource, so the
adapter never branches on strategy to find a collection. Fixtures that
declared a typeName and a labelSource for the same type now name it
once; the emitted collection schema is unchanged.
The reference that resolved no labels becomes idOnly; the rest name the
target they already declared twice.
…ields

- a lookup's emitted type derives from its target and carries that root
  type's output fields, the rule inline already followed; its id is
  non-null, since a lookup resolves a document by IRI
- a target's own references are registered in turn, so a cycle between
  two targets terminates on the memo rather than recursing
- an output idOnly reference must name its emitted type: it is the one
  strategy that derives no name, and graphql-js failed cryptically on it

BREAKING CHANGE: a labelOnly reference becomes a lookup naming its
target, and is served with the target's output fields rather than a
resolved label alone.
Rebasing onto the IRI-typed filters made an idOnly reference a bare IRI,
which leaves no reference emitting an id-plus-label object: a lookup
carries its target's fields, an inline reference its Reference Type's.
So labelKeyOf and the label-word agreement check go, and with them the
missing-ref-type-name rule – an idOnly reference names no emitted type
because it emits none.

Fixtures that declared a labelOnly reference to a type nothing indexes
were serving a label no engine could fill; they are bare IRIs now, or
lookups where the target really is a root type.
…llection

resolveProjection walks a query's projection level by level: the IRIs of
each level are deduped across the whole page, grouped by the collection
they live in and fetched in one batched multi_search with include_fields
holding exactly what the level asked for. One round-trip per level, not
per document, and a level that cannot be fetched degrades its references
to bare ids rather than failing the search.

The referents reconstruct through the same path a hit does, read through
the target's own declaration, so a consumer cannot tell a projected
referent from an inline one. The facet path is untouched: a bucket
carries one label and stays on the cacheable label lookup.
- the strategy table, the reference prose and three examples described the
  removed labelOnly; copied today they threw
- a section on projecting what a lookup carries, since what it fetches is
  named per query rather than declared
- CONTEXT.md gains Target and Reference Projection, and marks labelOnly as
  a word to avoid
- corrects a JSDoc claim that an output idOnly reference must name a type:
  it surfaces as a bare IRI, so there is no type to name
A lookup derives its emitted name from the target it already names, and an
idOnly reference is served as a bare IRI, so neither can be nameless.
@ddeboer
ddeboer force-pushed the feat/search-lookup-strategy branch from 354e912 to da71d41 Compare August 14, 2026 13:44
Every GraphQL client injects __typename into each selection set, and it
was carried into the projection verbatim: the port's guard then reported
it as an unknown field of the target and threw, failing the whole search
for any query selecting a lookup. The projection now asks only for what
the target declares as output.

Two more, from the same review:
- one lookup selected twice (two fragments spreading it) merged its
  deeper levels shallowly, so the second selection replaced the first and
  a field the client asked for was never fetched
- a lookup declaring no target passed validation, since the type-name
  rule had been narrowed to inline alone
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.

Replace labelOnly with a lookup strategy that carries the target's fields

1 participant