Skip to content

feat(search)!: type a filter by what its field keys on - #728

Merged
ddeboer merged 1 commit into
mainfrom
feat-iri-typed-filters
Aug 14, 2026
Merged

feat(search)!: type a filter by what its field keys on#728
ddeboer merged 1 commit into
mainfrom
feat-iri-typed-filters

Conversation

@ddeboer

@ddeboer ddeboer commented Aug 13, 2026

Copy link
Copy Markdown
Member

Fix #723

ADR 18 settled how a consumer states “everything referencing this IRI” – a cross-field or of @oneOf criteria – but left the vocabulary to the client: which fields belong in that or list. Nothing in the surface answered it. Every filterable keyword and reference field pointed at one shared StringFilter, so introspection could enumerate a criterion’s fields perfectly and still not tell that creator and material want IRIs while identifier wants an accession number. The list had to be hardcoded per deployment and drifted whenever a reference field was added.

A filter is typed by what its field keys on

scalar IRI

input KeywordFilter { in: [String!] }   # literals
input IRIFilter     { in: [IRI!]    }   # IRIs belonging to no collection
input TermFilter    { in: [IRI!]    }   # IRIs of Term, one per ref.typeName

‹Type›Where.id is typed self-referentially (TermWhere.id: TermFilter), connecting “the collection I am browsing” to “the filter type that accepts its IRIs”. Two discovery strategies fall out, both answered by one cached introspection round-trip:

  • coarse – select every criterion field whose filter’s in element type is IRI; the complete reference-field set, needing no origin collection;
  • refined – resolve the queried root field through where‹Type›Where.id → a filter type name, then select the criterion fields of every collection whose filter is that same type.

The type name is compared, never parsed. Both are exercised by tests reading only the introspection result, with no domain name in the traversal.

kind becomes the discriminator, because idOnly now exists

The issue proposed a declaration-level marker (iri: true on a keyword), since sameAs and license hold IRIs while being keyword-kind. Investigating why showed the marker would treat a symptom: the reason was never that they hold literals but the output shapeoutput on a reference requires ref, and ref gave the { id, label } object when the deployment wanted a flat list of IRIs. Linked Open Limburg carries seven such internal-reader/keyword-derive pairs.

Implementing idOnly – forward-declared since ADR 3, already documented as “the IRI” – removes the reason instead. It surfaces as a bare IRI, each pair collapses to one field, and kind is the discriminator after all. Its ref.typeName is optional, since it emits no type to name.

The type is enforced in both directions

parseValue/parseLiteral reject a value with no scheme, so where: { material: { in: ["boerenbont"] } } is a coercion error rather than a silent empty result – the job argsToQuery already does for an out-of-range perPage.

serialize enforces it too. A type checked in one direction only is not one a consumer can rely on, and the coarse strategy reads IRI as the promise that a value is a selection key; serving a non-IRI under it would falsify the promise exactly where a consumer acts on it. The projection applies the same rule at the source, so the only way to reach the outbound error is an index predating it – which a reindex fixes.

That consistency is also why a reference facet now returns IriBucket (value: IRI!) rather than ValueBucket: the bucket a consumer selects feeds the ‹Target›Filter that selects it, without crossing a type boundary. Both bucket types are built from the same field factory so they cannot drift.

The check is a scheme check, not http(s)urn:, doi:, ark:, tag: and a deployment’s own minted scheme are ordinary Linked Data. It lives in @lde/search as isAbsoluteIri, shared with the projection.

Blank-node referents

That sharing is load-bearing: kind: 'reference' did not guarantee an IRI. A profile-admitted blank-node referent projected as _:b0, which a facet would offer and the filter then refuse. iriString now applies the rule documentKey already applied to a root, so such a value is dropped – what a labelOnly/idOnly reference stores is a selection key, and a framing-minted label is not one. Inline references are untouched: they carry fields rather than identity, so a blank-node referent nests exactly as before, which ADR 11 depends on.

Rejected, and recorded in ADR 19

