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
2 changes: 1 addition & 1 deletion docs/decisions/0024-carry-data-on-a-reference-edge.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ top-level one does (ADR 22).
### 3. A reference resolves where it can, and nests where it cannot

Such a reference stores the endpoint’s locally-projected fields **always**, and
overlays the resolved document when the lookup succeeds. One field then serves
replaces it with the resolved document when the lookup succeeds. One field then serves
both populations: an identified endpoint gets its id and the target’s own
fields; an unidentified one gets the fields the referring document states, and
no id.
Expand Down
5 changes: 3 additions & 2 deletions docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,9 @@ field's [`description`](#describing-a-field).

**`local: true`** on a lookup additionally stores the endpoint's own fields, as
this document states them, projected through the target's own declaration. The
resolved document is overlaid on them at query time. That is what lets one field
serve both populations:
resolved document replaces them at query time – replaces rather than merges, so
a name this document stated in one language cannot survive beside the target’s
name in another. That is what lets one field serve both populations:

| the endpoint | what the entry carries |
| ----------------------- | ---------------------------------------------------- |
Expand Down
31 changes: 20 additions & 11 deletions packages/search-typesense/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,15 +1083,19 @@ function nestedValue(

/**
* Rebuild a {@link ReferenceStrategy.local local} lookup: the endpoint’s own
* document as this document stated it, with the **resolved** document overlaid
* document as this document stated it, **replaced** by the resolved document
* where the lookup found one.
*
* Both sides are the target type’s flat physical shape, so the merge is a plain
* override and the authoritative value wins field by field. Three cases fall
* out of one expression, which is the point of storing local fields
* unconditionally:
* Replaced rather than merged, because both sides are the target type’s flat
* physical shape and a logical field fans out into several physical ones: a
* name this document stated in one language would survive beside the target’s
* name in another, and a field the target’s record does not carry would
* survive after the target dropped it. The resolved document carries what the
* projection asked for, and a resolved entry says exactly that – what a plain
* lookup says too. Three cases fall out of one expression, which is the point
* of storing local fields unconditionally:
*
* - identified and indexed → the target’s own record, over what was stated here;
* - identified and indexed → the target’s own record;
* - identified but not indexed → what was stated here, plus the `id`, rather
* than a bare IRI;
* - not identified → what was stated here, with no `id` at all.
Expand All @@ -1106,19 +1110,24 @@ function localLookupValue(
): SearchValue | undefined {
const fetched =
resolved?.via === 'lookup' ? resolved.documents : new Map<string, never>();
const merged = (Array.isArray(raw) ? raw : [raw])
const entries = (Array.isArray(raw) ? raw : [raw])
.filter(
(entry): entry is Record<string, unknown> =>
typeof entry === 'object' && entry !== null,
)
.map((entry) => {
const id = typeof entry.id === 'string' ? entry.id : undefined;
const authoritative = id === undefined ? undefined : fetched.get(id);
return authoritative === undefined
? entry
: { ...entry, ...authoritative };
return authoritative ?? entry;
});
return nestedValue(merged, field, target, labels, schema, resolved?.children);
return nestedValue(
entries,
field,
target,
labels,
schema,
resolved?.children,
);
}

/**
Expand Down
24 changes: 17 additions & 7 deletions packages/search-typesense/test/qualified-lookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ const person = defineSearchType({
{
name: 'label',
kind: 'text',
locales: ['nl'],
locales: ['nl', 'und'],
output: true,
searchable: { weight: 1 },
},
{ name: 'birthDate', kind: 'keyword', output: true },
{ name: 'deathDate', kind: 'keyword', output: true },
],
});

Expand Down Expand Up @@ -77,8 +78,14 @@ const hits = {
title_nl: 'Eerste',
creator: [
{
// Stated in another language than the target’s record, plus a
// field that record does not carry: what a merge would leak.
role: 'etser',
creator: { id: 'https://p/1', label_nl: 'Rembrandt' },
creator: {
id: 'https://p/1',
label_und: 'Rembrandt',
deathDate: '1669-10-04',
},
},
{ role: 'auteur', creator: { label_nl: 'Jan Jansen' } },
],
Expand Down Expand Up @@ -228,7 +235,7 @@ describe('an edge that is single-valued and stores a bare id', () => {
describe('an edge whose endpoint is itself multi-valued', () => {
// A relation qualified once but reaching several endpoints – a joint
// attribution, say. The entry then holds a LIST of endpoint documents, which
// both the id collection and the overlay have to read as one.
// both the id collection and the resolution have to read as one.
const jointEdge = defineSearchType({
name: 'CreatorEdge',
fields: [
Expand Down Expand Up @@ -304,7 +311,7 @@ describe('an edge whose endpoint is itself multi-valued', () => {
});
const [entry] = entriesOf(result.hits[0]);

// The one that resolved is overlaid; the one that did not keeps what the
// The one that resolved is replaced by its document; the other keeps what the
// work stated. Both stay in the entry, in order.
expect(entry.creator).toEqual([
{ id: 'https://p/1', label: { nl: ['Eerste'] } },
Expand Down Expand Up @@ -351,7 +358,7 @@ describe('resolving a lookup inside an edge', () => {
}
});

it('overlays the resolved document on what the work stated', async () => {
it('replaces what the work stated with the resolved document', async () => {
const { fake } = client();
const engine = createTypesenseSearchEngine(fake.client, schema, {
collections,
Expand All @@ -360,7 +367,9 @@ describe('resolving a lookup inside an edge', () => {
const result = await engine.search(work as never, { ...base, resolve });
const [identified] = entriesOf(result.hits[1]);

// The authoritative record wins over the name this work published.
// The authoritative record is all there is: neither the `und` name this
// work published nor the `deathDate` the target’s record lacks survives,
// so the reference cannot disagree with the document it resolves to.
expect(identified.creator).toEqual({
id: 'https://p/1',
label: { nl: ['Rembrandt Harmenszoon van Rijn'] },
Expand Down Expand Up @@ -410,7 +419,8 @@ describe('resolving a lookup inside an edge', () => {
expect(lookups).toHaveLength(0);
expect(identified.creator).toEqual({
id: 'https://p/1',
label: { nl: ['Rembrandt'] },
label: { und: ['Rembrandt'] },
deathDate: '1669-10-04',
});
});
});
2 changes: 1 addition & 1 deletion packages/search/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export type ReferenceStrategy =
/**
* Also project the target’s **own fields from this document’s frame**, so
* the reference stores what the referring document states about the
* referent alongside the id – and the resolved document overlays it at
* referent alongside the id – and the resolved document replaces it at
* query time rather than being the only source of it.
*
* What it buys is one field where there were two. A referent the graph
Expand Down