Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions packages/search-typesense/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 7 additions & 4 deletions packages/search/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 21 additions & 6 deletions packages/search/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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*
Expand Down
25 changes: 7 additions & 18 deletions packages/search/test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' });
});
});

Expand Down
27 changes: 27 additions & 0 deletions packages/search/test/qualified-relation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/search/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default mergeConfig(
thresholds: {
functions: 100,
lines: 100,
branches: 99.71,
branches: 99.72,
statements: 100,
},
},
Expand Down