The output-side interface Reference (issue item 6) is dropped: its premise – that the reference types are structurally identical – is false, since an inline type’s id is nullable by design and it carries no guaranteed label field (all the more so now a type can name its own via labelField). Narrowed to labelOnly types it becomes buildable and still fails the deletion test, since a generic client is already introspecting and generating its queries per deployment. The ADR also records why an input-side interface cannot exist (abstract types are output-only in GraphQL) and why keeping a single ValueBucket was rejected.

Notes for review

  • packages/search-api-graphql/vite.config.ts moves the branch threshold, an autoUpdate re-anchor from the new covered branches.
  • @lde/search-typesense needs no change: the engine stores IRIs as strings, and the IR still carries a Reference for every non-inline reference – the strategy governs this surface, not the port, so the idOnly flattening happens in the resolver.
  • Rebased onto current main, so it sits on top of labelField (feat(search): let a search type name its label field #729).
  • Downstream, LOL can collapse its seven _x/x pairs and regenerate its SDL snapshot.

Ships as breaking: StringFilter is gone, id filters are per-type, a reference facet returns IriBucket, and a variable must be declared [IRI!] rather than [String!] (GraphQL checks variable usage nominally, though the two are identical on the wire).

@ddeboer
ddeboer force-pushed the feat-iri-typed-filters branch 2 times, most recently from 5181e3a to f79fa40 Compare August 13, 2026 17:34
@ddeboer
ddeboer force-pushed the feat-iri-typed-filters branch from f79fa40 to d653032 Compare August 14, 2026 10:13
Every filterable keyword and reference field pointed at one shared
StringFilter, so introspection could enumerate a criterion’s fields and
still not tell which of them accept an IRI. A consumer building the
cross-field `or` had to hardcode that list per deployment, and it drifted
silently whenever a reference field was added.

- add an IRI scalar, and split the membership filters by what the field
  keys on: KeywordFilter for literals, IRIFilter for IRIs belonging to no
  collection, and one <Target>Filter per ref.typeName
- type <Type>Where.id self-referentially, so a consumer can resolve the
  collection it is browsing to the filter type accepting its IRIs
- implement the idOnly reference strategy, forward-declared since ADR 3:
  it surfaces as a bare IRI, so a field over an IRI-valued property no
  longer has to be declared keyword to get a flat output shape. Its
  ref.typeName is optional, since it emits no type to name
- bucket a reference facet as IriBucket, whose value is an IRI, so the
  bucket a consumer selects feeds the <Target>Filter that selects it
  without crossing a type boundary. Built from the same field factory as
  ValueBucket, so the two cannot drift
- validate IRIs in both directions: a bare token on the way in names the
  field it was written on instead of matching nothing, and a value the
  projection would have dropped fails on the way out rather than passing
  as an IRI a consumer would feed back as a selection key
- share isAbsoluteIri between the projection and the surface, and drop a
  reference value that is not an absolute IRI: what a labelOnly/idOnly
  reference stores is a selection key, and a framing-minted blank node
  label is not one. Inline references are untouched
- reject a root type whose filter name would collide with a built-in one,
  naming the declaration rather than failing schema assembly

BREAKING CHANGE: StringFilter is replaced by KeywordFilter, IRIFilter and
per-target filter inputs, and `id` is filtered per type. A variable must
be declared [IRI!] rather than [String!] - GraphQL checks variable usage
nominally, though the two are identical on the wire. Type.id and
<Type>Reference.id are IRI!. A reference facet returns IriBucket rather
than ValueBucket. A labelOnly/idOnly reference to a blank node is no
longer indexed, and a non-IRI already in an index now fails on read
instead of being served as an IRI.
@ddeboer
ddeboer force-pushed the feat-iri-typed-filters branch from d653032 to af453bf Compare August 14, 2026 10:20
@ddeboer
ddeboer merged commit 7926e44 into main Aug 14, 2026
4 checks passed
@ddeboer
ddeboer deleted the feat-iri-typed-filters branch August 14, 2026 11:43
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.

Distinguish IRI-valued from literal filters in the GraphQL surface (IRI scalar + per-target filter inputs)

1 participant