Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/decisions/0004-search-api-graphql-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/search-api-graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/search-typesense.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 39 additions & 5 deletions docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 41 additions & 11 deletions packages/search-api-graphql/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import {
} from '@lde/search';
import {
AND_KEY,
DEFAULT_LABEL_FIELD,
facetableFields,
filterableFields,
filterOn,
filterOperatorFor,
ID_FIELD,
OR_KEY,
isRangeFacet,
labelFieldNameOf,
nestedReferenceType,
outputFields,
pageForOffset,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -236,6 +239,22 @@ export function buildGraphQLSchema(
// and rejects duplicate names schema-wide.
const referenceTypes = new Map<string, GraphQLObjectType>();
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<string, string>();

/** 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
Expand All @@ -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;
Expand All @@ -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({
Expand All @@ -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,
),
}
Expand Down
67 changes: 67 additions & 0 deletions packages/search-api-graphql/test/build-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 7 additions & 1 deletion packages/search-api-graphql/test/print-sdl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ beforeEach(async () => {

afterEach(() => {
vi.doUnmock('prettier');
vi.resetModules();
});

describe('printSchemaModuleSdl', () => {
Expand Down Expand Up @@ -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: /,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/search-api-graphql/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
7 changes: 6 additions & 1 deletion packages/search/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions packages/search/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export {
AND_KEY,
OR_KEY,
labelFieldOf,
labelFieldNameOf,
DEFAULT_LABEL_FIELD,
isRangeFacet,
isoToUnixSeconds,
unixSecondsToIso,
Expand Down
Loading