You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
labelSource names one root type, so a reference whose range spans two of them can only label half its values – and its GraphQL type claims the wrong kind for the other half.
Observed
SCHEMA-AP-NDE ranges CreativeWork.creator over PersonorOrganization. In limburg/lol it is declared ref: { typeName: 'Person' }, labelSource: 'Person', and the facet comes back:
creator
in persons
in organizations
facet label
Koel, Frank
✓
–
✓
Lommen, Math.
✓
–
✓
Smeets, René
✓
–
✓
Franssen, Tom
✓
–
✓
Josef Maria Arthur Nols
✓
–
✓
Vermeulen-Joosten, Riek
✓
–
✓
Zusters Franciscanessen
–
✓
null
Peters (Leo) Kunststickerei
–
✓
null
Zusters van het Arme Kind Jezus
–
✓
null
Zusters Misericorde
–
✓
null
The split is exact: every labelled value is a Person, every null one an Organization. The names exist – they are in the organizations collection.
The missing label is the visible half. The other half is that all ten are typed PersonReference, so a consumer routing a click on creator.id to its person page misses four of them with nothing in the response to indicate it. That is wrong today, before any fix.
Proposal
Make a labelOnly reference an interface implemented once per label source:
interfaceLabelledReference { id: String!, name: [LanguageString!]! }
typePersonReferenceimplementsLabelledReference { id, name }
typeOrganizationReferenceimplementsLabelledReference { id, name }
with labelSource accepting several types and resolving across their collections. __typename then reports which one matched, per value.
Named for the mechanism, not for a grouping of the underlying classes: the RDF asserts no supertype over Person and Organization (foaf:Agent is declared as a range but never asserted, so selecting on it matches nothing). The interface claims only what is true of every reference – it has an id and a resolved label.
Why an interface
A union breaks every existing consumer. Union members share no fields, so even creator { id name } would need ... on PersonReference. An interface keeps that selection working unchanged.
A type scalar on a flat reference is a dead end. The moment a reference carries more than a label, per-type fields have nowhere to live: birthDate applies to a person and not to an organization. The interface gives each label source somewhere to grow independently.
Joins do not subsume this
A Typesense reference field names one target collection (reference: "persons.id"); the array variants hold several references into that same collection, not references into different ones. A field whose values live in two collections therefore cannot be expressed as a join, so creator can never be joinable and #712 will never reach it.
That makes query-time label resolution the permanent mechanism for polymorphic references rather than a stopgap until joins arrive – and the reason it has to resolve across several label sources, because nothing else will. Where such a reference eventually needs more than a label, it has to come from the label-source lookup fetching more fields, not from a join.
Scope
Not systemic yet. Of LOL's reference facets only creator shows the gap; about is fully labelled – but its profile range is CreativeWork | Person | Organization too, and it works only because that publisher co-types referenced terms consistently. The same failure is latent there.
No conflict with #712: joinable is opt-in, so a polymorphic reference simply does not set it and keeps query-time label resolution. Related to #714, which is the other half of how these labels surface – a reference renames label to name.
Recovering joins for a polymorphic reference
Losing joins here is a real cost, not just a missing label: filtering works by their creator's properties – died before 1900, located in Maastricht – is what a join buys and label resolution never can. Three ways it could be recovered, differing in what survives.
A. A shared collection of the common fields. An agents collection carrying what Person and Organization share, as a single-target join. Needs a root type to select on several classes – class is one IRI today, though sh:targetClass may appear more than once, so the extension is natural. But the intersection is essentially just label, so this restores joins over precisely what label resolution already gives, and the per-type filters stay out of reach. It fixes the mechanism without recovering the functionality.
B. The same collection denormalised to the union of fields, engine-side only.agents carries birthDateandlocation, each absent for the kind it does not apply to, plus a discriminator. creator is a single-target reference to it, so joins work with no dangling references and no disjunction. The sparse columns never surface: the resolver reads the discriminator and returns a PersonReference or an OrganizationReference carrying only the fields that apply. The denormalisation is a storage detail – the same split the schema already draws between logical fields and physicalFields, and between internal fields and what reaches a writer.
C. One reference field per target, disjoined at query time.creator → persons.idandcreator → organizations.id; each document resolves in one and dangles in the other, and a polymorphic join compiles to $persons(…) || $organizations(…). No new collection and per-type fields survive, but it rests on two behaviours worth confirming first: whether Typesense tolerates a permanently dangling async_reference rather than a not-yet-resolved one, and whether #712's rule of at most one joinable field per (root type, label source) was written to exclude this or simply did not consider it.
B asks least of the engine – a normal collection reached by a normal single-target reference – where C depends on two unverified behaviours and A gives up the functionality that motivated the exercise.
What B costs
Memory. Typesense holds its indexes in RAM, so a second copy of every agent is paid for in RAM, not disk. This is the largest cost and it scales with the corpus rather than with the number of polymorphic references.
Sparsity. Every merged field must be optional, so sort_by needs missing-value handling, and a filter over a field only one type has silently restricts the result to that type – $agents(birthDate:<1900) cannot match an organization. Correct, but worth being explicit about rather than discovering.
The discriminator is not guaranteed single-valued. The profile co-types referenced entities freely, and it is not hypothetical: in the Drapo dataset 140 of 158 Persons and 388 of 1045 Organizations are alsoDefinedTerm. Person ∩ Organization is empty there, so creator's discriminator is unambiguous today – but by that dataset's shape, not by any rule. A node carrying two of the merged classes needs a defined answer.
Field-name collisions across merged types need a rule rather than luck. Here the overlap is only label, same kind.
Migration. A reference field cannot be added to an existing collection by alter, so introducing or widening agents means a rebuild, on top of the rebuild the referencing collection already needs.
Two addresses for one entity.persons and agents both hold every person, so a consumer has two ways to reach one thing.
Open on the input side
Under the interface, birthDate is not a field of the polymorphic reference, so a filter has to name the target type – the mirror of what __typename does for output. #712's on path is the likely home.
Summary
labelSourcenames one root type, so a reference whose range spans two of them can only label half its values – and its GraphQL type claims the wrong kind for the other half.Observed
SCHEMA-AP-NDE ranges
CreativeWork.creatoroverPersonorOrganization. In limburg/lol it is declaredref: { typeName: 'Person' }, labelSource: 'Person', and the facet comes back:personsorganizationslabelThe split is exact: every labelled value is a Person, every null one an Organization. The names exist – they are in the
organizationscollection.The missing label is the visible half. The other half is that all ten are typed
PersonReference, so a consumer routing a click oncreator.idto its person page misses four of them with nothing in the response to indicate it. That is wrong today, before any fix.Proposal
Make a
labelOnlyreference an interface implemented once per label source:with
labelSourceaccepting several types and resolving across their collections.__typenamethen reports which one matched, per value.Named for the mechanism, not for a grouping of the underlying classes: the RDF asserts no supertype over
PersonandOrganization(foaf:Agentis declared as a range but never asserted, so selecting on it matches nothing). The interface claims only what is true of every reference – it has an id and a resolved label.Why an interface
creator { id name }would need... on PersonReference. An interface keeps that selection working unchanged.typescalar on a flat reference is a dead end. The moment a reference carries more than a label, per-type fields have nowhere to live:birthDateapplies to a person and not to an organization. The interface gives each label source somewhere to grow independently.Joins do not subsume this
A Typesense reference field names one target collection (
reference: "persons.id"); the array variants hold several references into that same collection, not references into different ones. A field whose values live in two collections therefore cannot be expressed as a join, socreatorcan never bejoinableand #712 will never reach it.That makes query-time label resolution the permanent mechanism for polymorphic references rather than a stopgap until joins arrive – and the reason it has to resolve across several label sources, because nothing else will. Where such a reference eventually needs more than a label, it has to come from the label-source lookup fetching more fields, not from a join.
Scope
Not systemic yet. Of LOL's reference facets only
creatorshows the gap;aboutis fully labelled – but its profile range is CreativeWork | Person | Organization too, and it works only because that publisher co-types referenced terms consistently. The same failure is latent there.No conflict with #712:
joinableis opt-in, so a polymorphic reference simply does not set it and keeps query-time label resolution. Related to #714, which is the other half of how these labels surface – a reference renameslabeltoname.Recovering joins for a polymorphic reference
Losing joins here is a real cost, not just a missing label: filtering works by their creator's properties – died before 1900, located in Maastricht – is what a join buys and label resolution never can. Three ways it could be recovered, differing in what survives.
A. A shared collection of the common fields. An
agentscollection carrying whatPersonandOrganizationshare, as a single-target join. Needs a root type to select on several classes –classis one IRI today, thoughsh:targetClassmay appear more than once, so the extension is natural. But the intersection is essentially justlabel, so this restores joins over precisely what label resolution already gives, and the per-type filters stay out of reach. It fixes the mechanism without recovering the functionality.B. The same collection denormalised to the union of fields, engine-side only.
agentscarriesbirthDateandlocation, each absent for the kind it does not apply to, plus a discriminator.creatoris a single-target reference to it, so joins work with no dangling references and no disjunction. The sparse columns never surface: the resolver reads the discriminator and returns aPersonReferenceor anOrganizationReferencecarrying only the fields that apply. The denormalisation is a storage detail – the same split the schema already draws between logical fields andphysicalFields, and between internal fields and what reaches a writer.C. One reference field per target, disjoined at query time.
creator → persons.idandcreator → organizations.id; each document resolves in one and dangles in the other, and a polymorphic join compiles to$persons(…) || $organizations(…). No new collection and per-type fields survive, but it rests on two behaviours worth confirming first: whether Typesense tolerates a permanently danglingasync_referencerather than a not-yet-resolved one, and whether #712's rule of at most one joinable field per (root type, label source) was written to exclude this or simply did not consider it.B asks least of the engine – a normal collection reached by a normal single-target reference – where C depends on two unverified behaviours and A gives up the functionality that motivated the exercise.
What B costs
sort_byneeds missing-value handling, and a filter over a field only one type has silently restricts the result to that type –$agents(birthDate:<1900)cannot match an organization. Correct, but worth being explicit about rather than discovering.agentsneeds its own provenance stamp and its own membership sweep, and inherits Single-valued provenance stamp: shared entities lose sources and can be swept while still contributed #697 in a second place: an entity contributed by two datasets is stamped with whichever wrote last, in both collections independently.Persons and 388 of 1045Organizations are alsoDefinedTerm.Person∩Organizationis empty there, socreator's discriminator is unambiguous today – but by that dataset's shape, not by any rule. A node carrying two of the merged classes needs a defined answer.label, same kind.alter, so introducing or wideningagentsmeans a rebuild, on top of the rebuild the referencing collection already needs.personsandagentsboth hold every person, so a consumer has two ways to reach one thing.Open on the input side
Under the interface,
birthDateis not a field of the polymorphic reference, so a filter has to name the target type – the mirror of what__typenamedoes for output. #712'sonpath is the likely home.