@lde/search 0.23.2 → 0.24.1
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/adapter.d.ts +3 -3
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +130 -17
- package/dist/query.d.ts +84 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +145 -44
- package/dist/schema.d.ts +150 -15
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +431 -66
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -139,20 +139,27 @@ export function isInlineReference(field) {
|
|
|
139
139
|
return field.kind === 'reference' && field.ref?.strategy === 'inline';
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* The {@link ReferenceType}
|
|
143
|
-
* `
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
142
|
+
* The {@link ReferenceType} an inline reference **stores entries of**, or
|
|
143
|
+
* `undefined` for every other field. The one predicate the collection
|
|
144
|
+
* definition, result reconstruction and the API surfaces share, so they cannot
|
|
145
|
+
* disagree about which fields carry nested Search Documents and which carry a
|
|
146
|
+
* bare IRI: a `labelOnly`/`idOnly` reference stays an id (plus a resolved
|
|
147
|
+
* label).
|
|
148
|
+
*
|
|
149
|
+
* The line is *any Role at all*, not `output`: an inline reference declaring no
|
|
150
|
+
* Role is a reading device, pruned before the writer
|
|
151
|
+
* ({@link isInternalField}), while one that is `filterable` or `facetable`
|
|
152
|
+
* without being `output` still has its entries stored – they are what a nested
|
|
153
|
+
* filter constrains and what the
|
|
154
|
+
* {@link ReferenceStrategy.identity identity companion} is harvested from. Only
|
|
155
|
+
* `output` decides whether they are *surfaced*, which is the surfaces’ own
|
|
156
|
+
* question ({@link outputFields}).
|
|
150
157
|
*
|
|
151
158
|
* A schema built by {@link searchSchema} always resolves the reference type of
|
|
152
159
|
* its own types; `undefined` for a type declared against another schema.
|
|
153
160
|
*/
|
|
154
161
|
export function nestedReferenceType(schema, field) {
|
|
155
|
-
return isInlineReference(field) && field
|
|
162
|
+
return isInlineReference(field) && !isInternalField(field)
|
|
156
163
|
? referenceTypeNamed(schema, field.ref.typeName)
|
|
157
164
|
: undefined;
|
|
158
165
|
}
|
|
@@ -173,33 +180,139 @@ export function nestedFieldName(parent, name) {
|
|
|
173
180
|
return `${parent}.${name}`;
|
|
174
181
|
}
|
|
175
182
|
/**
|
|
176
|
-
* The framing depth a Root Type needs: how
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
183
|
+
* The framing depth a Root Type needs: how far the **furthest value it declares**
|
|
184
|
+
* lies from it, in hops. Depth is a property of the declaration, bounded because
|
|
185
|
+
* {@link searchSchema} rejects inline cycles – never a knob or a constant.
|
|
186
|
+
* Framing bounded per batch keeps memory bounded by the unit of work (ADR 12),
|
|
187
|
+
* not the graph.
|
|
188
|
+
*
|
|
189
|
+
* Reach is measured in hops and depth in *intermediate* nodes, so depth is one
|
|
190
|
+
* less: reading `<a>/<b>` off a root needs the root’s own triples plus those of
|
|
191
|
+
* the node `<a>` reaches, which is depth 1. Floored at one, preserving the
|
|
192
|
+
* single-hop embed a non-inline reference has always had.
|
|
193
|
+
*
|
|
194
|
+
* Four things add hops, and counting only the first is what let a value fall
|
|
195
|
+
* outside the frame and be stored as absent, in silence:
|
|
196
|
+
*
|
|
197
|
+
* - **a property path**, which may traverse (`<a>/<b>` is two hops);
|
|
198
|
+
* - **an inline reference**, whose referent’s own fields reach on from wherever
|
|
199
|
+
* its path landed;
|
|
200
|
+
* - **a {@link ReferenceStrategy.local local} lookup**, whose target’s own
|
|
201
|
+
* fields reach on from the endpoint in the same way, through a Root Type;
|
|
202
|
+
* - **a reference naming a keyed target**, whose document key is read one hop
|
|
203
|
+
* past the referent (ADR 22) – so a reference reached through an inline
|
|
204
|
+
* chain needs its key hop inside the frame too, or it stores an un-keyed id
|
|
205
|
+
* that matches nothing in the target’s collection. This one is also what a
|
|
206
|
+
* cut local expansion falls back to, so it is counted there as well.
|
|
182
207
|
*/
|
|
183
208
|
export function inlineFramingDepth(schema, searchType) {
|
|
184
|
-
return Math.max(1,
|
|
209
|
+
return Math.max(1, framingReach(schema, searchType) - 1);
|
|
185
210
|
}
|
|
186
|
-
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
/**
|
|
212
|
+
* The furthest a declared value lies from a node of `searchType`, in hops. A
|
|
213
|
+
* reference the given schema does not declare (e.g. a type framed against
|
|
214
|
+
* another schema) contributes no reach.
|
|
215
|
+
*
|
|
216
|
+
* `visiting` is what makes the walk terminate. {@link searchSchema} rejects
|
|
217
|
+
* *inline* cycles, but a {@link ReferenceStrategy.local local} lookup can reach
|
|
218
|
+
* a Root Type that reaches back – `Work → Person → Work` is a perfectly
|
|
219
|
+
* reasonable schema – and nothing forbids it. A type already on the path
|
|
220
|
+
* contributes no further reach, so a cycle stops rather than being rejected:
|
|
221
|
+
* the depth it would ask for is unbounded, and the useful depth is the acyclic
|
|
222
|
+
* one.
|
|
223
|
+
*
|
|
224
|
+
* The cut is made **where the field is read**, not on entry, because the
|
|
225
|
+
* extraction cuts there too and still emits the key hop it falls back to. A
|
|
226
|
+
* guard on entry would count the cut as reach 0 and nothing else, framing that
|
|
227
|
+
* hop out – and the innermost referent would store a node IRI keying nothing.
|
|
228
|
+
*/
|
|
229
|
+
function framingReach(schema, searchType, visiting = new Set()) {
|
|
230
|
+
const onPath = new Set(visiting).add(searchType.name);
|
|
231
|
+
let furthest = 0;
|
|
192
232
|
for (const field of searchType.fields) {
|
|
193
|
-
if (
|
|
233
|
+
if (field.path === undefined) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const hops = pathHopCount(field.path);
|
|
237
|
+
if (isInlineReference(field)) {
|
|
238
|
+
const referent = referenceTypeNamed(schema, field.ref.typeName);
|
|
239
|
+
furthest = Math.max(furthest, hops +
|
|
240
|
+
(referent === undefined ? 0 : framingReach(schema, referent, onPath)));
|
|
194
241
|
continue;
|
|
195
242
|
}
|
|
196
|
-
|
|
197
|
-
|
|
243
|
+
// A `local` lookup reads the target’s OWN fields off the referent, so the
|
|
244
|
+
// target’s reach continues from wherever this field’s path landed – the
|
|
245
|
+
// same accumulation an inline reference makes, through a Root Type.
|
|
246
|
+
//
|
|
247
|
+
// Cut at a type already on the path, exactly where the extraction cuts:
|
|
248
|
+
// there the local expansion contributes nothing and the extraction falls
|
|
249
|
+
// back to the key hop, whose own traversal still has to be framed. Reading
|
|
250
|
+
// the cut as “reach 0, and nothing else to count” left the innermost
|
|
251
|
+
// referent’s key one hop outside the frame, so it stored a node IRI that
|
|
252
|
+
// matches nothing in the target’s collection.
|
|
253
|
+
const local = localLookupTypeOf(field, schema);
|
|
254
|
+
if (local !== undefined && !onPath.has(local.name)) {
|
|
255
|
+
furthest = Math.max(furthest, hops + framingReach(schema, local, onPath));
|
|
198
256
|
continue;
|
|
199
257
|
}
|
|
200
|
-
|
|
258
|
+
// A keyed target’s key field is itself path-bearing, so its own traversal
|
|
259
|
+
// counts too – the extraction reads it off the referent with that path.
|
|
260
|
+
const keyPath = keyedTargetKeyPath(field, schema);
|
|
261
|
+
furthest = Math.max(furthest, hops + (keyPath === undefined ? 0 : pathHopCount(keyPath)));
|
|
262
|
+
}
|
|
263
|
+
return furthest;
|
|
264
|
+
}
|
|
265
|
+
/** The `path` of the key field of the keyed Root Type a reference names, when
|
|
266
|
+
* it names one that declares a {@link RootType.key}. */
|
|
267
|
+
function keyedTargetKeyPath(field, schema) {
|
|
268
|
+
if (field.kind !== 'reference') {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
const targetName = labelSourceNameOf(field);
|
|
272
|
+
const target = targetName === undefined ? undefined : rootTypeNamed(schema, targetName);
|
|
273
|
+
if (target?.key === undefined) {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
// `searchSchema` guarantees a key field that is declared and path-bearing –
|
|
277
|
+
// the same guarantee the extraction’s key hop is built on.
|
|
278
|
+
return fieldNamed(target, target.key.field).path;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* How many hops a `path` traverses: the number of top-level sequence steps.
|
|
282
|
+
* `<a>/<b>` is two; alternation and inverse do not add a hop, and a `/` inside
|
|
283
|
+
* an IRI or a group is not a step.
|
|
284
|
+
*
|
|
285
|
+
* A path that carries no `<` is a **single term** – one IRI – however many `/`
|
|
286
|
+
* its IRI happens to contain (`<https://schema.org/name>` is one hop, not
|
|
287
|
+
* four). That is not a special case but the grammar: a sequence is only
|
|
288
|
+
* expressible with each IRI delimited, since `?s a/b ?o` does not parse.
|
|
289
|
+
* Reading the delimiters is therefore what tells a path apart from an IRI, and
|
|
290
|
+
* it is the whole reason this is safe to scan rather than parse.
|
|
291
|
+
*
|
|
292
|
+
* Deliberately a scanner: the grammar belongs to the reader adapter (which
|
|
293
|
+
* parses it for real), and only the *shape* of the sequence matters here. It is
|
|
294
|
+
* also the safe direction to be approximate in – an over-count frames one hop
|
|
295
|
+
* too wide, which costs a little; an under-count leaves a declared value
|
|
296
|
+
* outside the frame, which is silent.
|
|
297
|
+
*/
|
|
298
|
+
function pathHopCount(path) {
|
|
299
|
+
if (!path.includes('<')) {
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
let steps = 1;
|
|
303
|
+
let nesting = 0;
|
|
304
|
+
for (const character of path) {
|
|
305
|
+
if (character === '<' || character === '(') {
|
|
306
|
+
nesting++;
|
|
307
|
+
}
|
|
308
|
+
else if (character === '>' || character === ')') {
|
|
309
|
+
nesting--;
|
|
310
|
+
}
|
|
311
|
+
else if (character === '/' && nesting === 0) {
|
|
312
|
+
steps++;
|
|
313
|
+
}
|
|
201
314
|
}
|
|
202
|
-
return
|
|
315
|
+
return steps;
|
|
203
316
|
}
|
|
204
317
|
/**
|
|
205
318
|
* Every inline `ref.typeName` must resolve to a **declared Reference Type**, and
|
|
@@ -218,23 +331,156 @@ function assertResolvableInlineReferences(types, referenceTypes) {
|
|
|
218
331
|
if (!referenceTypes.has(field.ref.typeName)) {
|
|
219
332
|
throw new Error(`Inline reference “${searchType.name}.${field.name}” names “${field.ref.typeName}”, which is not a declared reference type; declare a reference type (a SearchType with no class) with that name.`);
|
|
220
333
|
}
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
const extraRoles = unserviceableNestedRoles(field);
|
|
334
|
+
// An inline reference’s stored value is a nested object, which an engine
|
|
335
|
+
// can neither tokenize for free text nor order by. `filterable` and
|
|
336
|
+
// `facetable` it CAN serve – through the identity companion below – so
|
|
337
|
+
// only the roles no companion could answer are refused here.
|
|
338
|
+
const extraRoles = unserviceableInlineRoles(field);
|
|
227
339
|
if (extraRoles.length > 0) {
|
|
228
340
|
throw new Error(`Inline reference “${searchType.name}.${field.name}” declares ${extraRoles
|
|
229
341
|
.map((role) => `“${role}”`)
|
|
230
|
-
.join(', ')}, which it cannot serve:
|
|
342
|
+
.join(', ')}, which it cannot serve: its value is a nested object, not a token an engine can search, sort or join on.`);
|
|
231
343
|
}
|
|
344
|
+
assertIdentityCompanion(searchType, field, referenceTypes);
|
|
232
345
|
}
|
|
233
346
|
}
|
|
347
|
+
for (const searchType of types) {
|
|
348
|
+
for (const field of searchType.fields) {
|
|
349
|
+
assertServiceableLocalLookup(searchType, field);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
assertConsistentIdentities(types);
|
|
234
353
|
for (const referenceType of referenceTypes.values()) {
|
|
235
354
|
assertNoInlineCycle(referenceType, referenceTypes, new Set());
|
|
236
355
|
}
|
|
237
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* An inline reference’s {@link ReferenceStrategy.identity identity companion}
|
|
359
|
+
* holds together in both directions: `filterable`/`facetable` need an
|
|
360
|
+
* `identity` to point the engine at, and an `identity` with neither Role is a
|
|
361
|
+
* companion nothing reads.
|
|
362
|
+
*
|
|
363
|
+
* The named field must be a nested **reference that names a target** – a
|
|
364
|
+
* `lookup`’s `target` or an `idOnly`’s `labelSource` – because naming one is
|
|
365
|
+
* exactly the claim the companion rests on: *these values are ids of documents
|
|
366
|
+
* in that collection* (ADR 20). That the target resolves to a declared Root
|
|
367
|
+
* Type needs no rule here: {@link assertResolvableLabelSources} walks Reference
|
|
368
|
+
* Types too, so the nested reference this names is resolved with every other.
|
|
369
|
+
*/
|
|
370
|
+
function assertIdentityCompanion(searchType, field, referenceTypes) {
|
|
371
|
+
const where = `Inline reference “${searchType.name}.${field.name}”`;
|
|
372
|
+
const identity = field.ref.identity;
|
|
373
|
+
const filters = field.filterable === true || field.facetable === true;
|
|
374
|
+
if (identity === undefined) {
|
|
375
|
+
if (filters) {
|
|
376
|
+
throw new Error(`${where} declares “filterable”/“facetable” without an “identity”: a nested object is not a value an engine filters or facets, so it needs a nested reference to name the ids to filter and facet in its place.`);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!filters) {
|
|
381
|
+
throw new Error(`${where} declares an “identity” but neither “filterable” nor “facetable”: the companion it names would be indexed and never read. Declare a Role for it, or drop the “identity”.`);
|
|
382
|
+
}
|
|
383
|
+
// A companion is a FLAT field beside the reference, and only a Root Type has
|
|
384
|
+
// somewhere flat to put one: inside a Reference Type it would be written into
|
|
385
|
+
// each entry and declared nowhere, so a filter on it would name a field the
|
|
386
|
+
// collection does not carry. An edge nested inside another edge is not a
|
|
387
|
+
// shape anything has needed; refusing it is better than serving it wrong.
|
|
388
|
+
if (searchType.class === undefined) {
|
|
389
|
+
throw new Error(`${where} declares an “identity”, but “${searchType.name}” is a reference type: an identity companion is a flat field beside the reference, and a nested one has nowhere to live. Declare the companion on the reference that carries this type.`);
|
|
390
|
+
}
|
|
391
|
+
// Resolvable by construction: the typeName was checked just above.
|
|
392
|
+
const referenceType = referenceTypes.get(field.ref.typeName);
|
|
393
|
+
const identityField = referenceType && fieldNamed(referenceType, identity);
|
|
394
|
+
if (identityField === undefined) {
|
|
395
|
+
throw new Error(`${where} names identity “${identity}”, which “${field.ref.typeName}” does not declare.`);
|
|
396
|
+
}
|
|
397
|
+
if (identityField.kind !== 'reference' ||
|
|
398
|
+
labelSourceNameOf(identityField) === undefined) {
|
|
399
|
+
throw new Error(`${where} names identity “${identity}”, which names no target: an identity companion holds ids of documents in a collection, so the field it harvests must be a reference declaring a “lookup” target or a label source.`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* A {@link ReferenceStrategy.local local} lookup stores a nested object rather
|
|
404
|
+
* than an id, so it is held to the same Roles an inline reference is: an engine
|
|
405
|
+
* can neither tokenize an object for free text, order by it, facet it, nor
|
|
406
|
+
* point a reference field at it.
|
|
407
|
+
*
|
|
408
|
+
* Without this the failures are all silent-ish and all different: a `joinable`
|
|
409
|
+
* one emits an engine reference over a field holding objects, a `facetable` one
|
|
410
|
+
* has `physicalFields` name a facet field the collection never declares, and a
|
|
411
|
+
* `filterable` one compiles a membership clause against an object. Refusing the
|
|
412
|
+
* declaration is the only place these are one mistake.
|
|
413
|
+
*
|
|
414
|
+
* `filterable` it CAN serve, and does so the same way an inline reference does:
|
|
415
|
+
* through a flat `${name}_id` companion beside the stored object
|
|
416
|
+
* ({@link physicalFields}). That is what lets a condition on the endpoint's
|
|
417
|
+
* identity be welded to a condition on the edge's own value – an engine can
|
|
418
|
+
* only weld conditions on an entry's own **leaf** fields, and the endpoint's id
|
|
419
|
+
* is otherwise one level further in.
|
|
420
|
+
*/
|
|
421
|
+
function assertServiceableLocalLookup(searchType, field) {
|
|
422
|
+
if (field.kind !== 'reference' ||
|
|
423
|
+
field.ref?.strategy !== 'lookup' ||
|
|
424
|
+
field.ref.local !== true) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const unserviceable = unserviceableInlineRoles(field);
|
|
428
|
+
if (unserviceable.length > 0 ||
|
|
429
|
+
field.facetable === true ||
|
|
430
|
+
field.joinable === true) {
|
|
431
|
+
const roles = [
|
|
432
|
+
...unserviceable,
|
|
433
|
+
...(field.facetable === true ? ['facetable'] : []),
|
|
434
|
+
// `validateSearchType` refuses `joinable` on an INLINE reference, not on
|
|
435
|
+
// a lookup, so a `local` one would otherwise pass: `joinGraph` builds the
|
|
436
|
+
// edge, the collection builder takes the nesting branch and emits no
|
|
437
|
+
// engine reference, and the join fails at query time against a field the
|
|
438
|
+
// collection never declared.
|
|
439
|
+
...(field.joinable === true ? ['joinable'] : []),
|
|
440
|
+
];
|
|
441
|
+
throw new Error(`Local lookup “${searchType.name}.${field.name}” declares ${roles
|
|
442
|
+
.map((role) => `“${role}”`)
|
|
443
|
+
.join(', ')}, which it cannot serve: a “local” lookup stores the referent’s own document, not an id an engine can search, sort, facet or join on.`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Every field nesting one Reference Type must agree about whether it declares
|
|
448
|
+
* an {@link ReferenceStrategy.identity identity}.
|
|
449
|
+
*
|
|
450
|
+
* One reference type yields ONE emitted filter type, shared by every field that
|
|
451
|
+
* nests it, and whether that filter offers an “ids it holds” arm is exactly
|
|
452
|
+
* whether an identity was declared. Left to disagree, the arm would be decided
|
|
453
|
+
* by whichever field a surface happened to build the type from: the
|
|
454
|
+
* identity-bearing field silently loses the ability to filter by id, or the
|
|
455
|
+
* identity-less one gains an arm that filters a nested object an engine cannot
|
|
456
|
+
* read. Both are silent, and which one you get depends on declaration order.
|
|
457
|
+
*
|
|
458
|
+
* The rule is therefore about the *edge type*, not about each field reaching
|
|
459
|
+
* it, and it fails at startup naming both fields.
|
|
460
|
+
*/
|
|
461
|
+
function assertConsistentIdentities(types) {
|
|
462
|
+
const declaredBy = new Map();
|
|
463
|
+
for (const searchType of types) {
|
|
464
|
+
for (const field of searchType.fields) {
|
|
465
|
+
if (!isInlineReference(field) || isInternalField(field)) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const identity = field.ref.identity !== undefined;
|
|
469
|
+
const where = `${searchType.name}.${field.name}`;
|
|
470
|
+
const first = declaredBy.get(field.ref.typeName);
|
|
471
|
+
if (first === undefined) {
|
|
472
|
+
declaredBy.set(field.ref.typeName, { field: where, identity });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (first.identity !== identity) {
|
|
476
|
+
const [with_, without] = first.identity
|
|
477
|
+
? [first.field, where]
|
|
478
|
+
: [where, first.field];
|
|
479
|
+
throw new Error(`References “${with_}” and “${without}” both nest “${field.ref.typeName}” but disagree about “identity”: they share one emitted filter type, so one of them would silently lose the ability to filter by id – or gain one that filters a nested object. Declare an identity on both, or on neither.`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
238
484
|
function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
|
|
239
485
|
if (onPath.has(referenceType.name)) {
|
|
240
486
|
throw new Error(`Inline reference cycle through reference type “${referenceType.name}”; an inline reference graph must be acyclic, so its framing depth stays bounded.`);
|
|
@@ -252,17 +498,31 @@ function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
|
|
|
252
498
|
}
|
|
253
499
|
/**
|
|
254
500
|
* Every field of a {@link ReferenceType} must be one the nesting can actually
|
|
255
|
-
* serve. A nested field carries
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
501
|
+
* serve. A nested field carries `output`, `filterable` and `searchable`; it may
|
|
502
|
+
* also be a `lookup`, resolved by descending its entries rather than by a
|
|
503
|
+
* round-trip of its own. `facetable` and `sortable` are refused, and for
|
|
504
|
+
* measured reasons rather than for want of a compiler:
|
|
505
|
+
*
|
|
506
|
+
* - **`facetable`** – an engine’s facet counts over a nested field are
|
|
507
|
+
* *document-level*. A filter scoped to one entry does not scope the facet, so
|
|
508
|
+
* a bucket counts every entry of every matching document, including the
|
|
509
|
+
* entries that did not satisfy the filter. The count is therefore wrong in a
|
|
510
|
+
* way no work on our side recovers. Facet a qualified edge through its
|
|
511
|
+
* {@link ReferenceStrategy.identity identity companion}, which is a flat
|
|
512
|
+
* field an engine facets exactly.
|
|
513
|
+
* - **`sortable`** – there is no such thing as sorting *into* an element of an
|
|
514
|
+
* array; a document holds many entries and a sort key is one value.
|
|
515
|
+
*
|
|
516
|
+
* A `labelSource` stays refused: it would need a label lookup no reconstruction
|
|
517
|
+
* runs, and a nested reference that wants its referent’s fields declares a
|
|
518
|
+
* `lookup` instead. A Role-less field stays an {@link isInternalField Internal
|
|
519
|
+
* Field}, the reading device that is the other half of an inline reference’s
|
|
520
|
+
* job.
|
|
263
521
|
*
|
|
264
522
|
* Checked schema-wide, like the label sources and for the same reason: a single
|
|
265
523
|
* declaration cannot see whether it is a Reference Type at all.
|
|
524
|
+
*
|
|
525
|
+
* See [ADR 24](../../docs/decisions/0024-carry-data-on-a-reference-edge.md).
|
|
266
526
|
*/
|
|
267
527
|
function assertServiceableNestedFields(referenceTypes) {
|
|
268
528
|
for (const referenceType of referenceTypes.values()) {
|
|
@@ -271,34 +531,49 @@ function assertServiceableNestedFields(referenceTypes) {
|
|
|
271
531
|
if (unserviceable.length > 0) {
|
|
272
532
|
throw new Error(`Nested field “${referenceType.name}.${field.name}” declares ${unserviceable
|
|
273
533
|
.map((role) => `“${role}”`)
|
|
274
|
-
.join(', ')}, which
|
|
534
|
+
.join(', ')}, which nesting cannot serve: an engine facets a nested field per document rather than per entry, sorts on one value rather than into an array, and has no collection to join to from inside an entry. Facet the edge through the inline reference’s “identity” companion instead.`);
|
|
275
535
|
}
|
|
276
536
|
if (field.labelSource !== undefined) {
|
|
277
|
-
throw new Error(`Nested field “${referenceType.name}.${field.name}” declares a label source, which an inline reference cannot serve;
|
|
278
|
-
}
|
|
279
|
-
// A lookup is resolved from the hit's own projection, level by level; a
|
|
280
|
-
// nested document is read back with its referent and never appears in
|
|
281
|
-
// one. Accepting it would emit a type whose every field serves null.
|
|
282
|
-
if (field.kind === 'reference' && field.ref?.strategy === 'lookup') {
|
|
283
|
-
throw new Error(`Nested field “${referenceType.name}.${field.name}” is a lookup, which an inline reference cannot serve: a nested document is read back with its referent, so nothing resolves a lookup inside one. Nest the referent through an inline reference instead.`);
|
|
537
|
+
throw new Error(`Nested field “${referenceType.name}.${field.name}” declares a label source, which an inline reference cannot serve; declare a “lookup” on the nested reference instead of resolving a label for it.`);
|
|
284
538
|
}
|
|
285
539
|
}
|
|
286
540
|
}
|
|
287
541
|
}
|
|
288
|
-
/** The Roles a field declares that nesting cannot serve
|
|
289
|
-
*
|
|
542
|
+
/** The Roles a **nested** field declares that nesting cannot serve, in
|
|
543
|
+
* declaration order, so a message names them all at once. */
|
|
290
544
|
function unserviceableNestedRoles(field) {
|
|
291
|
-
return UNSERVICEABLE_NESTED_ROLES.filter((role) => role ===
|
|
292
|
-
? field.searchable !== undefined
|
|
293
|
-
: field[role] === true);
|
|
545
|
+
return UNSERVICEABLE_NESTED_ROLES.filter((role) => field[role] === true);
|
|
294
546
|
}
|
|
295
|
-
/**
|
|
547
|
+
/**
|
|
548
|
+
* The Roles a nested field cannot carry. An engine facets a nested field per
|
|
549
|
+
* document rather than per entry, and cannot sort into an array element.
|
|
550
|
+
*
|
|
551
|
+
* `joinable` is refused for a different reason: a join is a clause about
|
|
552
|
+
* another COLLECTION, and there is none to address from inside an entry.
|
|
553
|
+
* `buildJoinGraph` walks Root Types, so a nested one builds no edge and the
|
|
554
|
+
* collection emits no engine reference – while the surface still offers the
|
|
555
|
+
* filter, whose criterion then degrades to a vacuous `in: []` and matches
|
|
556
|
+
* EVERYTHING. Refusing the declaration is the only place that is one mistake.
|
|
557
|
+
*/
|
|
296
558
|
const UNSERVICEABLE_NESTED_ROLES = [
|
|
297
|
-
'searchable',
|
|
298
|
-
'filterable',
|
|
299
559
|
'facetable',
|
|
300
560
|
'sortable',
|
|
561
|
+
'joinable',
|
|
301
562
|
];
|
|
563
|
+
/** The Roles the **inline reference itself** declares that it cannot serve, in
|
|
564
|
+
* declaration order. Its value is a nested object, which an engine can neither
|
|
565
|
+
* tokenize for free text nor order by; `filterable` and `facetable` it serves
|
|
566
|
+
* through its {@link ReferenceStrategy.identity identity companion}. */
|
|
567
|
+
function unserviceableInlineRoles(field) {
|
|
568
|
+
return UNSERVICEABLE_INLINE_ROLES.filter((role) => role === 'searchable'
|
|
569
|
+
? field.searchable !== undefined
|
|
570
|
+
: field[role] === true);
|
|
571
|
+
}
|
|
572
|
+
/** The Roles an inline reference cannot carry. `joinable` is absent because
|
|
573
|
+
* {@link validateSearchType} already refuses it on an inline reference, with a
|
|
574
|
+
* reason of its own; refusing it twice would only make which message appears
|
|
575
|
+
* depend on validation order. */
|
|
576
|
+
const UNSERVICEABLE_INLINE_ROLES = ['searchable', 'sortable'];
|
|
302
577
|
/** The label field name a type falls back to when it declares no
|
|
303
578
|
* {@link SearchTypeBase.labelField}. */
|
|
304
579
|
export const DEFAULT_LABEL_FIELD = 'label';
|
|
@@ -341,11 +616,16 @@ export function labelSourceNameOf(field) {
|
|
|
341
616
|
* can actually serve labels ({@link labelFieldOf}). Checked schema-wide,
|
|
342
617
|
* because a single declaration cannot see its siblings.
|
|
343
618
|
*
|
|
344
|
-
* Resolved through {@link rootTypeNamed}, which is exactly the set that
|
|
345
|
-
* serve labels
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
619
|
+
* Resolved through {@link rootTypeNamed}, which is exactly the set that has a
|
|
620
|
+
* collection to serve labels from – a Reference Type is nested inside its
|
|
621
|
+
* referrer and has none, so naming one here fails to resolve rather than
|
|
622
|
+
* resolving to something unreadable.
|
|
623
|
+
*
|
|
624
|
+
* Reference Types are walked too, so a **nested** `lookup` has its `target`
|
|
625
|
+
* resolved by the same pass a top-level one does. That is also what validates
|
|
626
|
+
* the target behind an inline reference’s
|
|
627
|
+
* {@link ReferenceStrategy.identity identity companion}, since the companion
|
|
628
|
+
* harvests a nested reference that names one.
|
|
349
629
|
*/
|
|
350
630
|
function assertResolvableLabelSources(types, schema) {
|
|
351
631
|
for (const searchType of types) {
|
|
@@ -742,6 +1022,69 @@ export function assertValidSearchType(searchType) {
|
|
|
742
1022
|
throw new Error(`Invalid search type “${searchType.name}”: ${detail}.`);
|
|
743
1023
|
}
|
|
744
1024
|
}
|
|
1025
|
+
/** The physical name of an inline reference’s
|
|
1026
|
+
* {@link ReferenceStrategy.identity identity companion}. */
|
|
1027
|
+
export function identityFieldName(name) {
|
|
1028
|
+
return `${name}_id`;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* The Root Type whose labels a reference’s **facet buckets** read – its own
|
|
1032
|
+
* label source, or, for an inline reference, the one its
|
|
1033
|
+
* {@link ReferenceStrategy.identity identity companion} points at.
|
|
1034
|
+
*
|
|
1035
|
+
* The same one-level-in reading {@link inheritedFacetKeys} makes, and for the
|
|
1036
|
+
* same reason: the companion holds that field’s ids, so the type that names
|
|
1037
|
+
* those ids is the type that can label them. Kept together with it so a facet
|
|
1038
|
+
* cannot inherit a policy from one type and its labels from another – or, as
|
|
1039
|
+
* happened first, inherit the policy and no labels at all.
|
|
1040
|
+
*/
|
|
1041
|
+
export function labelTargetNameOf(field, schema) {
|
|
1042
|
+
if (field.kind !== 'reference') {
|
|
1043
|
+
return undefined;
|
|
1044
|
+
}
|
|
1045
|
+
return labelSourceNameOf(identityFieldOf(field, schema) ?? field);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The Root Type a {@link ReferenceStrategy.local local} lookup projects its
|
|
1049
|
+
* referents through, or `undefined` for every other field. Such a reference
|
|
1050
|
+
* stores nested documents shaped by the **target’s own declaration** – so it
|
|
1051
|
+
* needs no reference type of its own, and reconstructs through the same path a
|
|
1052
|
+
* resolved referent does.
|
|
1053
|
+
*/
|
|
1054
|
+
export function localLookupTypeOf(field, schema) {
|
|
1055
|
+
if (schema === undefined ||
|
|
1056
|
+
field.kind !== 'reference' ||
|
|
1057
|
+
field.ref?.strategy !== 'lookup' ||
|
|
1058
|
+
field.ref.local !== true) {
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
return rootTypeNamed(schema, field.ref.target);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* The nested reference an inline reference’s
|
|
1065
|
+
* {@link ReferenceStrategy.identity identity companion} harvests, or
|
|
1066
|
+
* `undefined` where the field declares none (or the schema cannot resolve its
|
|
1067
|
+
* reference type). The one reading of *which nested field identifies the edge*,
|
|
1068
|
+
* so the projection (which harvests it), {@link inheritedFacetKeys} (which
|
|
1069
|
+
* reads its target’s facet policy) and an adapter’s filter compiler cannot
|
|
1070
|
+
* resolve it differently.
|
|
1071
|
+
*/
|
|
1072
|
+
export function identityFieldOf(field, schema) {
|
|
1073
|
+
if (schema === undefined || !isInlineReference(field)) {
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const identity = field.ref.identity;
|
|
1077
|
+
if (identity === undefined) {
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
const referenceType = referenceTypeNamed(schema, field.ref.typeName);
|
|
1081
|
+
if (referenceType === undefined) {
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
// `assertIdentityCompanion` guarantees the named field is declared and is a
|
|
1085
|
+
// reference, so this is a read rather than a search.
|
|
1086
|
+
return fieldNamed(referenceType, identity);
|
|
1087
|
+
}
|
|
745
1088
|
/**
|
|
746
1089
|
* The display fields of a localized `text` field are pattern-based, not
|
|
747
1090
|
* enumerated per declared locale: projection stores `${name}_${lang}` for
|
|
@@ -1049,16 +1392,29 @@ export function physicalFields(field, schema) {
|
|
|
1049
1392
|
: [],
|
|
1050
1393
|
// Text is never facetable (`validateSearchType`).
|
|
1051
1394
|
facet: undefined,
|
|
1395
|
+
identity: undefined,
|
|
1052
1396
|
};
|
|
1053
1397
|
}
|
|
1398
|
+
// A field storing a nested OBJECT cannot be filtered or faceted as itself, so
|
|
1399
|
+
// everything an engine indexes for it reads the identity companion instead –
|
|
1400
|
+
// the facet included, which is why the facet name is derived from the
|
|
1401
|
+
// companion rather than from `name`. Two shapes store one: an inline
|
|
1402
|
+
// reference that names an `identity`, and a `local` lookup, whose own id is
|
|
1403
|
+
// otherwise a level further in than an engine can weld a condition to.
|
|
1404
|
+
const nestsAnObject = identityFieldOf(field, schema) !== undefined ||
|
|
1405
|
+
(localLookupTypeOf(field, schema) !== undefined &&
|
|
1406
|
+
field.filterable === true);
|
|
1407
|
+
const identity = nestsAnObject ? identityFieldName(field.name) : undefined;
|
|
1408
|
+
const filtered = identity ?? field.name;
|
|
1054
1409
|
return {
|
|
1055
1410
|
search: field.searchable !== undefined ? [`${field.name}_search`] : [],
|
|
1056
1411
|
sort: [],
|
|
1057
1412
|
facet: field.facetable !== true
|
|
1058
1413
|
? undefined
|
|
1059
1414
|
: inheritedFacetKeys(field, schema) === undefined
|
|
1060
|
-
?
|
|
1061
|
-
: `${
|
|
1415
|
+
? filtered
|
|
1416
|
+
: `${filtered}_facet`,
|
|
1417
|
+
identity,
|
|
1062
1418
|
};
|
|
1063
1419
|
}
|
|
1064
1420
|
/**
|
|
@@ -1071,6 +1427,14 @@ export function physicalFields(field, schema) {
|
|
|
1071
1427
|
* own values rather than reading a referent, is re-keyed by nothing and so
|
|
1072
1428
|
* narrowed by nothing either. The one reading the projection (which writes the
|
|
1073
1429
|
* companion) and {@link physicalFields} (which names it) share.
|
|
1430
|
+
*
|
|
1431
|
+
* An **inline** reference names its target one level in, through the nested
|
|
1432
|
+
* reference its {@link ReferenceStrategy.identity identity companion} harvests
|
|
1433
|
+
* ({@link identityFieldOf}). The boundary is the same one: the companion holds
|
|
1434
|
+
* that field’s ids, so the policy that narrows a facet over those ids is the
|
|
1435
|
+
* policy of the type that field names. Reading it through the nested field
|
|
1436
|
+
* rather than declaring it twice is what keeps *which of a type’s ids deserve
|
|
1437
|
+
* a bucket* a fact about the type.
|
|
1074
1438
|
*/
|
|
1075
1439
|
export function inheritedFacetKeys(field, schema) {
|
|
1076
1440
|
if (schema === undefined ||
|
|
@@ -1079,7 +1443,8 @@ export function inheritedFacetKeys(field, schema) {
|
|
|
1079
1443
|
field.derive !== undefined) {
|
|
1080
1444
|
return undefined;
|
|
1081
1445
|
}
|
|
1082
|
-
const
|
|
1446
|
+
const identity = identityFieldOf(field, schema);
|
|
1447
|
+
const targetName = labelSourceNameOf(identity ?? field);
|
|
1083
1448
|
return targetName === undefined
|
|
1084
1449
|
? undefined
|
|
1085
1450
|
: rootTypeNamed(schema, targetName)?.facetKeys;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Engine- and domain-agnostic core for RDF-backed search: a unified declarative field model (SearchField/SearchType/SearchSchema), a neutral query IR, the SearchEngine port with logical result types, and a streaming CONSTRUCT-to-document projection. Bakes in no engine, protocol, or domain.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@tpluscode/rdf-ns-builders": "^5.0.0",
|
|
54
54
|
"@types/jsonld": "^1.5.15",
|
|
55
|
-
"n3": "^2.
|
|
55
|
+
"n3": "^2.4.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"vitest": "^4.0.0"
|