Skip to content

Implement inline references and class-less reference types #608

Description

@ddeboer

Implement inline references and class-less reference types.

Problem

ReferenceField.ref.strategy declares 'labelOnly' | 'idOnly' | 'inline', but only labelOnly is implemented – inline is a forward declaration. And ref.typeName is today “a name, not a key: it need not correspond to any indexed root type”, so there is nothing for inline to resolve to.

inline is what lets a field read a value behind a qualified hop – one whose intermediate node must be filtered to identify the value. A SPARQL property path cannot constrain an intermediate node; neither can SHACL’s sh:path (it needs sh:qualifiedValueShape, a constraint, not a path).

The Dataset Register has several, and the sharpest is five fields sharing one path: quadsValidated, schemaApNdeConformant, subjectUrisSampled, subjectUrisResolved and subjectNamespaceDurable are all dqv:hasQualityMeasurement/dqv:value, differing only by the intermediate node’s dqv:isMeasurementOf. No path expression can tell them apart.

Inlining the intermediate node together with its discriminator lets a derive select from it – so the filtering lives where computation belongs, and no path grammar has to grow into a query language.

Scope

  • Implement strategy: 'inline': the reference carries its referent’s projected fields.
  • Reference types: a SearchType declaring no class, reached only through a reference. See What a reference type is below.
  • A field’s path is relative to the node of the type that declares it – a root type’s root, a reference type’s referent. No absolute paths, no subject-variable axis.
  • Framing follows the reference graph to the depth the schema declares, rather than the hardcoded single hop frame-by-type.ts embeds today. The Dataset Register needs two levels (void:subset[conformsTo <iiif>]dqv:hasQualityMeasurement[isMeasurementOf …]dqv:value).
  • searchSchema rejects inline cycles – the only way depth could be unbounded. Depth then becomes a property of the declaration rather than a knob or a constant.

What a reference type is, and why it has no class

SearchType.class does exactly two jobs today:

  1. Root discovery. frame-by-type finds a type's roots by matching rdf:type against it. (Project inside the batch, not at the dataset flush (implement ADR 13) #606 replaces this with the stage's itemSelector, but the point stands: it identifies roots.)
  2. The SearchSchema map key – and therefore, via searchIndexWriter's [...schema.values()].map(writerFor), it means “this type gets a collection”.

A reference type needs neither, for one reason:

Its type comes from the edge, not from the node. Given ?dataset ^schema:about ?r, we know ?r is a Registration because Dataset.registration declares ref.typeName: 'Registration'. The pointer types the referent. A root type has nothing pointing at it, so its type must come from the node or the selector – a reference type always has a pointer, so restating the type on the node is redundant, and would be a second thing to keep in sync.

And the absence is load-bearing, not stylistic. A class would put the type in the SearchSchema map, and the writer opens one run – one Typesense collection – per entry. A Registration with a class would silently acquire a collection nobody asked for. The absence is what keeps it out. (Same idiom as the Internal Field: a field without a Role is internal; a type without a class is a reference type.)

Corroborating that class is not a source-side fact: three of the Dataset Register's four current types have no source class at all – register-source.ts mints a dcat:Dataset, and label-collections.ts injects the other three – so class was never doing what its JSDoc claims for them either.

// ROOT TYPE — declares a class. Roots are selected for it; it gets a collection.
{ name: 'Dataset', class: 'http://www.w3.org/ns/dcat#Dataset', fields: [
    // no Roles -> Internal Field (#607): read for the derives, pruned before the writer
    { name: 'registration', kind: 'reference', path: '^schema:about', array: true,
      ref: { typeName: 'Registration', strategy: 'inline' } },

    { name: 'datePosted', kind: 'date', output: true,
      derive: (doc) => newestBy(doc.registration, 'dateRead')?.datePosted },
]}

// REFERENCE TYPE — no class. Reached only through Dataset.registration.
// Never selected, never framed by type, never indexed: no collection, no run, no writer.
{ name: 'Registration', fields: [
    { name: 'dateRead',   kind: 'date', path: 'https://schema.org/dateRead' },
    { name: 'datePosted', kind: 'date', path: 'https://schema.org/datePosted' },
]}

Its paths are relative to the registration node, because a field's path is always relative to the node of the type that declares it.

API shape (to settle during implementation):

  • class becomes optional on SearchType; a Root Type is one that declares it. Prefer making the two shapes distinguishable at the type level, the way SearchField already discriminates by kind, so an indexed reference type fails to compile rather than at run time.
  • searchSchema() partitions its arguments: root types key the map (unchanged, so searchIndexWriter still opens exactly the right collections), reference types go into a name index that ref.typeName resolves against. The existing distinct-name check already spans both.
  • ref.typeName currently is “a name, not a key: it need not correspond to any indexed root type”. With inline it must resolve to a declared type, so that becomes a real lookup and a new validation – an unresolvable typeName should fail at searchSchema() construction, not at index time.

Notes

Roles decide whether nesting surfaces, and an inline reference has two quite different jobs:

So RDF depth and API shape stay independent: inline as deep as the source demands, expose exactly the flat fields you want.

Framing depth is bounded per batch, so the O(N²) whole-graph framing cost frame-by-type.ts warns about applies to batchSize roots, not to the graph.

Acceptance

  • An inline reference carries its referent’s projected fields, to the schema’s declared depth.
  • A reference type is declared, resolved and projected, and no writer ever opens a run for it.
  • An inline cycle fails at searchSchema construction, not at index time.

Relates to

Depends on #607. Precondition for #548. Design context: #534.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions