diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index 3e47efdf..288ea52b 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -927,16 +927,16 @@ function tuplesOf( const identity = field.ref.identity; const weldable = nestedType.fields .filter((nested) => nested.filterable === true || nested.name === identity) - .map((nested) => irAlias(nestedType, nested)) - // A leaf the frame carries at most one value for is already a tuple - // position; splitting it would copy the node to no purpose. - .filter((alias) => valuesOf(node, alias).length > 1); + .map((nested) => ({ nested, alias: irAlias(nestedType, nested) })) + // A leaf the frame carries at most one READABLE value for is already a + // tuple position; splitting it would copy the node to no purpose. + .filter(({ nested, alias }) => splittable(node, alias, nested).length > 1); if (weldable.length === 0) { return [node]; } let tuples: FramedNode[] = [node]; - for (const alias of weldable) { - const values = valuesOf(node, alias); + for (const { nested, alias } of weldable) { + const values = splittable(node, alias, nested); tuples = tuples.flatMap((tuple) => values.map((value) => ({ ...tuple, [alias]: value })), ); @@ -944,6 +944,35 @@ function tuplesOf( return tuples; } +/** + * The framed values of one leaf that fan-out may split on: those the field can + * actually READ. + * + * A leaf reads its own kind of value and passes over the rest: a `keyword` + * takes literals and ignores an IRI stated beside them, which real data does – + * a role named both as a string and as a Wikidata entity on one node. + * Splitting on a value the leaf then reads nothing from mints an entry the leaf + * is absent from – the same endpoint, apparently in no role at all, shown to a + * reader and matched by a filter. So the split follows the reader. + * + * `keyword` is the only kind that needs narrowing. A `text` field has no filter + * operator (`filterOperatorFor`), so nothing welds it and it is never a tuple + * position. A `reference` is, and both of its shapes are meaningful: an IRI, + * and – for a {@link ReferenceStrategy.local local} lookup – a node the graph + * named inline, which has no IRI and is a referent all the same. + */ +function splittable( + node: FramedNode, + alias: string, + field: SearchField, +): readonly unknown[] { + const values = valuesOf(node, alias); + if (field.kind === 'keyword') { + return values.filter((value) => literalString(value) !== undefined); + } + return values; +} + // --- Framed-IR readers: read a field’s value off the framed node by its // {@link irAlias IR Alias} key. Internal to projection – a `derive` reads the // projected document, never the node, so `path` stays the whole statement of diff --git a/packages/search/test/qualified-relation.test.ts b/packages/search/test/qualified-relation.test.ts index e0516b30..efe12761 100644 --- a/packages/search/test/qualified-relation.test.ts +++ b/packages/search/test/qualified-relation.test.ts @@ -318,6 +318,34 @@ describe('fanning an edge out into one entry per tuple', () => { expect(entries).toEqual([{ role: 'etser' }]); }); + it('splits only on values the leaf can read', () => { + // Real data states a role both as a string and as a Wikidata entity on one + // node. A `keyword` leaf reads literals and passes over the IRI, so + // splitting on it would mint an entry the role is absent from – the same + // endpoint, apparently in no role at all, shown to a reader and matched by + // a filter. + const tagged = { + '@id': 'https://ex/work/16', + [workKey('creator')]: [ + { + [edgeKey('role')]: [ + { '@value': 'fotograaf' }, + { '@id': 'http://www.wikidata.org/entity/Q33231' }, + ], + [edgeKey('creator')]: [ + { '@id': 'https://a/1', [personKey('sameAs')]: [{ '@id': RKD }] }, + ], + }, + ], + }; + const entries = entriesOf( + projectDocument(tagged, work, searchSchema(work, person, weldableEdge)), + ); + + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('fotograaf'); + }); + it('treats the identity field as a tuple position', () => { // Nothing welds the identity field by name, but the flat companion // harvested from it is the leaf a weld uses to name the endpoint. An entry 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, }, },