diff --git a/docs/decisions/0004-search-api-graphql-surface.md b/docs/decisions/0004-search-api-graphql-surface.md index 5c9e5028..66e360a4 100644 --- a/docs/decisions/0004-search-api-graphql-surface.md +++ b/docs/decisions/0004-search-api-graphql-surface.md @@ -12,6 +12,13 @@ Amended 2026-08-10: a `labelOnly` reference serves its resolved label as `label`, not `name` – the word the label source declares and a reference facet’s bucket already carries. The `name` below is historical. +Amended 2026-08-13: that word is the label source’s own – its +`labelField`, `label` by default (see +[ADR 8](./0008-resolve-reference-labels-from-per-reference-label-sources.md)), +so a source declaring `labelField: 'name'` is served as `{ id, name }`. A +`ValueBucket`’s `label` is unchanged: it is per-facet-field, and a per-type name +would make the bucket type non-uniform. + ## Context Given the engine-neutral core of [ADR 3](./0003-search-api-core-query-model.md), the first diff --git a/docs/decisions/0008-resolve-reference-labels-from-per-reference-label-sources.md b/docs/decisions/0008-resolve-reference-labels-from-per-reference-label-sources.md index b3a09823..88a407a5 100644 --- a/docs/decisions/0008-resolve-reference-labels-from-per-reference-label-sources.md +++ b/docs/decisions/0008-resolve-reference-labels-from-per-reference-label-sources.md @@ -11,6 +11,14 @@ Amends the reference model of part of search as a Configurable Pipeline instance ([#534](https://github.com/ldelements/lde/issues/534)). +Amended 2026-08-13: a type may name its own label field with +`SearchTypeBase.labelField`, defaulting to `label`. The rules below are the +label field’s, not the word’s – so a schema declaring to a profile that models +display names as `schema:name` can serve `name` without the surface word being +decided by the internal role of being a label source. The one-word invariant is +untouched: whichever word the source declares, the reference resolving against +it and the label field itself use that word. + ## Context Reference labels resolved from one global sidecar `labels` collection, diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index fb1a2332..a71eff9d 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -153,9 +153,10 @@ editor. - **Output type** (the `SearchType`’s `name`): localized text → best-first `[LanguageString!]!` (`[0].language` is the language actually served); references → named per-shape - types (`Organization`, `Term`) with an `id` and a `label` – the same word the - [label source](./search#field-model) declares, so a reference reads like the - collection it points at – a reference whose `typeName` + types (`Organization`, `Term`) with an `id` and a label field, keyed under the + same word the [label source](./search#naming-the-label-field) declares + (`label`, unless it names another with `labelField`), so a reference reads + like the collection it points at – a reference whose `typeName` names a root type (`creator` → `Person`) is served under a derived name (`PersonReference`), since GraphQL type names must be unique; a **surfaced inline reference** instead gets a type built from its Reference Type’s own diff --git a/docs/reference/search-typesense.md b/docs/reference/search-typesense.md index b81acec7..f0b6e5d4 100644 --- a/docs/reference/search-typesense.md +++ b/docs/reference/search-typesense.md @@ -122,7 +122,9 @@ down. reimplementing it. A label source is just another `SearchType` in the schema (with an `output`, -`searchable` text field called `label`), so its collection is named by the same +`searchable` text field under its +[`labelField`](./search#naming-the-label-field) name, `label` by default), so +its collection is named by the same convention as any other type’s – a typed entity collection and a ‘labels collection’ are the same kind of thing. diff --git a/docs/reference/search.md b/docs/reference/search.md index 19aafb6b..185d185f 100644 --- a/docs/reference/search.md +++ b/docs/reference/search.md @@ -92,8 +92,8 @@ Exports are stratified by audience: (`media.contentUrl`), the nested counterpart of `physicalFields`; - `inlineFramingDepth` – the framing depth a Root Type’s inline reference graph needs; - - `labelFieldOf` – the `label` text field a label source serves labels - from; + - `labelFieldOf`, `labelFieldNameOf` – the text field a label source serves + labels from, and the name it serves it under (`label` by default); - `filterOperatorFor`, `filterOperator`, `FilterOperator` – the kind→operator table and the operator a `Filter` value carries; - `validateQuery` / `QueryIssue`, `assertValidQuery` – query validation @@ -342,17 +342,51 @@ way that depth could be unbounded – so it stays a bounded property of the declaration. A reference resolves its label from a **label source**: `labelSource` names -the `SearchType` whose collection holds the referenced entities. The named -type must declare an `output`, `searchable` text field called `label` – +the Root Type whose collection holds the referenced entities – a Root Type +specifically, since the labels are read from that collection. It must declare +an `output`, `searchable` text field named by its label field (below) – `searchSchema` validates this schema-wide, so a dangling or unsuitable label source fails at startup. A reference without a `labelSource` stays id-only. -The resolved label is called **`label`** wherever it surfaces: on the label +The resolved label carries **one word** wherever it surfaces: on the label source’s own type, on the reference that resolves against it (`dataset { id label }`), and on a reference facet’s bucket. One word, one meaning – so a collection reads the same whether you arrive at it directly or through a reference. +#### Naming the label field + +That word is `label` by default, but a type may name its own display field with +`labelField`, so the surface word is the profile’s rather than one an internal +role imposes. [SCHEMA-AP-NDE](https://docs.nde.nl/schema-profile/), for +instance, models display names as `schema:name` on `Person`, `Organization`, +`Place` and `DefinedTerm`, and consumers expect them served as `name`: + +```ts +const term = defineSearchType({ + name: 'Term', + class: `${SCHEMA}DefinedTerm`, + labelField: 'name', + fields: [ + { + name: 'name', + kind: 'text', + locales: ['nl', 'und'], + path: [`${SCHEMA}name`], + output: true, + searchable: { weight: 4 }, + }, + ], +}); +``` + +The named field must still be an `output`, `searchable` text field – the rules +are the label field’s, not the word’s – and a reference resolving against this +type serves `theme { id name }`. `labelField` is ignored for a type nothing +resolves labels from; without it, everything keeps serving `label`. A facet +bucket’s `label` is unaffected: it is per-facet-field, and a per-type name would +make the bucket shape non-uniform. + ### Describing a field A field may carry a `description`, which surfaces wherever an API has somewhere diff --git a/packages/search-api-graphql/src/build-schema.ts b/packages/search-api-graphql/src/build-schema.ts index 868dc166..8e1c8204 100644 --- a/packages/search-api-graphql/src/build-schema.ts +++ b/packages/search-api-graphql/src/build-schema.ts @@ -29,6 +29,7 @@ import { } from '@lde/search'; import { AND_KEY, + DEFAULT_LABEL_FIELD, facetableFields, filterableFields, filterOn, @@ -36,6 +37,7 @@ import { ID_FIELD, OR_KEY, isRangeFacet, + labelFieldNameOf, nestedReferenceType, outputFields, pageForOffset, @@ -126,9 +128,10 @@ export function buildGraphQLSchema( ): GraphQLSchema { const languageOrder = options.languageOrder ?? defaultLanguageOrder; const maxPerPage = options.maxPerPage ?? 100; - const rootTypeNames = new Set( - [...schema.values()].map((searchType) => searchType.name), + const rootTypesByName = new Map( + [...schema.values()].map((searchType) => [searchType.name, searchType]), ); + const rootTypeNames = new Set(rootTypesByName.keys()); for (const name of Object.keys(options.types ?? {})) { if (!rootTypeNames.has(name)) { throw new Error( @@ -236,6 +239,22 @@ export function buildGraphQLSchema( // and rejects duplicate names schema-wide. const referenceTypes = new Map(); const takenTypeNames = new Set(rootTypeNames); + /** The label key each id-plus-label reference type was registered with; no + * entry for a surfaced inline one, which carries fields instead of a label. */ + const labelKeys = new Map(); + + /** The key a reference serves its resolved label under: the name its label + * source declares that label field with, so the reference and the type it + * resolves against agree on the word. An id-only reference resolves no + * label, so it keeps the default. `searchSchema` guarantees the source is a + * declared Root Type. */ + function labelKeyOf( + field: SearchField & { readonly kind: 'reference' }, + ): string { + return field.labelSource === undefined + ? DEFAULT_LABEL_FIELD + : labelFieldNameOf(rootTypesByName.get(field.labelSource)!); + } /** * Register the GraphQL type one reference field is served as, once per @@ -247,11 +266,19 @@ export function buildGraphQLSchema( * stays the id-plus-label pair its strategy carries. */ function registerReferenceType(field: SearchField, owner: SearchType): void { - if ( - field.kind !== 'reference' || - field.ref === undefined || - referenceTypes.has(field.ref.typeName) - ) { + if (field.kind !== 'reference' || field.ref === undefined) { + return; + } + if (referenceTypes.has(field.ref.typeName)) { + // Fields sharing a `ref.typeName` share one emitted type, so they must + // agree on the word it serves its label under – otherwise which one wins + // would come down to declaration order. + const registered = labelKeys.get(field.ref.typeName); + if (registered !== undefined && registered !== labelKeyOf(field)) { + throw new Error( + `Reference “${owner.name}.${field.name}” serves its label as “${labelKeyOf(field)}”, but “${field.ref.typeName}” is already served with “${registered}”; fields sharing a reference type must resolve labels from sources that agree on their labelField.`, + ); + } return; } const { typeName } = field.ref; @@ -265,6 +292,9 @@ export function buildGraphQLSchema( } takenTypeNames.add(graphQLName); const nested = nestedReferenceType(schema, field); + if (nested === undefined) { + labelKeys.set(typeName, labelKeyOf(field)); + } referenceTypes.set( typeName, new GraphQLObjectType({ @@ -277,11 +307,11 @@ export function buildGraphQLSchema( > => nested === undefined ? { + // The same word the label source declares its label field + // under (`label` by default): one resolved label, one name for + // it wherever it surfaces. id: { type: new GraphQLNonNull(GraphQLString) }, - // `label`, the same word the label source declares and a - // reference facet’s bucket carries: one resolved label, one - // name for it wherever it surfaces. - label: labelList( + [labelKeyOf(field)]: labelList( (source) => source.label as LocalizedValue | undefined, ), } diff --git a/packages/search-api-graphql/test/build-schema.test.ts b/packages/search-api-graphql/test/build-schema.test.ts index 73cd7254..77a6c01c 100644 --- a/packages/search-api-graphql/test/build-schema.test.ts +++ b/packages/search-api-graphql/test/build-schema.test.ts @@ -961,6 +961,73 @@ describe('buildGraphQLSchema', () => { ); }); + it('serves the resolved label under the label source’s own label field name', () => { + const namedLabel: SearchType = { ...PERSON, labelField: 'name' }; + const withReferenceToRoot: SearchType = { + name: 'CreativeWork', + class: 'https://schema.org/CreativeWork', + fields: [ + { + name: 'author', + kind: 'reference', + output: true, + labelSource: 'Person', + ref: { typeName: 'Person', strategy: 'labelOnly' }, + }, + ], + }; + const sdl = printSchema( + buildGraphQLSchema(searchSchema(namedLabel, withReferenceToRoot)), + ); + expect(sdl).toMatch( + /type PersonReference \{\s+id: String!\s+name: \[LanguageString!\]!\s+\}/, + ); + }); + + it('throws when fields sharing a reference type disagree on the label word', () => { + // One emitted type cannot serve two words, and which one won would + // otherwise come down to declaration order. + const namedLabel: SearchType = { ...PERSON, labelField: 'name' }; + const organization: SearchType = { + name: 'Organization', + class: 'https://schema.org/Organization', + fields: [ + { + name: 'label', + kind: 'text', + locales: ['nl'], + output: true, + searchable: { weight: 1 }, + }, + ], + }; + const twoSources: SearchType = { + name: 'CreativeWork', + class: 'https://schema.org/CreativeWork', + fields: [ + { + name: 'creator', + kind: 'reference', + output: true, + labelSource: 'Person', + ref: { typeName: 'Agent', strategy: 'labelOnly' }, + }, + { + name: 'publisher', + kind: 'reference', + output: true, + labelSource: 'Organization', + ref: { typeName: 'Agent', strategy: 'labelOnly' }, + }, + ], + }; + expect(() => + buildGraphQLSchema(searchSchema(namedLabel, organization, twoSources)), + ).toThrow( + /“CreativeWork.creator” serves its label as “name”, but “Agent” is already served with “label”/, + ); + }); + it('throws when the derived reference name is itself taken', () => { const takenDerivedName: SearchType = { name: 'PersonReference', diff --git a/packages/search-api-graphql/test/print-sdl.test.ts b/packages/search-api-graphql/test/print-sdl.test.ts index 9fdc93d6..37e5c572 100644 --- a/packages/search-api-graphql/test/print-sdl.test.ts +++ b/packages/search-api-graphql/test/print-sdl.test.ts @@ -16,6 +16,7 @@ beforeEach(async () => { afterEach(() => { vi.doUnmock('prettier'); + vi.resetModules(); }); describe('printSchemaModuleSdl', () => { @@ -119,12 +120,17 @@ describe('printSchemaModuleSdl', () => { }); it('names the optional Prettier peer when it cannot be loaded', async () => { + // Reset first: the tests above have already pulled the real Prettier into + // the module graph, and a mock only applies to a module imported after it. + vi.resetModules(); vi.doMock('prettier', () => { throw new Error('Cannot find package ‘prettier’'); }); + const { printSchemaModuleSdl: printWithoutPrettier } = + await import('../src/print-sdl.js'); await expect( - printSchemaModuleSdl({ modulePath: fixture('no-options.mjs') }), + printWithoutPrettier({ modulePath: fixture('no-options.mjs') }), ).rejects.toThrowError( /Formatting the SDL requires “prettier”, an optional peer dependency .*, which could not be loaded: /, ); diff --git a/packages/search-api-graphql/vite.config.ts b/packages/search-api-graphql/vite.config.ts index 941c1afb..9e5aa112 100644 --- a/packages/search-api-graphql/vite.config.ts +++ b/packages/search-api-graphql/vite.config.ts @@ -23,7 +23,7 @@ export default mergeConfig( lines: 100, // Full-suite baseline, re-anchored when covered branches are // deleted (autoUpdate only ever raises; see AGENTS.md). - branches: 95.62, + branches: 95.85, statements: 100, }, }, diff --git a/packages/search/CONTEXT.md b/packages/search/CONTEXT.md index ebe4f443..d4603c1d 100644 --- a/packages/search/CONTEXT.md +++ b/packages/search/CONTEXT.md @@ -116,9 +116,14 @@ is an Internal Field; a type without a `class` is a Reference Type.** **Label Source**: The Search Type whose collection resolves a reference’s labels. Must declare an -`output`, `searchable` text field named `label`. +`output`, `searchable` text field named by its **Label Field**. _Avoid_: labels collection, lookup table +**Label Field**: +The name a Label Source serves its label under, and so the word every surface +resolving against it uses. Declared as `labelField`; `label` by default. +_Avoid_: display field, title field + **Reference Strategy**: How much of a referenced entity a reference carries: `idOnly` (the IRI), `labelOnly` (+ its Label Source’s label, resolved at query time), `inline` diff --git a/packages/search/src/adapter.ts b/packages/search/src/adapter.ts index 453143a1..00295edd 100644 --- a/packages/search/src/adapter.ts +++ b/packages/search/src/adapter.ts @@ -31,6 +31,8 @@ export { AND_KEY, OR_KEY, labelFieldOf, + labelFieldNameOf, + DEFAULT_LABEL_FIELD, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, diff --git a/packages/search/src/schema.ts b/packages/search/src/schema.ts index 652661c3..8f900e24 100644 --- a/packages/search/src/schema.ts +++ b/packages/search/src/schema.ts @@ -209,7 +209,8 @@ export interface ReferenceField extends SearchFieldBase, Searchable { /** * The `name` of the {@link SearchType} whose collection resolves this * reference’s labels – its ‘label source’. The named type must declare an - * `output`, `searchable` text field called `label` (validated by + * `output`, `searchable` text field under its + * {@link SearchTypeBase.labelField} name (`label` by default; validated by * {@link searchSchema}), so an engine can both reconstruct the label and * search it (typeahead). Omit for an id-only reference: no label * resolution. @@ -290,6 +291,14 @@ export interface SearchTypeBase { * than derived from the `class` IRI, so re-modelling the vocabulary cannot * silently rename the public contract. */ readonly name: string; + /** The `name` of this type’s display field, when it serves as a + * {@link ReferenceField.labelSource} – the word the API surfaces a resolved + * label under. Defaults to `label`; declare another (e.g. `name`, for a + * profile that models display names as `schema:name`) so the surface word is + * the profile’s, not a role the type happens to play internally. The named + * field must be an `output`, `searchable` text field ({@link labelFieldOf}). + * Ignored for a type nothing resolves labels from. */ + readonly labelField?: string; readonly fields: readonly SearchField[]; } @@ -680,16 +689,26 @@ const UNSERVICEABLE_NESTED_ROLES = [ 'sortable', ] as const; +/** The label field name a type falls back to when it declares no + * {@link SearchTypeBase.labelField}. */ +export const DEFAULT_LABEL_FIELD = 'label'; + +/** The `name` the type serves its label under: its declared + * {@link SearchTypeBase.labelField}, else `label`. */ +export function labelFieldNameOf(searchType: SearchType): string { + return searchType.labelField ?? DEFAULT_LABEL_FIELD; +} + /** - * The text field a label source serves labels from – the ‘label’ convention - * in one place: an `output` (something to reconstruct a label from), - * `searchable` (something to type ahead against) text field called `label`. - * Returns `undefined` when the type declares no such field; a schema built by - * {@link searchSchema} guarantees it for every type named as a - * {@link ReferenceField.labelSource}. + * The text field a label source serves labels from – the label convention in + * one place: an `output` (something to reconstruct a label from), `searchable` + * (something to type ahead against) text field named by + * {@link labelFieldNameOf}. Returns `undefined` when the type declares no such + * field; a schema built by {@link searchSchema} guarantees it for every type + * named as a {@link ReferenceField.labelSource}. */ export function labelFieldOf(searchType: SearchType): TextField | undefined { - const field = fieldNamed(searchType, 'label'); + const field = fieldNamed(searchType, labelFieldNameOf(searchType)); return field !== undefined && field.kind === 'text' && field.output === true && @@ -702,6 +721,11 @@ export function labelFieldOf(searchType: SearchType): TextField | undefined { * Every {@link ReferenceField.labelSource} must name a declared type that can * actually serve labels ({@link labelFieldOf}). Checked schema-wide, because * a single declaration cannot see its siblings. + * + * That leaves only a {@link RootType}, without naming one: a label field is + * `searchable`, and {@link assertServiceableNestedFields} already rejects a + * `searchable` field on a Reference Type – so a Reference Type can never serve + * labels, and a resolved label always has a collection to come from. */ function assertResolvableLabelSources(types: readonly SearchType[]): void { const byName = new Map( @@ -727,7 +751,7 @@ function assertResolvableLabelSources(types: readonly SearchType[]): void { } if (labelFieldOf(source) === undefined) { throw new Error( - `Reference “${searchType.name}.${field.name}” uses label source “${field.labelSource}”, which must declare an output, searchable text field “label”.`, + `Reference “${searchType.name}.${field.name}” uses label source “${field.labelSource}”, which must declare an output, searchable text field “${labelFieldNameOf(source)}”.`, ); } } diff --git a/packages/search/test/schema.test.ts b/packages/search/test/schema.test.ts index 3aa318dd..39b9ace2 100644 --- a/packages/search/test/schema.test.ts +++ b/packages/search/test/schema.test.ts @@ -1083,6 +1083,83 @@ describe('searchSchema validation', () => { ).toThrow(/label source/); }); + it('accepts a label source that names its own label field', () => { + expect(() => + searchSchema( + { + name: 'Term', + class: 'https://example.org/DefinedTerm', + labelField: 'name', + fields: [ + { + name: 'name', + kind: 'text', + locales: ['und', 'nl'], + output: true, + searchable: { weight: 1 }, + }, + ], + }, + { + name: 'Dataset', + class: DATASET, + fields: [{ name: 'theme', kind: 'reference', labelSource: 'Term' }], + }, + ), + ).not.toThrow(); + }); + + it('rejects a label source whose declared label field is missing, naming it', () => { + expect(() => + searchSchema( + { ...organization, labelField: 'name' }, + { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'publisher', + kind: 'reference', + labelSource: 'Organization', + }, + ], + }, + ), + ).toThrow(/must declare an output, searchable text field “name”/); + }); + + it('rejects a Reference Type as a label source: it cannot be searchable', () => { + // Why a label source is always a Root Type, and so always has a + // collection to resolve from: a Reference Type carries `output` only. + expect(() => + searchSchema( + { + name: 'Agent', + fields: [ + { + name: 'label', + kind: 'text', + locales: ['und'], + output: true, + searchable: { weight: 1 }, + }, + ], + }, + { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'publisher', + kind: 'reference', + labelSource: 'Agent', + }, + ], + }, + ), + ).toThrow(/Nested field “Agent.label” declares “searchable”/); + }); + it('rejects a labelSource on a non-reference field', () => { expect(() => searchSchema(organization, { diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index 34d009f3..7248b3c0 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.41, + branches: 99.42, statements: 100, }, },