diff --git a/docs/reference/search.md b/docs/reference/search.md index 9efc1eca..f9017eda 100644 --- a/docs/reference/search.md +++ b/docs/reference/search.md @@ -381,12 +381,20 @@ is indexed. So a schema can nest ten fields for display and index one, and pay for the one. A referent needs no identity of its own: the nesting carries its fields, not a -document key. A blank-node referent – whose `@id` JSON-LD 1.1 framing prunes – -nests exactly like a named one, minus the `id`, so a profile that allows a blank -node here needs no flattening workaround. A blank node label (`_:b0`) never -becomes that `id`: framing mints it per call. Only a root, which is keyed, must -be an IRI. Fields are what make a referent, so a value that projects none – a -literal under the reference’s alias, say – nests nothing. +document key. An entry of a Reference Type carries **no `id` at all** – named +node or blank – because nothing resolves an entry by key: it is kept in no +collection, a weld names the flat identity companion beside it, and an entry's +own key answers no query. So a profile that allows a blank node here needs no +flattening workaround, and an entry's shape never depends on whether a publisher +minted an IRI for the thing it describes. + +A `local` lookup is the exception, because it +nests a **Root Type**: there the `id` is the key that type's own collection files +the referent under, and the lookup resolves against exactly it. Only a keyed +document has an id, and it must be an IRI – a blank node label (`_:b0`) never +becomes one, since framing mints it per call. Fields are what make a referent, +so a value that projects none – a literal under the reference's alias, say – +nests nothing. So RDF depth and API shape stay independent: inline as deep as the source demands, expose exactly the flat fields you want. Framing follows the inline diff --git a/packages/search-typesense/src/search.ts b/packages/search-typesense/src/search.ts index 163f1d37..b1caa61c 100644 --- a/packages/search-typesense/src/search.ts +++ b/packages/search-typesense/src/search.ts @@ -1052,9 +1052,11 @@ function localizedValue( * Rebuild a surfaced inline reference from the nested object(s) the engine * stored: one {@link NestedDocument} per referent, each carrying its Reference * Type’s own output fields – so a multi-valued reference keeps every referent’s - * values grouped rather than smeared across parallel arrays. `id` is carried - * only when the referent had one; a blank-node referent nests without it, since - * a nested document is read, not addressed. A nested reference type may itself + * values grouped rather than smeared across parallel arrays. An entry of a + * Reference Type carries no `id` – a nested document is read, not addressed – + * so the one read back here belongs to a {@link ReferenceStrategy.local local} + * lookup's Root Type, whose collection files it under that key. A nested + * reference type may itself * surface an inline reference, which recurses through {@link reconstructDocument}. */ function nestedValue( diff --git a/packages/search/src/engine.ts b/packages/search/src/engine.ts index 203bd61b..e9335473 100644 --- a/packages/search/src/engine.ts +++ b/packages/search/src/engine.ts @@ -159,10 +159,13 @@ export type SearchValue = * values grouped together, so a multi-valued reference never degrades into * parallel arrays a consumer has to pair by index. * - * `id` is present only when the referent is a named node: nesting carries the - * referent’s fields, not a document key, so a blank-node referent nests exactly - * like a named one, minus the `id`. Distinct from {@link Reference}, which is - * what an `idOnly`/`labelOnly` reference carries – there the IRI *is* the value. + * **No `id`**: nesting carries the referent’s fields, not a document key, and a + * Reference Type is kept in no collection for a key to address it in. A named + * node and a blank one therefore nest alike. The exception is a + * {@link ReferenceStrategy.local local} lookup, which nests a Root Type – there + * the `id` is the key its own collection files the referent under, and the + * lookup resolves against it. Distinct from {@link Reference}, which is what an + * `idOnly`/`labelOnly` reference carries – there the IRI *is* the value. */ export interface NestedDocument { readonly [field: string]: SearchValue | undefined; diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index 3e47efdf..32f8b307 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -33,11 +33,11 @@ import { } from './schema.js'; /** - * A projected node: the fields of one {@link SearchType}, flat. A root always - * carries the `id` that keys it – it is a {@link SearchDocument} – but an inline - * referent carries one only when the referent is a named node: a nested document - * is not a document key, so nesting needs the referent’s fields, not its - * identity. + * A projected node: the fields of one {@link SearchType}, flat. A **Root Type** + * carries the `id` that keys it – as a root it is a {@link SearchDocument}, and + * nested by a {@link ReferenceStrategy.local local} lookup it is what the lookup + * resolves against. A **Reference Type** never does: it is kept in no collection, + * so nesting needs the referent’s fields and not its identity. */ export type ProjectedNode = Record; @@ -195,6 +195,18 @@ function documentIdOf( node: FramedNode, searchType: SearchType, ): string | undefined { + // A Reference Type is nested inside its referrer and keyed in no collection + // of its own, so its entries carry no `id` – whether or not the graph happened + // to name the node. Nesting carries a referent's FIELDS, not a document key + // (ADR 24), and nothing reads one here: the collection declares an `id` for a + // locally-nested Root Type alone, a weld names the flat identity companion + // beside the entry, and an entry's own key answers no query. Emitting it made + // an entry's shape depend on whether a publisher minted an IRI for a + // relationship – arbitrary to a consumer, and a field the collection never + // declared. + if (searchType.class === undefined) { + return undefined; + } const nodeIri = documentKey(node); if (nodeIri === undefined || searchType.key === undefined) { return nodeIri; @@ -654,7 +666,10 @@ function applyProjectionValue( * {@link ProjectedNode} for a single reference, an array for an `array` one. * A referent needs **no identity**: nesting carries its fields, not a document * key, so a blank-node referent – whose `@id` JSON-LD 1.1 framing prunes when its - * label occurs once – nests exactly like a named one, minus the `id`. + * label occurs once – nests exactly like a named one. Neither carries an `id`: + * a Reference Type is kept in no collection, so nothing resolves an entry by + * key, and an entry's shape no longer depends on whether a publisher minted an + * IRI for the relationship ({@link documentIdOf}). * The referent is projected in full – internal fields included – so the * declaring type’s (or the reference type’s own) derives can read them; * {@link pruneInternalFields} then removes the internal fields from a *surfaced* diff --git a/packages/search/test/project.test.ts b/packages/search/test/project.test.ts index 7dc62486..f77b1363 100644 --- a/packages/search/test/project.test.ts +++ b/packages/search/test/project.test.ts @@ -967,10 +967,7 @@ describe('projectDocument', () => { ); // The literal contributed no referent, so the real one takes the slot. - expect(document.creator).toEqual({ - id: 'https://ex/c/3', - label_nl: 'Naam', - }); + expect(document.creator).toEqual({ label_nl: 'Naam' }); }); it('skips an inline reference the given schema does not declare', () => { @@ -1048,10 +1045,7 @@ describe('projectDocument', () => { // An output inline reference surfaces its referent as a nested Search // Document (its Reference Type’s projected fields), not a bare IRI. - expect(document.creator).toEqual({ - id: 'https://ex/c/1', - label_nl: 'Naam', - }); + expect(document.creator).toEqual({ label_nl: 'Naam' }); }); it('prunes an internal helper field from a surfaced (output) inline referent, after a derive reads it', () => { @@ -1122,7 +1116,6 @@ describe('projectDocument', () => { const [referent] = document.creator as SearchDocument[]; // The derive read the helper (sortLabel carries its value)… expect(referent).toMatchObject({ - id: 'https://ex/c/2', label_nl: 'Naam', sortLabel: 'Alt', }); @@ -1473,7 +1466,9 @@ describe('projectRoots', () => { it('keeps blank-node and named referents of one inline reference side by side', async () => { // The reproduction from the field: a work whose media are one blank node // (an image) and one named node (a IIIF manifest). Each referent keeps its - // own values grouped; only the named one is keyed. + // own values grouped, and neither is keyed: a Reference Type is nested in + // its referrer rather than kept in a collection, so nesting carries a + // referent's fields and never a document key (ADR 24). const mediaObject = defineSearchType({ name: 'MediaObject', fields: [ @@ -1534,10 +1529,7 @@ describe('projectRoots', () => { // Single-valued by declaration → the IRI itself. thumbnailUrl: 'https://ex/thumb.jpg', }, - { - id: 'https://ex/iiif/manifest', - encodingFormat: ['application/ld+json'], - }, + { encodingFormat: ['application/ld+json'] }, ]), ); expect(documents[0].media).toHaveLength(2); @@ -1699,10 +1691,7 @@ describe('projection-time values', () => { { dataset: 'https://ex/d/1' }, ); - expect(document.media).toEqual({ - id: 'https://ex/m/1', - dataset: 'https://ex/d/1', - }); + expect(document.media).toEqual({ dataset: 'https://ex/d/1' }); }); }); diff --git a/packages/search/test/qualified-relation.test.ts b/packages/search/test/qualified-relation.test.ts index e0516b30..bc76559e 100644 --- a/packages/search/test/qualified-relation.test.ts +++ b/packages/search/test/qualified-relation.test.ts @@ -122,6 +122,33 @@ describe('an edge that carries data and resolves a lookup', () => { expect(entries[1].role).toBe('auteur'); }); + it('gives an entry no id, however the graph named the edge', () => { + // A Reference Type is nested in its referrer and kept in no collection of + // its own, so an entry carries fields and no document key (ADR 24). The + // edge below IS a named node, and still gets none: nothing reads it – the + // collection declares an `id` for a locally-nested Root Type alone, and a + // weld names the flat companion beside the entry. Emitting it would make an + // entry's shape depend on whether a publisher minted an IRI for a + // relationship, which is arbitrary to a consumer. + const named = { + '@id': 'https://ex/work/17', + [workKey('creator')]: [ + { + '@id': 'https://ex/work/17#production-role-1', + [edgeKey('role')]: [{ '@value': 'etser' }], + }, + ], + }; + const [entry] = entriesOf(projectDocument(named, work, schema)); + + expect(entry).not.toHaveProperty('id'); + expect(entry.role).toBe('etser'); + // The endpoint keeps its own, because a Root Type IS keyed in a collection + // and the lookup resolves against exactly that key. + const [resolved] = entriesOf(projectDocument(node, work, schema)); + expect((resolved.creator as SearchDocument).id).toBe(RKD); + }); + it('re-keys an identified endpoint through the target’s key field', () => { // Stored as the key the Person collection files the document under, not as // the IRI the publisher minted – otherwise the lookup resolves nothing at diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index 3066345a..92874588 100644 --- a/packages/search/vite.config.ts +++ b/packages/search/vite.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( thresholds: { functions: 100, lines: 100, - branches: 99.71, + branches: 99.72, statements: 100, }, },