feat(search)!: type a filter by what its field keys on - #728
Merged
Conversation
ddeboer
force-pushed
the
feat-iri-typed-filters
branch
2 times, most recently
from
August 13, 2026 17:34
5181e3a to
f79fa40
Compare
ddeboer
force-pushed
the
feat-iri-typed-filters
branch
from
August 14, 2026 10:13
f79fa40 to
d653032
Compare
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
force-pushed
the
feat-iri-typed-filters
branch
from
August 14, 2026 10:20
d653032 to
af453bf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix #723
ADR 18 settled how a consumer states “everything referencing this IRI” – a cross-field
orof@oneOfcriteria – but left the vocabulary to the client: which fields belong in thatorlist. Nothing in the surface answered it. Every filterable keyword and reference field pointed at one sharedStringFilter, so introspection could enumerate a criterion’s fields perfectly and still not tell thatcreatorandmaterialwant IRIs whileidentifierwants 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
‹Type›Where.idis 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:inelement type isIRI; the complete reference-field set, needing no origin collection;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.
kindbecomes the discriminator, becauseidOnlynow existsThe issue proposed a declaration-level marker (
iri: trueon akeyword), sincesameAsandlicensehold 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 shape –outputon areferencerequiresref, andrefgave 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 bareIRI, each pair collapses to one field, andkindis the discriminator after all. Itsref.typeNameis optional, since it emits no type to name.The type is enforced in both directions
parseValue/parseLiteralreject a value with no scheme, sowhere: { material: { in: ["boerenbont"] } }is a coercion error rather than a silent empty result – the jobargsToQueryalready does for an out-of-rangeperPage.serializeenforces it too. A type checked in one direction only is not one a consumer can rely on, and the coarse strategy readsIRIas 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 thanValueBucket: the bucket a consumer selects feeds the‹Target›Filterthat 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/searchasisAbsoluteIri, 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.iriStringnow applies the ruledocumentKeyalready applied to a root, so such a value is dropped – what alabelOnly/idOnlyreference 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’sidis nullable by design and it carries no guaranteed label field (all the more so now a type can name its own vialabelField). Narrowed tolabelOnlytypes 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 singleValueBucketwas rejected.Notes for review
packages/search-api-graphql/vite.config.tsmoves the branch threshold, anautoUpdatere-anchor from the new covered branches.@lde/search-typesenseneeds no change: the engine stores IRIs as strings, and the IR still carries aReferencefor every non-inline reference – the strategy governs this surface, not the port, so theidOnlyflattening happens in the resolver.main, so it sits on top oflabelField(feat(search): let a search type name its label field #729)._x/xpairs and regenerate its SDL snapshot.Ships as breaking:
StringFilteris gone,idfilters are per-type, a reference facet returnsIriBucket, and a variable must be declared[IRI!]rather than[String!](GraphQL checks variable usage nominally, though the two are identical on the wire).