@lde/search-typesense 0.27.4 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collection-definition.d.ts +17 -12
- package/dist/collection-definition.d.ts.map +1 -1
- package/dist/collection-definition.js +253 -54
- package/dist/lookup.d.ts +16 -7
- package/dist/lookup.d.ts.map +1 -1
- package/dist/lookup.js +49 -7
- package/dist/query-compiler.d.ts.map +1 -1
- package/dist/query-compiler.js +141 -23
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +62 -10
- package/package.json +4 -4
|
@@ -19,11 +19,11 @@ export interface CollectionDefinitionOptions {
|
|
|
19
19
|
*/
|
|
20
20
|
readonly collectionNameFor?: (searchType: SearchType) => string;
|
|
21
21
|
/**
|
|
22
|
-
* The Search Schema the type belongs to – required when the type
|
|
23
|
-
* inline reference (whose nested fields are declared from the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* The Search Schema the type belongs to – required when the type carries an
|
|
23
|
+
* inline reference (whose nested fields are declared from the reference type
|
|
24
|
+
* the schema resolves), or declares a joinable reference (whose target
|
|
25
|
+
* collection the schema’s {@link joinGraph} resolves).
|
|
26
|
+
* A type doing neither needs no schema, so a caller passes none;
|
|
27
27
|
* a type that does fails here rather than building a collection that
|
|
28
28
|
* silently omits the nesting or the reference.
|
|
29
29
|
*
|
|
@@ -82,13 +82,18 @@ export interface CollectionDefinitionOptions {
|
|
|
82
82
|
* entirely: they exist only as a projection-time reading device for the
|
|
83
83
|
* derives, so the collection stores nothing for them.
|
|
84
84
|
*
|
|
85
|
-
*
|
|
86
|
-
* `object
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
85
|
+
* An inline reference is stored as a **nested object** – `object` or
|
|
86
|
+
* `object[]` – with one nested Physical Field per field of the type it nests
|
|
87
|
+
* ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
|
|
88
|
+
* collection. A nested field is `index: false` unless it declares a query Role,
|
|
89
|
+
* so a display-only nesting stays disk weight and costs no RAM, exactly like
|
|
90
|
+
* the display labels; one that opts into `filterable` or `searchable` is
|
|
91
|
+
* indexed, and its parent object with it (see {@link nestedFields}).
|
|
92
|
+
*
|
|
93
|
+
* An inline reference declaring `filterable` or `facetable` also emits its
|
|
94
|
+
* {@link ReferenceStrategy.identity identity companion} – a flat id field the
|
|
95
|
+
* engine can filter and facet, which a nested object is not
|
|
96
|
+
* ({@link identityCompanionFields}).
|
|
92
97
|
*/
|
|
93
98
|
export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
|
|
94
99
|
//# sourceMappingURL=collection-definition.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,
|
|
1
|
+
{"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAcrB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
|
|
1
|
+
import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, localLookupTypeOf, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
|
|
2
2
|
import { deriveCollectionName } from './collection-name.js';
|
|
3
3
|
/**
|
|
4
4
|
* Build a Typesense collection definition from the unified {@link SearchType}, so
|
|
@@ -36,13 +36,18 @@ import { deriveCollectionName } from './collection-name.js';
|
|
|
36
36
|
* entirely: they exist only as a projection-time reading device for the
|
|
37
37
|
* derives, so the collection stores nothing for them.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
* `object
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
39
|
+
* An inline reference is stored as a **nested object** – `object` or
|
|
40
|
+
* `object[]` – with one nested Physical Field per field of the type it nests
|
|
41
|
+
* ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
|
|
42
|
+
* collection. A nested field is `index: false` unless it declares a query Role,
|
|
43
|
+
* so a display-only nesting stays disk weight and costs no RAM, exactly like
|
|
44
|
+
* the display labels; one that opts into `filterable` or `searchable` is
|
|
45
|
+
* indexed, and its parent object with it (see {@link nestedFields}).
|
|
46
|
+
*
|
|
47
|
+
* An inline reference declaring `filterable` or `facetable` also emits its
|
|
48
|
+
* {@link ReferenceStrategy.identity identity companion} – a flat id field the
|
|
49
|
+
* engine can filter and facet, which a nested object is not
|
|
50
|
+
* ({@link identityCompanionFields}).
|
|
46
51
|
*/
|
|
47
52
|
export function buildCollectionDefinition(searchType, options = {}) {
|
|
48
53
|
const { defaultLocale, collectionNameFor = deriveCollectionName } = options;
|
|
@@ -64,16 +69,19 @@ export function buildCollectionDefinition(searchType, options = {}) {
|
|
|
64
69
|
return collection;
|
|
65
70
|
}
|
|
66
71
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
72
|
+
* The type a field nests, or `undefined` when it nests nothing. Throws when the
|
|
73
|
+
* type carries an inline reference the caller gave no schema to resolve: the
|
|
74
|
+
* collection would silently store the reference as a string the projection
|
|
75
|
+
* never writes, so every document would fail to import.
|
|
71
76
|
*/
|
|
72
77
|
function nestedTypeOf(searchType, field, schema) {
|
|
73
|
-
const nested = schema === undefined
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
const nested = schema === undefined
|
|
79
|
+
? undefined
|
|
80
|
+
: (nestedReferenceType(schema, field) ??
|
|
81
|
+
localLookupTypeOf(field, schema));
|
|
82
|
+
// Reached only for a field carrying a Role, so an inline reference here
|
|
83
|
+
// stores entries: a Role-less one is an internal reading device, pruned
|
|
84
|
+
// before the writer.
|
|
77
85
|
if (nested === undefined && isInlineReference(field)) {
|
|
78
86
|
throw new Error(`Building the collection for “${searchType.name}” needs the search schema its surfaced inline reference “${field.name}” resolves against; pass it as the collection-definition option “schema”.`);
|
|
79
87
|
}
|
|
@@ -136,7 +144,10 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
|
|
|
136
144
|
}
|
|
137
145
|
const nested = nestedTypeOf(searchType, field, schema);
|
|
138
146
|
if (nested !== undefined) {
|
|
139
|
-
return
|
|
147
|
+
return [
|
|
148
|
+
...nestedFields(field.name, field, nested, schema, defaultLocale),
|
|
149
|
+
...identityCompanionFields(field, schema),
|
|
150
|
+
];
|
|
140
151
|
}
|
|
141
152
|
const names = physicalFields(field, schema);
|
|
142
153
|
if (field.kind === 'text') {
|
|
@@ -223,53 +234,217 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
|
|
|
223
234
|
return fields;
|
|
224
235
|
}
|
|
225
236
|
/**
|
|
226
|
-
* The nested object one
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
237
|
+
* The nested object one inline reference stores, plus one nested Physical Field
|
|
238
|
+
* per field of the type it nests – recursing for a nested field that itself
|
|
239
|
+
* nests (another inline reference, or a
|
|
240
|
+
* {@link ReferenceStrategy.local local} lookup, which stores its endpoint
|
|
241
|
+
* shaped by the target’s own declaration). `searchSchema` rejects inline
|
|
242
|
+
* cycles, so the recursion terminates.
|
|
231
243
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
244
|
+
* **A nested field is indexed exactly when it declares a query Role.** One that
|
|
245
|
+
* declares only `output` keeps `index: false`: stored with its referent, read
|
|
246
|
+
* back with it, costing disk rather than RAM – the same lever the per-language
|
|
247
|
+
* display labels use. `filterable` or `searchable` turns indexing on for that
|
|
248
|
+
* one field, and nothing else.
|
|
249
|
+
*
|
|
250
|
+
* **The parent object must be indexed whenever any descendant is.** Typesense
|
|
251
|
+
* silently ignores an indexed child under an `index: false` parent: no error,
|
|
252
|
+
* no match, every query answering empty. So the parent’s flag is computed from
|
|
253
|
+
* the children rather than declared, and this is the one place that invariant
|
|
254
|
+
* can be got wrong.
|
|
240
255
|
*/
|
|
241
|
-
function nestedFields(prefix, reference,
|
|
256
|
+
function nestedFields(prefix, reference, nestedType, schema, defaultLocale, withinArray = false, onPath = new Set()) {
|
|
242
257
|
const array = reference.array === true;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
258
|
+
// A type already on this path is not descended into again. `searchSchema`
|
|
259
|
+
// rejects INLINE cycles, but a {@link ReferenceStrategy.local local} lookup
|
|
260
|
+
// may reach a Root Type that reaches back – `Work → Person → Work` is a
|
|
261
|
+
// reasonable schema and nothing forbids it – and without this the builder
|
|
262
|
+
// recurses until the stack gives out. Stopping here matches where the FRAME
|
|
263
|
+
// stops ({@link inlineFramingDepth}), so the collection declares exactly the
|
|
264
|
+
// depth the projection can fill.
|
|
265
|
+
const walked = new Set(onPath).add(nestedType.name);
|
|
266
|
+
// Typesense flattens a nested object, so a value under a multi-valued
|
|
267
|
+
// ancestor arrives as an array however single-valued the declaration is.
|
|
268
|
+
// That only matters for an INDEXED field, whose declared type is checked
|
|
269
|
+
// against what is stored; an unindexed one is stored as-is.
|
|
270
|
+
const flattensToArray = withinArray || array;
|
|
271
|
+
const children = [];
|
|
272
|
+
// A `local` lookup nests its endpoint’s own document, which carries the `id`
|
|
273
|
+
// that identifies it. The target type does not declare `id` – no type
|
|
274
|
+
// declares its own key – so it is added here or it would be the one stored
|
|
275
|
+
// value the collection never mentions.
|
|
276
|
+
if (nestedType.class !== undefined) {
|
|
277
|
+
children.push({
|
|
278
|
+
name: nestedFieldName(prefix, 'id'),
|
|
279
|
+
type: flattensToArray ? 'string[]' : 'string',
|
|
247
280
|
index: false,
|
|
248
|
-
optional:
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
for (const field of
|
|
281
|
+
optional: true,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
for (const field of nestedType.fields) {
|
|
252
285
|
if (isInternalField(field)) {
|
|
253
286
|
continue;
|
|
254
287
|
}
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
288
|
+
const deeper = nestedTypeOfNestedField(field, schema);
|
|
289
|
+
if (deeper !== undefined && walked.has(deeper.name)) {
|
|
290
|
+
// The cycle stops here, and so does the frame – nothing will ever be
|
|
291
|
+
// stored under this field, so nothing is declared for it. Falling
|
|
292
|
+
// through would declare it as a LEAF, whose scalar type is not what a
|
|
293
|
+
// nested object would be stored as.
|
|
258
294
|
continue;
|
|
259
295
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
296
|
+
if (deeper !== undefined) {
|
|
297
|
+
children.push(...nestedFields(nestedFieldName(prefix, field.name), field, deeper, schema, defaultLocale, flattensToArray, walked),
|
|
298
|
+
// A nested field that stores an object cannot be filtered as itself,
|
|
299
|
+
// and its own id is a level deeper than a condition can be welded to –
|
|
300
|
+
// an engine welds conditions on an entry's LEAF fields only. Its
|
|
301
|
+
// identity companion is that leaf, so it sits beside the object rather
|
|
302
|
+
// than inside it.
|
|
303
|
+
...nestedIdentityFields(prefix, field, schema));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
children.push(...nestedLeafFields(prefix, field, defaultLocale, flattensToArray));
|
|
307
|
+
}
|
|
308
|
+
return [
|
|
309
|
+
{
|
|
310
|
+
name: prefix,
|
|
311
|
+
// Flattening applies to an object exactly as it does to a value: a
|
|
312
|
+
// single-valued nesting inside a multi-valued one arrives as a list of
|
|
313
|
+
// objects, so it is only declared `object` where nothing above it is an
|
|
314
|
+
// `object[]`.
|
|
315
|
+
type: flattensToArray ? 'object[]' : 'object',
|
|
316
|
+
// Never hard-coded: see the parent-object rule above.
|
|
317
|
+
index: children.some((child) => child.index !== false),
|
|
318
|
+
optional: reference.required !== true,
|
|
319
|
+
},
|
|
320
|
+
...children,
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The **identity companion** of an inline reference: the flat field holding the
|
|
325
|
+
* ids its entries reference, which the engine filters and facets in place of
|
|
326
|
+
* the nested object it cannot. Empty for an inline reference declaring no
|
|
327
|
+
* `identity` – a display-only nesting keeps costing no RAM.
|
|
328
|
+
*
|
|
329
|
+
* A second, narrowed field joins it where the target declares a facet policy,
|
|
330
|
+
* exactly as it does for a top-level facetable reference: the facet then reads
|
|
331
|
+
* the admitted subset, so an excluded id is never seen by the engine rather
|
|
332
|
+
* than merely unlabelled.
|
|
333
|
+
*/
|
|
334
|
+
function identityCompanionFields(field, schema) {
|
|
335
|
+
const names = physicalFields(field, schema);
|
|
336
|
+
if (names.identity === undefined) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const fields = [
|
|
340
|
+
{
|
|
341
|
+
name: names.identity,
|
|
342
|
+
type: 'string[]',
|
|
343
|
+
facet: names.facet === names.identity,
|
|
344
|
+
optional: true,
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
if (names.facet !== undefined && names.facet !== names.identity) {
|
|
348
|
+
fields.push({
|
|
349
|
+
name: names.facet,
|
|
350
|
+
type: 'string[]',
|
|
351
|
+
facet: true,
|
|
352
|
+
optional: true,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return fields;
|
|
356
|
+
}
|
|
357
|
+
/** The identity companion of a nested field that stores an object: a flat leaf
|
|
358
|
+
* beside it, holding the ids a filter welds on. Empty for a nested field that
|
|
359
|
+
* declares no query Role, which needs none. */
|
|
360
|
+
function nestedIdentityFields(prefix, field, schema) {
|
|
361
|
+
const names = physicalFields(field, schema);
|
|
362
|
+
if (names.identity === undefined) {
|
|
363
|
+
return [];
|
|
364
|
+
}
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
name: nestedFieldName(prefix, names.identity),
|
|
368
|
+
// Always a list, whatever the enclosing reference's arity: the projection
|
|
369
|
+
// writes it with `setArray`, and an indexed field's declared type is
|
|
370
|
+
// checked against what is stored – a `string` here rejects every
|
|
371
|
+
// document carrying such an edge, at import.
|
|
372
|
+
type: 'string[]',
|
|
373
|
+
index: true,
|
|
374
|
+
optional: true,
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
}
|
|
378
|
+
/** The type a *nested* field itself nests: another inline reference’s type, or
|
|
379
|
+
* the Root Type a {@link ReferenceStrategy.local local} lookup projects its
|
|
380
|
+
* endpoint through. */
|
|
381
|
+
function nestedTypeOfNestedField(field, schema) {
|
|
382
|
+
return nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The Physical Fields one non-nesting nested field contributes: exactly what
|
|
386
|
+
* the projection writes under the referent’s prefix, so the collection and the
|
|
387
|
+
* document cannot disagree.
|
|
388
|
+
*
|
|
389
|
+
* A nested `text` field stores its display values as a root one does – one
|
|
390
|
+
* `${name}_<lang>` per present language, matched by a single pattern field,
|
|
391
|
+
* never indexed – plus a folded search companion per locale when it is
|
|
392
|
+
* `searchable`. Anything undeclared here would still arrive in the document,
|
|
393
|
+
* and Typesense indexes what it is not told to leave alone, so the fanout is
|
|
394
|
+
* enumerated rather than left to inference.
|
|
395
|
+
*/
|
|
396
|
+
function nestedLeafFields(prefix, field, defaultLocale, flattensToArray) {
|
|
397
|
+
const names = physicalFields(field);
|
|
398
|
+
const searchString = flattensToArray ? 'string[]' : 'string';
|
|
399
|
+
const fields = [];
|
|
400
|
+
if (field.kind === 'text') {
|
|
401
|
+
const pattern = displayFieldPattern(field);
|
|
402
|
+
if (pattern !== undefined) {
|
|
403
|
+
// Display values are never indexed, whatever Roles the field declares:
|
|
404
|
+
// search hits the folded companions below, so the display copies stay on
|
|
405
|
+
// disk with every language they carry.
|
|
406
|
+
fields.push({
|
|
407
|
+
name: nestedFieldName(prefix, pattern),
|
|
408
|
+
type: 'string',
|
|
409
|
+
index: false,
|
|
410
|
+
optional: true,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
fields.push(...names.search.map((name, index) => {
|
|
414
|
+
const locale = field.locales[index] === 'und' ? defaultLocale : field.locales[index];
|
|
415
|
+
return {
|
|
416
|
+
name: nestedFieldName(prefix, name),
|
|
417
|
+
type: searchString,
|
|
418
|
+
optional: true,
|
|
419
|
+
...(locale !== undefined && { stem: true, locale }),
|
|
420
|
+
};
|
|
421
|
+
}));
|
|
422
|
+
return fields;
|
|
423
|
+
}
|
|
424
|
+
// A nested field can be `filterable` or `searchable`, never `facetable` or
|
|
425
|
+
// `sortable` (`searchSchema`), so `filterable` is what indexes the value
|
|
426
|
+
// itself – its search companion below indexes on its own account.
|
|
427
|
+
const indexed = field.filterable === true;
|
|
428
|
+
const valueType = typesenseValueType(field);
|
|
429
|
+
// Two ways a list arrives: the field declares one, or an ancestor's
|
|
430
|
+
// `object[]` flattens it into one. `typesenseValueType` honours `array` only
|
|
431
|
+
// for the string-shaped kinds, so a multi-valued nested `integer` would
|
|
432
|
+
// otherwise be declared scalar and rejected at import.
|
|
433
|
+
const storesAList = flattensToArray || field.array === true;
|
|
434
|
+
fields.push({
|
|
435
|
+
name: nestedFieldName(prefix, field.name),
|
|
436
|
+
type: indexed && storesAList ? arrayValueType(valueType) : valueType,
|
|
437
|
+
index: indexed,
|
|
438
|
+
// Always optional: `required` is a promise about the *referent* (this value
|
|
439
|
+
// is on every referent), while Typesense’s flag is about the document.
|
|
440
|
+
// Under a multi-valued reference those are different claims, and only the
|
|
441
|
+
// referent-level one is the declaration’s.
|
|
442
|
+
optional: true,
|
|
443
|
+
});
|
|
444
|
+
for (const name of names.search) {
|
|
265
445
|
fields.push({
|
|
266
|
-
name: nestedFieldName(prefix,
|
|
267
|
-
type:
|
|
268
|
-
index: false,
|
|
269
|
-
// Always optional: `required` is a promise about the *referent* (this
|
|
270
|
-
// value is on every referent), while Typesense’s flag is about the
|
|
271
|
-
// document. Under a multi-valued reference those are different claims,
|
|
272
|
-
// and only the referent-level one is the declaration’s.
|
|
446
|
+
name: nestedFieldName(prefix, name),
|
|
447
|
+
type: searchString,
|
|
273
448
|
optional: true,
|
|
274
449
|
});
|
|
275
450
|
}
|
|
@@ -277,6 +452,30 @@ function nestedFields(prefix, reference, referenceType, schema) {
|
|
|
277
452
|
}
|
|
278
453
|
/** The Typesense field type for a non-localized field, from its `kind`. 64-bit
|
|
279
454
|
* integers (and dates, stored as Unix seconds) so large counts never overflow. */
|
|
455
|
+
/**
|
|
456
|
+
* The array form of a value type, for a nested field that an ancestor’s
|
|
457
|
+
* `object[]` flattens into a list. Only an **indexed** field needs it: Typesense
|
|
458
|
+
* checks a declared type against what is stored, and a value under a
|
|
459
|
+
* multi-valued ancestor arrives as an array however single-valued its own
|
|
460
|
+
* declaration is. Enumerated rather than string-appended so an invalid
|
|
461
|
+
* combination cannot be built.
|
|
462
|
+
*/
|
|
463
|
+
function arrayValueType(type) {
|
|
464
|
+
switch (type) {
|
|
465
|
+
case 'string':
|
|
466
|
+
return 'string[]';
|
|
467
|
+
case 'int64':
|
|
468
|
+
return 'int64[]';
|
|
469
|
+
case 'float':
|
|
470
|
+
return 'float[]';
|
|
471
|
+
case 'bool':
|
|
472
|
+
return 'bool[]';
|
|
473
|
+
// Already a list: a multi-valued declaration under a multi-valued ancestor
|
|
474
|
+
// flattens no further.
|
|
475
|
+
case 'string[]':
|
|
476
|
+
return 'string[]';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
280
479
|
function typesenseValueType(field) {
|
|
281
480
|
switch (field.kind) {
|
|
282
481
|
case 'integer':
|
package/dist/lookup.d.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
import type { Client } from 'typesense';
|
|
2
2
|
import { type ReferenceProjection, type RootType, type SearchSchema, type SearchType } from '@lde/search';
|
|
3
3
|
/**
|
|
4
|
-
* One resolved level of a {@link ReferenceProjection}
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* One resolved level of a {@link ReferenceProjection}. A level is one of two
|
|
5
|
+
* things, discriminated by `via`, because the two cost different things:
|
|
6
|
+
*
|
|
7
|
+
* - **`lookup`** – referents fetched from the target’s own collection, keyed by
|
|
8
|
+
* id and still in the engine’s flat physical shape. One batched round trip.
|
|
9
|
+
* - **`nested`** – an inline reference descended into. Its entries are already
|
|
10
|
+
* in the hit, so nothing is fetched and there is no target; the level exists
|
|
11
|
+
* only to carry the levels resolved *below* it.
|
|
7
12
|
*
|
|
8
13
|
* Kept flat rather than reconstructed here so one reconstruction path serves
|
|
9
14
|
* hits and referents alike – the surface never learns that a nested document
|
|
10
15
|
* arrived by a second round-trip rather than from the hit itself.
|
|
11
16
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
export type ResolvedReferents = {
|
|
18
|
+
readonly via: 'lookup';
|
|
19
|
+
/** The Root Type these referents are declared by – reconstruction reads
|
|
20
|
+
* them through the target’s own declaration, never the referrer’s. */
|
|
15
21
|
readonly target: RootType;
|
|
16
22
|
readonly documents: ReadonlyMap<string, Record<string, unknown>>;
|
|
17
23
|
/** Keyed by the reference field’s name on the type this level carries. */
|
|
18
24
|
readonly children: ReadonlyMap<string, ResolvedReferents>;
|
|
19
|
-
}
|
|
25
|
+
} | {
|
|
26
|
+
readonly via: 'nested';
|
|
27
|
+
readonly children: ReadonlyMap<string, ResolvedReferents>;
|
|
28
|
+
};
|
|
20
29
|
/**
|
|
21
30
|
* Resolve a projection against the documents that name its referents: one
|
|
22
31
|
* batched `multi_search` **per level**, never per document, because the IRIs of
|
package/dist/lookup.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,iBAAiB,GACzB;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB;2EACuE;IACvE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjE,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,GACD;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,CAAC;AAKN;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,mBAAmB,GAAG,SAAS,EAC3C,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,EACpB,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC3C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACjC,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAwFjD"}
|
package/dist/lookup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { displayFieldName, fieldNamed, labelFieldOf, rootTypeNamed, } from '@lde/search/adapter';
|
|
1
|
+
import { displayFieldName, fieldNamed, labelFieldOf, nestedReferenceType, rootTypeNamed, } from '@lde/search/adapter';
|
|
2
2
|
import { escapeFilterValue } from './query-compiler.js';
|
|
3
3
|
/** Typesense caps a filter list; the same batch size the label lookup uses. */
|
|
4
4
|
const BATCH_SIZE = 200;
|
|
@@ -21,9 +21,24 @@ export async function resolveProjection(client, projection, searchType, schema,
|
|
|
21
21
|
// stated bound – one round-trip per level – one per field instead.
|
|
22
22
|
const levels = await Promise.all(Object.entries(projection).map(async ([name, level]) => {
|
|
23
23
|
const field = fieldNamed(searchType, name);
|
|
24
|
-
if (field === undefined
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
if (field === undefined) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
// An inline level costs no round trip: its entries are already in the
|
|
28
|
+
// parents. Flatten them out and hand them down, so the level BELOW reads
|
|
29
|
+
// its ids off the entries rather than off the document – which is the
|
|
30
|
+
// whole of what makes a lookup inside a qualified edge resolvable.
|
|
31
|
+
const nested = nestedReferenceType(schema, field);
|
|
32
|
+
if (nested !== undefined) {
|
|
33
|
+
return [
|
|
34
|
+
name,
|
|
35
|
+
{
|
|
36
|
+
via: 'nested',
|
|
37
|
+
children: await resolveProjection(client, level.resolve, nested, schema, collections, entriesOf(parents, name), onError),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
if (field.kind !== 'reference' || field.ref?.strategy !== 'lookup') {
|
|
27
42
|
// `assertValidQuery` rejects this for every caller; skipping keeps a
|
|
28
43
|
// hand-built query from throwing here rather than at the port’s guard.
|
|
29
44
|
return undefined;
|
|
@@ -44,6 +59,7 @@ export async function resolveProjection(client, projection, searchType, schema,
|
|
|
44
59
|
return [
|
|
45
60
|
name,
|
|
46
61
|
{
|
|
62
|
+
via: 'lookup',
|
|
47
63
|
target,
|
|
48
64
|
documents,
|
|
49
65
|
// The level below reads the IRIs off the documents this one just
|
|
@@ -59,14 +75,40 @@ export async function resolveProjection(client, projection, searchType, schema,
|
|
|
59
75
|
}
|
|
60
76
|
return resolved;
|
|
61
77
|
}
|
|
62
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* The nested entries a page of documents carries under one inline reference,
|
|
80
|
+
* flattened into one list – the “documents” the level below resolves against.
|
|
81
|
+
*
|
|
82
|
+
* Flattening rather than grouping is what keeps the round-trip count per
|
|
83
|
+
* *level* rather than per document: every entry on the page contributes its ids
|
|
84
|
+
* to one batch, and reconstruction re-associates them afterwards by id.
|
|
85
|
+
*/
|
|
86
|
+
function entriesOf(documents, field) {
|
|
87
|
+
return documents.flatMap((document) => {
|
|
88
|
+
const raw = document[field];
|
|
89
|
+
return (Array.isArray(raw) ? raw : [raw]).filter((entry) => typeof entry === 'object' && entry !== null);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every distinct id a page of documents carries under one reference field.
|
|
94
|
+
*
|
|
95
|
+
* A reference stores its ids one of two ways, and which one is about how much
|
|
96
|
+
* of the referent the deployment wants stored, not about identity: a plain
|
|
97
|
+
* reference stores the id itself, while a
|
|
98
|
+
* {@link ReferenceStrategy.local local} lookup stores the endpoint’s own
|
|
99
|
+
* document, whose `id` is the same value. Both are read, so a level resolves
|
|
100
|
+
* the same either way.
|
|
101
|
+
*/
|
|
63
102
|
function distinctIris(documents, field) {
|
|
64
103
|
const iris = new Set();
|
|
65
104
|
for (const document of documents) {
|
|
66
105
|
const raw = document[field];
|
|
67
106
|
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
const id = typeof value === 'object' && value !== null
|
|
108
|
+
? value.id
|
|
109
|
+
: value;
|
|
110
|
+
if (typeof id === 'string' && id !== '') {
|
|
111
|
+
iris.add(id);
|
|
70
112
|
}
|
|
71
113
|
}
|
|
72
114
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,QAAQ,EAEb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,UAAU,
|
|
1
|
+
{"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,QAAQ,EAEb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAC;AAmBrB;mDACmD;AACnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,KACpB,UAAU,GAAG,SAAS,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AAwfD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
|
package/dist/query-compiler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fold } from '@lde/text-normalization';
|
|
2
|
-
import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
|
|
2
|
+
import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isInternalField, isRangeFacet, isWelded, joinGraph, localLookupTypeOf, pageForOffset, physicalFields, nestedReferenceType, resolvePath, searchableFields, } from '@lde/search/adapter';
|
|
3
3
|
/**
|
|
4
4
|
* Compile the engine-neutral {@link SearchQuery} into Typesense search
|
|
5
5
|
* parameters – the query half of the engine adapter. Pure (no client, no env),
|
|
@@ -16,7 +16,7 @@ export function buildSearchParams(query, searchType, options = {}) {
|
|
|
16
16
|
const folded = query.text !== undefined && query.text.length > 0
|
|
17
17
|
? fold(query.text)
|
|
18
18
|
: undefined;
|
|
19
|
-
const { names, weights } = queryFields(searchType, query.locale);
|
|
19
|
+
const { names, weights } = queryFields(searchType, query.locale, options.schema);
|
|
20
20
|
const filterBy = compileFilterBy(query.where, searchType, options);
|
|
21
21
|
const sortBy = query.orderBy
|
|
22
22
|
.map((sort) => compileSort(sort, searchType, query.locale))
|
|
@@ -78,16 +78,41 @@ function compileRangeFacet(name, ranges) {
|
|
|
78
78
|
* 1), so a match in the user’s language ranks higher while cross-language matches
|
|
79
79
|
* still surface.
|
|
80
80
|
*/
|
|
81
|
-
function queryFields(searchType, locale) {
|
|
81
|
+
function queryFields(searchType, locale, schema) {
|
|
82
82
|
const names = [];
|
|
83
83
|
const weights = [];
|
|
84
|
+
collectSearchable(searchType, locale, schema, '', new Set(), names, weights);
|
|
85
|
+
return { names, weights };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Collect the searchable physical fields of a type and of everything it nests,
|
|
89
|
+
* each under the path an engine addresses it by.
|
|
90
|
+
*
|
|
91
|
+
* Nested fields are walked because a nested field may declare `searchable`
|
|
92
|
+
* (ADR 24) – and a companion that is indexed but absent from `query_by` is the
|
|
93
|
+
* worst of both: it costs the RAM of an indexed field and matches nothing, in
|
|
94
|
+
* silence. So the walk here is what makes that Role mean anything.
|
|
95
|
+
*
|
|
96
|
+
* `onPath` guards the walk, and is scoped to the PATH rather than to the walk
|
|
97
|
+
* as a whole: two fields may nest one type – `creator` and `contributor` over
|
|
98
|
+
* the same edge – and each reaches it under its own prefix, so each must
|
|
99
|
+
* contribute its own companions. A set shared across the walk would let the
|
|
100
|
+
* first field claim the type and leave the second's companions indexed but
|
|
101
|
+
* unqueried. What it does guard is a type reached from ITSELF, which a
|
|
102
|
+
* {@link ReferenceStrategy.local local} lookup can do.
|
|
103
|
+
*/
|
|
104
|
+
function collectSearchable(searchType, locale, schema, prefix, onPath, names, weights) {
|
|
105
|
+
if (onPath.has(searchType.name)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const walked = new Set(onPath).add(searchType.name);
|
|
84
109
|
for (const field of searchableFields(searchType)) {
|
|
85
110
|
const search = physicalFields(field).search;
|
|
86
111
|
const baseWeight = field.searchable.weight;
|
|
87
112
|
if (field.kind === 'text') {
|
|
88
113
|
const locales = field.locales;
|
|
89
114
|
search.forEach((name, index) => {
|
|
90
|
-
names.push(name);
|
|
115
|
+
names.push(qualify(prefix, name));
|
|
91
116
|
// The active locale keeps full weight; `und` is language-neutral, so
|
|
92
117
|
// it is never demoted (an untagged-only field would otherwise always
|
|
93
118
|
// rank below its declared weight).
|
|
@@ -98,12 +123,26 @@ function queryFields(searchType, locale) {
|
|
|
98
123
|
}
|
|
99
124
|
else {
|
|
100
125
|
for (const name of search) {
|
|
101
|
-
names.push(name);
|
|
126
|
+
names.push(qualify(prefix, name));
|
|
102
127
|
weights.push(baseWeight);
|
|
103
128
|
}
|
|
104
129
|
}
|
|
105
130
|
}
|
|
106
|
-
|
|
131
|
+
if (schema === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const field of searchType.fields) {
|
|
135
|
+
// An internal field is pruned before the writer and declared in no
|
|
136
|
+
// collection, so its target's companions exist nowhere – asking `query_by`
|
|
137
|
+
// for one makes the ENGINE reject every search on the collection.
|
|
138
|
+
if (isInternalField(field)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const nested = nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
|
|
142
|
+
if (nested !== undefined) {
|
|
143
|
+
collectSearchable(nested, locale, schema, qualify(prefix, field.name), walked, names, weights);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
107
146
|
}
|
|
108
147
|
/** AND-join the compiled `where` clauses. A clause that states no constraint is
|
|
109
148
|
* skipped; one that can match nothing compiles to {@link MATCHES_NOTHING}
|
|
@@ -201,16 +240,31 @@ const UNUSABLE = Symbol('unusable');
|
|
|
201
240
|
function compileCriterion(criterion, searchType, options) {
|
|
202
241
|
const on = criterion.on ?? [];
|
|
203
242
|
if (on.length === 0) {
|
|
204
|
-
return
|
|
243
|
+
return isWelded(criterion)
|
|
244
|
+
? compileWelded(criterion, searchType, options.schema, '')
|
|
245
|
+
: compileLeaf(criterion, searchType, options.schema);
|
|
205
246
|
}
|
|
247
|
+
// The path carries two kinds of hop, and they compile differently: a JOIN
|
|
248
|
+
// crosses into another collection and wraps the leaf in `$collection(…)`; a
|
|
249
|
+
// NESTING stays in this document and qualifies the leaf’s field name. The
|
|
250
|
+
// schema is what tells them apart, so without one every hop is read as a
|
|
251
|
+
// join – exactly the behaviour before nesting existed.
|
|
252
|
+
const resolvedPath = options.schema === undefined
|
|
253
|
+
? undefined
|
|
254
|
+
: resolvePath(searchType, on, options.schema, joinGraph(options.schema));
|
|
255
|
+
if (typeof resolvedPath === 'string') {
|
|
256
|
+
return UNUSABLE;
|
|
257
|
+
}
|
|
258
|
+
const joinHops = resolvedPath?.joinPath ?? on;
|
|
259
|
+
const nestedHops = resolvedPath?.nestedPath ?? [];
|
|
206
260
|
// A joined criterion constrains a document in ANOTHER collection, so the leaf
|
|
207
261
|
// is compiled against the type that path reaches and then wrapped, one
|
|
208
262
|
// `$collection(…)` per hop, outermost hop first:
|
|
209
263
|
// `['dataset', 'publisher']` → `$datasets($publishers(id:=…))`.
|
|
210
264
|
const collections = [];
|
|
211
265
|
let target = searchType;
|
|
212
|
-
for (let hop = 1; hop <=
|
|
213
|
-
const resolved = options.joinTargetFor?.(searchType,
|
|
266
|
+
for (let hop = 1; hop <= joinHops.length; hop++) {
|
|
267
|
+
const resolved = options.joinTargetFor?.(searchType, joinHops.slice(0, hop));
|
|
214
268
|
if (resolved === undefined) {
|
|
215
269
|
// An unresolvable path is a criterion that cannot be compiled at all –
|
|
216
270
|
// false, so it drops out and its siblings still stand. Through the
|
|
@@ -220,7 +274,11 @@ function compileCriterion(criterion, searchType, options) {
|
|
|
220
274
|
collections.push(resolved.collection);
|
|
221
275
|
target = resolved.searchType;
|
|
222
276
|
}
|
|
223
|
-
const
|
|
277
|
+
const leafType = resolvedPath?.leafType ?? target;
|
|
278
|
+
const prefix = nestedHops.join('.');
|
|
279
|
+
const leaf = isWelded(criterion)
|
|
280
|
+
? compileWelded(criterion, leafType, options.schema, prefix)
|
|
281
|
+
: compileLeaf(criterion, leafType, options.schema, prefix);
|
|
224
282
|
// A vacuous leaf states no constraint on the referent, so the join as a whole
|
|
225
283
|
// states none either – the reading, and so the outcome, passes straight
|
|
226
284
|
// through the hops.
|
|
@@ -229,9 +287,16 @@ function compileCriterion(criterion, searchType, options) {
|
|
|
229
287
|
}
|
|
230
288
|
return collections.reduceRight((inner, collection) => `$${collection}(${inner})`, leaf);
|
|
231
289
|
}
|
|
232
|
-
/**
|
|
233
|
-
*
|
|
234
|
-
|
|
290
|
+
/**
|
|
291
|
+
* The criterion’s own term, against the type it actually constrains – the
|
|
292
|
+
* searched type, the one its join path reaches, or the reference type its
|
|
293
|
+
* nesting path walks into.
|
|
294
|
+
*
|
|
295
|
+
* `prefix` is that nesting path, dotted: an engine addresses a stored nested
|
|
296
|
+
* document by qualifying the field name, so `role` inside `creator` becomes
|
|
297
|
+
* `creator.role`. Empty for every unnested criterion.
|
|
298
|
+
*/
|
|
299
|
+
function compileLeaf(criterion, searchType, schema, prefix = '') {
|
|
235
300
|
// `id` is the Typesense document key, not a declared field. Exact `:=`
|
|
236
301
|
// membership, like a non-facet field ({@link compileMembership}), so an IRI
|
|
237
302
|
// cannot partial-match on a shared path segment. (`fetchLabels` resolves
|
|
@@ -239,7 +304,12 @@ function compileLeaf(criterion, searchType, schema) {
|
|
|
239
304
|
// clause.) An empty identity membership enumerates NO document, so it is
|
|
240
305
|
// unusable rather than vacuous – the one place the two readings diverge.
|
|
241
306
|
if (criterion.field === ID_FIELD) {
|
|
242
|
-
|
|
307
|
+
// `id` names the DOCUMENT key, which exists only at the top level: inside a
|
|
308
|
+
// nested reference there is no document to key. Compiling it under a prefix
|
|
309
|
+
// would filter the root collection's `id` instead – a different question,
|
|
310
|
+
// silently answered. `assertValidQuery` rejects it first; this is the guard
|
|
311
|
+
// for a hand-built query.
|
|
312
|
+
return prefix === '' && 'in' in criterion && criterion.in.length > 0
|
|
243
313
|
? `${ID_FIELD}:=[${criterion.in.map(escapeFilterValue).join(',')}]`
|
|
244
314
|
: UNUSABLE;
|
|
245
315
|
}
|
|
@@ -254,13 +324,57 @@ function compileLeaf(criterion, searchType, schema) {
|
|
|
254
324
|
}
|
|
255
325
|
if ('in' in criterion) {
|
|
256
326
|
return criterion.in.length > 0
|
|
257
|
-
? compileMembership(field, criterion.in, schema)
|
|
327
|
+
? compileMembership(field, criterion.in, schema, prefix)
|
|
258
328
|
: VACUOUS;
|
|
259
329
|
}
|
|
260
330
|
if ('range' in criterion) {
|
|
261
|
-
return compileRange(field, criterion.range) ?? VACUOUS;
|
|
331
|
+
return compileRange(field, criterion.range, prefix) ?? VACUOUS;
|
|
332
|
+
}
|
|
333
|
+
// Every other shape returned above; a welded criterion never reaches here
|
|
334
|
+
// (compileWelded handles it before the leaf compiler is called).
|
|
335
|
+
return `${qualify(prefix, field.name)}:=${criterion.is}`;
|
|
336
|
+
}
|
|
337
|
+
/** A field name under its nesting path, if any – the one place the dotted
|
|
338
|
+
* addressing an engine uses for a stored nested document is spelled. */
|
|
339
|
+
function qualify(prefix, name) {
|
|
340
|
+
return prefix === '' ? name : `${prefix}.${name}`;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A **welded** criterion: `path.{a && b}`, matching a document with ONE entry
|
|
344
|
+
* that satisfies every condition – as against `path.a && path.b`, which two
|
|
345
|
+
* different entries can satisfy between them.
|
|
346
|
+
*
|
|
347
|
+
* Each condition is compiled against the reference type with an EMPTY prefix,
|
|
348
|
+
* so what goes inside the braces is always a leaf name. That is a correctness
|
|
349
|
+
* requirement, not tidiness: Typesense 30.2 **hangs** on a dotted path inside a
|
|
350
|
+
* group (`creator.{creator.name:=…}` never returns – no error, no result), so
|
|
351
|
+
* the compiler must be structurally incapable of emitting one. A field whose
|
|
352
|
+
* value is itself nested is reached through its identity companion, which is a
|
|
353
|
+
* leaf ({@link physicalFields}).
|
|
354
|
+
*
|
|
355
|
+
* A condition that states no constraint drops out, and the weld is whatever
|
|
356
|
+
* remains; a weld left with nothing is vacuous like any other empty clause.
|
|
357
|
+
*/
|
|
358
|
+
function compileWelded(criterion, searchType, schema, prefix) {
|
|
359
|
+
const entryType = schema &&
|
|
360
|
+
nestedReferenceType(schema, fieldNamed(searchType, criterion.field) ?? {});
|
|
361
|
+
if (entryType === undefined) {
|
|
362
|
+
return UNUSABLE;
|
|
363
|
+
}
|
|
364
|
+
const conditions = [];
|
|
365
|
+
for (const condition of criterion.entry) {
|
|
366
|
+
const compiled = compileLeaf(condition, entryType, schema);
|
|
367
|
+
if (compiled === UNUSABLE) {
|
|
368
|
+
return UNUSABLE;
|
|
369
|
+
}
|
|
370
|
+
if (compiled !== VACUOUS) {
|
|
371
|
+
conditions.push(compiled);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (conditions.length === 0) {
|
|
375
|
+
return VACUOUS;
|
|
262
376
|
}
|
|
263
|
-
return `${field.
|
|
377
|
+
return `${qualify(prefix, criterion.field)}.{${conditions.join(' && ')}}`;
|
|
264
378
|
}
|
|
265
379
|
/**
|
|
266
380
|
* A membership clause. A non-facet (tokenized) field uses the exact `:=`
|
|
@@ -272,11 +386,15 @@ function compileLeaf(criterion, searchType, schema) {
|
|
|
272
386
|
* filter on such a field is exactly what the policy promises stays whole, so
|
|
273
387
|
* it must compile exact, not tokenized.
|
|
274
388
|
*/
|
|
275
|
-
function compileMembership(field, values, schema) {
|
|
389
|
+
function compileMembership(field, values, schema, prefix = '') {
|
|
276
390
|
const list = `[${values.map(escapeFilterValue).join(',')}]`;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
391
|
+
const names = physicalFields(field, schema);
|
|
392
|
+
// An inline reference stores a nested object, which an engine cannot filter.
|
|
393
|
+
// Its identity companion is the flat id field standing in for it – so the
|
|
394
|
+
// logical name a consumer writes and the physical name the engine reads
|
|
395
|
+
// differ here, and only here.
|
|
396
|
+
const name = qualify(prefix, names.identity ?? field.name);
|
|
397
|
+
return names.facet === name ? `${name}:${list}` : `${name}:=${list}`;
|
|
280
398
|
}
|
|
281
399
|
/** An inclusive Typesense range clause, or `undefined` when neither bound is
|
|
282
400
|
* usable. Which of the two readings that is – the caller set no bounds, or the
|
|
@@ -285,8 +403,8 @@ function compileMembership(field, values, schema) {
|
|
|
285
403
|
* from the conjunction widens it, so the same reading cannot be right in both
|
|
286
404
|
* positions. `validateQuery` rejects an unreadable bound outright instead,
|
|
287
405
|
* which is the only answer that neither widens nor narrows. */
|
|
288
|
-
function compileRange(field, range) {
|
|
289
|
-
const name = field.name;
|
|
406
|
+
function compileRange(field, range, prefix = '') {
|
|
407
|
+
const name = qualify(prefix, field.name);
|
|
290
408
|
// A bound a caller sent as `null` – a GraphQL variable left unfilled, which
|
|
291
409
|
// the surface passes through – is a bound NOT SET, not a bound of `null`.
|
|
292
410
|
// Read literally it reaches the engine as `datePosted:[null..…]`, which
|
package/dist/search.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,EAGL,KAAK,cAAc,
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,EAGL,KAAK,cAAc,EAMnB,KAAK,UAAU,EAGf,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAoBrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAqB,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAExE;;;;sDAIsD;AACtD,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAIhC,SAAQ,IAAI,CAAC,wBAAwB,EAAE,eAAe,CAAC;IACvD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACnE;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CACpC,KAAK,SAAS,SAAS,UAAU,EAAE,GAAG,SAAS,UAAU,EAAE,CAC3D,SAAQ,YAAY,CAAC,KAAK,CAAC;IAC3B;;;;;;;OAOG;IACH,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC;CAC1D;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,GAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAM,GAChE,qBAAqB,CAAC,KAAK,CAAC,CA0Z9B;AAyCD,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAiJD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAgDtC;AA6BD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B;;2EAEmE;QACnE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;KACL,EAAE,CAAC;CACL;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,EAC3C,MAAM,EAAE,YAAY,EACpB,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,GACjD,YAAY,CAgDd"}
|
package/dist/search.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
|
|
1
|
+
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, labelTargetNameOf, localLookupTypeOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
|
|
2
2
|
import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
|
|
3
3
|
import { deriveCollectionName } from './collection-name.js';
|
|
4
4
|
import { resolveProjection } from './lookup.js';
|
|
@@ -68,9 +68,12 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
68
68
|
const labelSources = new Map([...schema.values()].map((searchType) => [
|
|
69
69
|
searchType.class,
|
|
70
70
|
new Map(referenceFields(searchType)
|
|
71
|
-
|
|
71
|
+
// An INLINE reference names its label target one level in, through
|
|
72
|
+
// its identity companion: the companion holds that field's ids, so
|
|
73
|
+
// that field's target is what can label them.
|
|
74
|
+
.filter((field) => labelTargetNameOf(field, schema) !== undefined)
|
|
72
75
|
.map((field) => {
|
|
73
|
-
const source = typesByName.get(
|
|
76
|
+
const source = typesByName.get(labelTargetNameOf(field, schema));
|
|
74
77
|
const labelField = labelFieldOf(source);
|
|
75
78
|
return [
|
|
76
79
|
field.name,
|
|
@@ -114,7 +117,18 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
114
117
|
return [
|
|
115
118
|
searchType.class,
|
|
116
119
|
referenceFields(searchType)
|
|
117
|
-
.filter((field) => field.output === true &&
|
|
120
|
+
.filter((field) => field.output === true &&
|
|
121
|
+
sources?.has(field.name) === true &&
|
|
122
|
+
// A field storing NESTED DOCUMENTS is excluded, and must be: this
|
|
123
|
+
// list drives the per-hit label lookup, which reads each stored
|
|
124
|
+
// value as an id. An inline reference and a `local` lookup store
|
|
125
|
+
// objects, so every entry would contribute the string
|
|
126
|
+
// `"[object Object]"` to the batched `id:[…]` filter. Neither
|
|
127
|
+
// needs entry labels anyway – the referent's own fields are in
|
|
128
|
+
// the entry – and their FACET buckets are labelled by a separate
|
|
129
|
+
// path that reads the companion.
|
|
130
|
+
nestedReferenceType(schema, field) === undefined &&
|
|
131
|
+
localLookupTypeOf(field, schema) === undefined)
|
|
118
132
|
.map((field) => ({
|
|
119
133
|
name: field.name,
|
|
120
134
|
source: sources.get(field.name),
|
|
@@ -538,7 +552,7 @@ export function parseSearchResponse(response, searchType, labels, schema, refere
|
|
|
538
552
|
// reference facet stays id-only even when a cached full-collection map holds
|
|
539
553
|
// its IRIs.
|
|
540
554
|
const referenceFacets = new Set(referenceFields(searchType)
|
|
541
|
-
.filter((field) =>
|
|
555
|
+
.filter((field) => labelTargetNameOf(field, schema) !== undefined)
|
|
542
556
|
.map((field) => field.name));
|
|
543
557
|
const facets = {};
|
|
544
558
|
for (const facet of response.facet_counts ?? []) {
|
|
@@ -587,15 +601,25 @@ function logicalValue(flat, field, labels, schema, referents) {
|
|
|
587
601
|
// reconstructs as a nested Search Document rather than as an IRI plus a
|
|
588
602
|
// label. Nesting is rebuilt HERE, below every surface, so a second
|
|
589
603
|
// surface inherits it instead of reimplementing it (ADR 11).
|
|
604
|
+
const resolved = referents?.get(field.name);
|
|
590
605
|
const nested = nestedReferenceType(schema, field);
|
|
591
606
|
if (nested !== undefined) {
|
|
592
|
-
|
|
607
|
+
// A nested level resolves nothing itself; what it carries are the
|
|
608
|
+
// levels below it, which the entries are reconstructed against.
|
|
609
|
+
return nestedValue(flat[field.name], field, nested, labels, schema, resolved?.children);
|
|
610
|
+
}
|
|
611
|
+
// Checked BEFORE the plain lookup below, because a `local` lookup
|
|
612
|
+
// resolves through the same kind of level while storing a different
|
|
613
|
+
// shape: its stored value is the endpoint's own document, not an id, so
|
|
614
|
+
// reading it as one would stringify the object into a bogus `id`.
|
|
615
|
+
const localType = localLookupTypeOf(field, schema);
|
|
616
|
+
if (localType !== undefined) {
|
|
617
|
+
return localLookupValue(flat[field.name], field, localType, resolved, labels, schema);
|
|
593
618
|
}
|
|
594
619
|
// A resolved lookup carries the referent's own fields, fetched from the
|
|
595
620
|
// target's collection. It reconstructs through the SAME path a hit does,
|
|
596
621
|
// so a consumer cannot tell a projected referent from an inline one.
|
|
597
|
-
|
|
598
|
-
if (resolved !== undefined) {
|
|
622
|
+
if (resolved !== undefined && resolved.via === 'lookup') {
|
|
599
623
|
return lookupValue(flat[field.name], field, resolved, labels, schema);
|
|
600
624
|
}
|
|
601
625
|
// A surfaced inline reference stores nested documents, not IRIs, so a
|
|
@@ -655,10 +679,10 @@ function localizedValue(flat, field) {
|
|
|
655
679
|
* a nested document is read, not addressed. A nested reference type may itself
|
|
656
680
|
* surface an inline reference, which recurses through {@link reconstructDocument}.
|
|
657
681
|
*/
|
|
658
|
-
function nestedValue(raw, field, referenceType, labels, schema) {
|
|
682
|
+
function nestedValue(raw, field, referenceType, labels, schema, children) {
|
|
659
683
|
const referents = (Array.isArray(raw) ? raw : [raw]).filter((referent) => typeof referent === 'object' && referent !== null);
|
|
660
684
|
const documents = referents.map((referent) => {
|
|
661
|
-
const document = reconstructDocument(referent, referenceType, labels, schema);
|
|
685
|
+
const document = reconstructDocument(referent, referenceType, labels, schema, children);
|
|
662
686
|
return typeof referent.id === 'string'
|
|
663
687
|
? { id: referent.id, ...document }
|
|
664
688
|
: document;
|
|
@@ -668,6 +692,34 @@ function nestedValue(raw, field, referenceType, labels, schema) {
|
|
|
668
692
|
}
|
|
669
693
|
return field.array === true ? documents : documents[0];
|
|
670
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* Rebuild a {@link ReferenceStrategy.local local} lookup: the endpoint’s own
|
|
697
|
+
* document as this document stated it, with the **resolved** document overlaid
|
|
698
|
+
* where the lookup found one.
|
|
699
|
+
*
|
|
700
|
+
* Both sides are the target type’s flat physical shape, so the merge is a plain
|
|
701
|
+
* override and the authoritative value wins field by field. Three cases fall
|
|
702
|
+
* out of one expression, which is the point of storing local fields
|
|
703
|
+
* unconditionally:
|
|
704
|
+
*
|
|
705
|
+
* - identified and indexed → the target’s own record, over what was stated here;
|
|
706
|
+
* - identified but not indexed → what was stated here, plus the `id`, rather
|
|
707
|
+
* than a bare IRI;
|
|
708
|
+
* - not identified → what was stated here, with no `id` at all.
|
|
709
|
+
*/
|
|
710
|
+
function localLookupValue(raw, field, target, resolved, labels, schema) {
|
|
711
|
+
const fetched = resolved?.via === 'lookup' ? resolved.documents : new Map();
|
|
712
|
+
const merged = (Array.isArray(raw) ? raw : [raw])
|
|
713
|
+
.filter((entry) => typeof entry === 'object' && entry !== null)
|
|
714
|
+
.map((entry) => {
|
|
715
|
+
const id = typeof entry.id === 'string' ? entry.id : undefined;
|
|
716
|
+
const authoritative = id === undefined ? undefined : fetched.get(id);
|
|
717
|
+
return authoritative === undefined
|
|
718
|
+
? entry
|
|
719
|
+
: { ...entry, ...authoritative };
|
|
720
|
+
});
|
|
721
|
+
return nestedValue(merged, field, target, labels, schema, resolved?.children);
|
|
722
|
+
}
|
|
671
723
|
/**
|
|
672
724
|
* Rebuild a resolved `lookup` reference: each stored IRI becomes the referent’s
|
|
673
725
|
* own document, read through the **target’s** declaration and carrying whatever
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-typesense",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
28
|
+
"@lde/search": "^0.24.0",
|
|
29
29
|
"@lde/text-normalization": "^0.1.2",
|
|
30
30
|
"tslib": "^2.3.0",
|
|
31
31
|
"typesense": "^3.0.6"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"n3": "^2.
|
|
34
|
+
"n3": "^2.4.0",
|
|
35
35
|
"testcontainers": "^12.1.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@lde/dataset": "^0.7.9",
|
|
39
|
-
"@lde/pipeline": "^0.36.
|
|
39
|
+
"@lde/pipeline": "^0.36.4"
|
|
40
40
|
}
|
|
41
41
|
}
|