@lde/search 0.28.1 → 0.30.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/schema.js CHANGED
@@ -120,7 +120,7 @@ export function referenceTypeNamed(schema, name) {
120
120
  }
121
121
  /**
122
122
  * The {@link RootType} a declaration names – a `lookup`’s `target`, an
123
- * `idOnly`’s {@link ReferenceField.labelSource} ({@link labelSourceNameOf}), a
123
+ * `idOnly`’s {@link ReferenceField.labelSource} ({@link labelSourceNamesOf}), a
124
124
  * join edge – or `undefined` when the schema declares no Root Type by that
125
125
  * name. The one reading of *which type does this point at*, so the projection
126
126
  * (which re-keys a reference through its target’s {@link KeyField}), the
@@ -250,27 +250,34 @@ function framingReach(schema, searchType, visiting = new Set()) {
250
250
  // the cut as “reach 0, and nothing else to count” left the innermost
251
251
  // referent’s key one hop outside the frame, so it stored a node IRI that
252
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));
256
- continue;
253
+ //
254
+ // Several targets each contribute their own reach: a referent is framed
255
+ // once, and the frame has to hold whichever declaration it turns out to
256
+ // match, so the furthest of them decides.
257
+ // The field’s own traversal counts whatever it names.
258
+ furthest = Math.max(furthest, hops);
259
+ const local = localLookupTargetsOf(field, schema).filter((target) => !onPath.has(target.name));
260
+ for (const target of local) {
261
+ furthest = Math.max(furthest, hops + framingReach(schema, target, onPath));
257
262
  }
258
263
  // A keyed target’s key field is itself path-bearing, so its own traversal
259
264
  // 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)));
265
+ // Counted for every target that is not locally expanded above, cut ones
266
+ // included: there the extraction falls back to exactly this hop.
267
+ for (const target of referencedTargetsOf(field, schema)) {
268
+ if (local.includes(target)) {
269
+ continue;
270
+ }
271
+ const keyPath = keyPathOf(target);
272
+ furthest = Math.max(furthest, hops + (keyPath === undefined ? 0 : pathHopCount(keyPath)));
273
+ }
262
274
  }
263
275
  return furthest;
264
276
  }
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) {
277
+ /** The `path` of a Root Type’s key field, when it declares a
278
+ * {@link RootType.key}. */
279
+ function keyPathOf(target) {
280
+ if (target.key === undefined) {
274
281
  return undefined;
275
282
  }
276
283
  // `searchSchema` guarantees a key field that is declared and path-bearing –
@@ -395,7 +402,7 @@ function assertIdentityCompanion(searchType, field, referenceTypes) {
395
402
  throw new Error(`${where} names identity “${identity}”, which “${field.ref.typeName}” does not declare.`);
396
403
  }
397
404
  if (identityField.kind !== 'reference' ||
398
- labelSourceNameOf(identityField) === undefined) {
405
+ labelSourceNamesOf(identityField).length === 0) {
399
406
  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
407
  }
401
408
  }
@@ -612,15 +619,36 @@ export function labelFieldOf(searchType) {
612
619
  : undefined;
613
620
  }
614
621
  /**
615
- * The Root Type a reference resolves labels from, by name: a `lookup`’s
616
- * `target`, an `idOnly`’s {@link ReferenceField.labelSource}, or `undefined`
617
- * when it resolves none. One reading for the two declarations, so a consumer
618
- * never branches on the strategy to find the collection.
622
+ * The Root Types a reference resolves labels from, by name and in order of
623
+ * precedence: a `lookup`’s `target`, an `idOnly`’s
624
+ * {@link ReferenceField.labelSource}, or none when it resolves none. One
625
+ * reading for the two declarations, so a consumer never branches on the
626
+ * strategy to find the collection – and one **shape** for the one-target and
627
+ * the several-target declaration, so a consumer never branches on that either:
628
+ * a single name is the list of one.
619
629
  */
620
- export function labelSourceNameOf(field) {
621
- return field.ref?.strategy === 'lookup'
622
- ? field.ref.target
623
- : field.labelSource;
630
+ export function labelSourceNamesOf(field) {
631
+ const declared = field.ref?.strategy === 'lookup' ? field.ref.target : field.labelSource;
632
+ return declared === undefined
633
+ ? []
634
+ : typeof declared === 'string'
635
+ ? [declared]
636
+ : declared;
637
+ }
638
+ /**
639
+ * The Root Types a reference resolves against ({@link labelSourceNamesOf}),
640
+ * resolved in the given schema and in order of precedence. A name the schema
641
+ * does not declare as a Root Type is skipped – `searchSchema` rejects such a
642
+ * declaration, so this only happens for a field read against a foreign schema,
643
+ * where it resolves what it can.
644
+ */
645
+ export function referencedTargetsOf(field, schema) {
646
+ if (schema === undefined || field.kind !== 'reference') {
647
+ return [];
648
+ }
649
+ return labelSourceNamesOf(field)
650
+ .map((name) => rootTypeNamed(schema, name))
651
+ .filter((target) => target !== undefined);
624
652
  }
625
653
  /**
626
654
  * Every name a reference resolves labels from – a `lookup`’s `target` or an
@@ -655,28 +683,97 @@ function assertResolvableLabelSources(types, schema) {
655
683
  throw new Error(`Reference “${searchType.name}.${field.name}” declares a label source on a ${field.ref.strategy} reference; only an idOnly reference does, for its facet buckets.`);
656
684
  }
657
685
  }
658
- const sourceName = field.kind === 'reference' && field.ref?.strategy === 'lookup'
659
- ? field.ref.target
660
- : labelSource;
661
- if (sourceName === undefined) {
686
+ if (field.kind !== 'reference') {
662
687
  continue;
663
688
  }
664
- const source = rootTypeNamed(schema, sourceName);
665
- if (source === undefined) {
666
- // A name that IS declared, just not as a Root Type, is the confusing
667
- // case: telling the author to declare a type they already declared
668
- // would send them looking in the wrong place. Only a Root Type has a
669
- // collection to resolve against, so name that instead.
670
- throw new Error(referenceTypeNamed(schema, sourceName) === undefined
671
- ? `Reference “${searchType.name}.${field.name}” names unknown label source “${sourceName}”; declare a SearchType with that name.`
672
- : `Reference “${searchType.name}.${field.name}” names label source “${sourceName}”, which is a Reference Type; a label source must be a Root Type, since a resolved label is read from that type’s own collection.`);
689
+ const sources = [];
690
+ for (const sourceName of labelSourceNamesOf(field)) {
691
+ const source = rootTypeNamed(schema, sourceName);
692
+ if (source === undefined) {
693
+ // A name that IS declared, just not as a Root Type, is the confusing
694
+ // case: telling the author to declare a type they already declared
695
+ // would send them looking in the wrong place. Only a Root Type has a
696
+ // collection to resolve against, so name that instead.
697
+ throw new Error(referenceTypeNamed(schema, sourceName) === undefined
698
+ ? `Reference “${searchType.name}.${field.name}” names unknown label source “${sourceName}”; declare a SearchType with that name.`
699
+ : `Reference “${searchType.name}.${field.name}” names label source “${sourceName}”, which is a Reference Type; a label source must be a Root Type, since a resolved label is read from that type’s own collection.`);
700
+ }
701
+ if (labelFieldOf(source) === undefined) {
702
+ throw new Error(`Reference “${searchType.name}.${field.name}” uses label source “${sourceName}”, which must declare an output, searchable text field “${labelFieldNameOf(source)}”.`);
703
+ }
704
+ sources.push(source);
673
705
  }
674
- if (labelFieldOf(source) === undefined) {
675
- throw new Error(`Reference “${searchType.name}.${field.name}” uses label source “${sourceName}”, which must declare an output, searchable text field “${labelFieldNameOf(source)}”.`);
706
+ assertCompatibleTargets(searchType, field, sources);
707
+ }
708
+ }
709
+ }
710
+ /**
711
+ * The targets of a reference naming **several** may not disagree about a
712
+ * field they both declare: a stored referent of either kind lands in one
713
+ * nested object, which an engine declares once, so `birthDate` cannot be a
714
+ * `date` on one target and a `keyword` on the other, nor a list on one and a
715
+ * single value on the other – nor an id on one and a nested document on the
716
+ * other, nor indexed on one and stored only on the other. Everything that
717
+ * decides a nested field’s physical shape has to agree: kind, arity, the
718
+ * Roles it opts into, and for a reference the strategy and what it points at.
719
+ * A field only one target declares is fine – the other simply never fills it
720
+ * – and so is a `text` field whose locales differ, since display stores every
721
+ * present language regardless.
722
+ */
723
+ function assertCompatibleTargets(searchType, field, targets) {
724
+ const seen = new Map();
725
+ for (const target of targets) {
726
+ for (const declared of target.fields) {
727
+ const earlier = seen.get(declared.name);
728
+ if (earlier === undefined) {
729
+ seen.set(declared.name, { target, field: declared });
730
+ continue;
731
+ }
732
+ if (describeShape(earlier.field) !== describeShape(declared)) {
733
+ throw new Error(`Reference “${searchType.name}.${field.name}” names targets “${earlier.target.name}” and “${target.name}”, which both declare “${declared.name}” but not alike (${describeShape(earlier.field)} vs ${describeShape(declared)}); a referent of either kind is stored in one shape, so a field the targets share must be declared the same on both.`);
676
734
  }
677
735
  }
678
736
  }
679
737
  }
738
+ /**
739
+ * Everything that decides a field’s physical shape, spelled out for a message
740
+ * comparing two declarations: kind and arity, the Roles it carries, and for a
741
+ * reference its strategy, what it points at, and whether it stores a copy.
742
+ */
743
+ function describeShape(field) {
744
+ const roles = ['filterable', 'facetable', 'sortable'].filter((role) => field[role] === true);
745
+ if (field.searchable !== undefined) {
746
+ roles.push('searchable');
747
+ }
748
+ const ref = field.kind === 'reference' && field.ref !== undefined
749
+ ? [
750
+ field.ref.strategy,
751
+ ...labelSourceNamesOf(field),
752
+ ...(field.ref.strategy === 'inline' ? [field.ref.typeName] : []),
753
+ ...(field.ref.strategy === 'lookup' && field.ref.local === true
754
+ ? ['local']
755
+ : []),
756
+ ]
757
+ : [];
758
+ return [
759
+ field.array === true ? `${field.kind} list` : field.kind,
760
+ ...(ref.length > 0 ? [`→ ${ref.join(' ')}`] : []),
761
+ ...(roles.length > 0 ? [`(${roles.join(', ')})`] : []),
762
+ ].join(' ');
763
+ }
764
+ /**
765
+ * The Root Type a stored referent was projected through, among the targets
766
+ * its lookup names: the one its discriminator names ({@link TARGET_FIELD}),
767
+ * or the first declared where it carries none – a single-target lookup stores
768
+ * none, and the first target is also what an unmatched referent was projected
769
+ * through. The one reading of a stored discriminator, so the projection
770
+ * (which prunes through it) and an engine adapter (which reconstructs through
771
+ * it) cannot read it differently.
772
+ */
773
+ export function storedTargetOf(entry, targets) {
774
+ const name = entry[TARGET_FIELD];
775
+ return targets.find((target) => target.name === name) ?? targets[0];
776
+ }
680
777
  /**
681
778
  * The reserved logical name of a document’s IRI – the one field every indexed
682
779
  * thing carries, and the only one no {@link SearchType} declares. It is the
@@ -706,9 +803,28 @@ export const ID_FIELD = 'id';
706
803
  */
707
804
  export const AND_KEY = 'and';
708
805
  export const OR_KEY = 'or';
806
+ /**
807
+ * The physical name under which a stored referent of a lookup naming
808
+ * **several** targets records which of them it belongs to: the `name` of the
809
+ * Root Type whose `class` its `rdf:type` matched first, or the first target
810
+ * declared where none did. Written by the projection, read by an engine
811
+ * adapter to reconstruct the referent through the right declaration when no
812
+ * collection answers for it – an unidentified referent, or an identified one
813
+ * whose document is not indexed. A single-target lookup stores none: there is
814
+ * nothing to tell apart.
815
+ *
816
+ * Reserved schema-wide ({@link validateSearchType} rejects a field of this
817
+ * name), so it can never shadow a declared field of a stored entry.
818
+ */
819
+ export const TARGET_FIELD = '_target';
709
820
  /** The logical field names no {@link SearchType} may declare, each because a
710
821
  * surface already gives that name a meaning of its own. */
711
- const RESERVED_FIELD_NAMES = [ID_FIELD, AND_KEY, OR_KEY];
822
+ const RESERVED_FIELD_NAMES = [
823
+ ID_FIELD,
824
+ AND_KEY,
825
+ OR_KEY,
826
+ TARGET_FIELD,
827
+ ];
712
828
  /** Kinds that can feed full-text search (project a folded search field). */
713
829
  const SEARCHABLE_KINDS = ['text', 'keyword', 'reference'];
714
830
  /** Kinds whose projection applies the {@link KeywordField.transform}. */
@@ -833,16 +949,38 @@ export function validateSearchType(searchType) {
833
949
  if (field.output === true &&
834
950
  ((field.ref?.strategy === 'inline' &&
835
951
  field.ref.typeName === undefined) ||
836
- (field.ref?.strategy === 'lookup' && field.ref.target === undefined))) {
952
+ (field.ref?.strategy === 'lookup' &&
953
+ labelSourceNamesOf(field).length === 0))) {
837
954
  issue('missing-ref-type-name');
838
955
  }
839
956
  // A join addresses the referent's collection – the one a lookup's
840
957
  // `target` or an idOnly's `labelSource` names. With neither, the flag
841
958
  // states an edge to nowhere.
842
- if (field.joinable === true &&
843
- labelSourceNameOf(field) === undefined) {
959
+ const targets = labelSourceNamesOf(field);
960
+ if (field.joinable === true && targets.length === 0) {
844
961
  issue('joinable-without-label-source');
845
962
  }
963
+ // An engine reference names ONE collection, and ids that live in
964
+ // several have no single collection to reference – so a reference whose
965
+ // referent may be any of several types can never be an engine edge. Its
966
+ // labels, facets and id filters all work from this document; a
967
+ // condition on the referent’s own fields does not (#717).
968
+ if (field.joinable === true && targets.length > 1) {
969
+ issue('joinable-with-several-targets');
970
+ }
971
+ // Precedence between targets is declaration order, so a name declared
972
+ // twice would have to mean two different ranks.
973
+ if (new Set(targets).size !== targets.length) {
974
+ issue('duplicate-target');
975
+ }
976
+ // A list of targets names at least one: an empty one would silently
977
+ // read as no target at all – no re-keying, no labels, no facet policy.
978
+ const declaredTargets = field.ref?.strategy === 'lookup'
979
+ ? field.ref.target
980
+ : field.labelSource;
981
+ if (Array.isArray(declaredTargets) && declaredTargets.length === 0) {
982
+ issue('empty-target');
983
+ }
846
984
  // An inline reference is stored as a NESTED OBJECT, not as an id an
847
985
  // engine can point a reference field at, so the two cannot both hold:
848
986
  // the collection definition would emit the nesting and silently drop the
@@ -1040,44 +1178,46 @@ export function identityFieldName(name) {
1040
1178
  return `${name}_id`;
1041
1179
  }
1042
1180
  /**
1043
- * The Root Type whose labels a reference’s **facet buckets** read – its own
1044
- * label source, or, for an inline reference, the one its
1045
- * {@link ReferenceStrategy.identity identity companion} points at.
1181
+ * The Root Types whose labels a reference’s **facet buckets** read, by name and
1182
+ * in order of precedence – its own label sources, or, for an inline reference,
1183
+ * those its {@link ReferenceStrategy.identity identity companion} points at.
1184
+ * Empty for a field that labels no bucket.
1046
1185
  *
1047
- * The same one-level-in reading {@link inheritedFacetKeys} makes, and for the
1048
- * same reason: the companion holds that field’s ids, so the type that names
1049
- * those ids is the type that can label them. Kept together with it so a facet
1050
- * cannot inherit a policy from one type and its labels from another – or, as
1051
- * happened first, inherit the policy and no labels at all.
1186
+ * The same one-level-in reading {@link inheritedFacetPolicies} makes, and for
1187
+ * the same reason: the companion holds that field’s ids, so the types that
1188
+ * name those ids are the types that can label them. Kept together with it so a
1189
+ * facet cannot inherit a policy from one type and its labels from another –
1190
+ * or, as happened first, inherit the policy and no labels at all.
1052
1191
  */
1053
- export function labelTargetNameOf(field, schema) {
1192
+ export function labelTargetNamesOf(field, schema) {
1054
1193
  if (field.kind !== 'reference') {
1055
- return undefined;
1194
+ return [];
1056
1195
  }
1057
- return labelSourceNameOf(identityFieldOf(field, schema) ?? field);
1196
+ return labelSourceNamesOf(identityFieldOf(field, schema) ?? field);
1058
1197
  }
1059
1198
  /**
1060
- * The Root Type a {@link ReferenceStrategy.local local} lookup projects its
1061
- * referents through, or `undefined` for every other field. Such a reference
1062
- * stores nested documents shaped by the **target’s own declaration** – so it
1063
- * needs no reference type of its own, and reconstructs through the same path a
1064
- * resolved referent does.
1199
+ * The Root Types a {@link ReferenceStrategy.local local} lookup projects its
1200
+ * referents through, in order of precedence – or none for every other field.
1201
+ * Such a reference stores nested documents shaped by the **target’s own
1202
+ * declaration** – so it needs no reference type of its own, and reconstructs
1203
+ * through the same path a resolved referent does. With several targets, each
1204
+ * referent is projected through the one its `rdf:type` matches
1205
+ * ({@link TARGET_FIELD}).
1065
1206
  */
1066
- export function localLookupTypeOf(field, schema) {
1067
- if (schema === undefined ||
1068
- field.kind !== 'reference' ||
1207
+ export function localLookupTargetsOf(field, schema) {
1208
+ if (field.kind !== 'reference' ||
1069
1209
  field.ref?.strategy !== 'lookup' ||
1070
1210
  field.ref.local !== true) {
1071
- return undefined;
1211
+ return [];
1072
1212
  }
1073
- return rootTypeNamed(schema, field.ref.target);
1213
+ return referencedTargetsOf(field, schema);
1074
1214
  }
1075
1215
  /**
1076
1216
  * The nested reference an inline reference’s
1077
1217
  * {@link ReferenceStrategy.identity identity companion} harvests, or
1078
1218
  * `undefined` where the field declares none (or the schema cannot resolve its
1079
1219
  * reference type). The one reading of *which nested field identifies the edge*,
1080
- * so the projection (which harvests it), {@link inheritedFacetKeys} (which
1220
+ * so the projection (which harvests it), {@link inheritedFacetPolicies} (which
1081
1221
  * reads its target’s facet policy) and an adapter’s filter compiler cannot
1082
1222
  * resolve it differently.
1083
1223
  */
@@ -1304,13 +1444,22 @@ export function irAlias(searchType, field) {
1304
1444
  return `urn:lde:${searchType.name}/${field.name}`;
1305
1445
  }
1306
1446
  /**
1307
- * Whether a facet on this field returns fixed range bins (a histogram) rather
1308
- * than one bucket per distinct value: it declares non-empty
1309
- * {@link RangeFacetable.facetRanges}. One predicate for the surface’s facet
1310
- * type, the adapter’s facet clause and the bucket reconstruction, so they
1311
- * cannot disagree.
1447
+ * Whether a facet on this field is a **range facet**: the field is numeric
1448
+ * (`integer`/`number`/`date`), so its facet reports the extent of its values
1449
+ * besides its buckets. One predicate for the surface’s facet type and the
1450
+ * facet reconstruction, so they cannot disagree.
1312
1451
  */
1313
1452
  export function isRangeFacet(field) {
1453
+ return (field.kind === 'integer' || field.kind === 'number' || field.kind === 'date');
1454
+ }
1455
+ /**
1456
+ * Whether a facet on this field is a **binned range facet**: it buckets into
1457
+ * the fixed bins it declares as non-empty {@link RangeFacetable.facetRanges}
1458
+ * rather than one bucket per distinct value. One predicate for the surface’s
1459
+ * facet type, the adapter’s facet clause and the bucket reconstruction, so
1460
+ * they cannot disagree.
1461
+ */
1462
+ export function isBinnedRangeFacet(field) {
1314
1463
  return field.facetRanges !== undefined && field.facetRanges.length > 0;
1315
1464
  }
1316
1465
  /**
@@ -1388,7 +1537,7 @@ export function unixSecondsToIso(seconds) {
1388
1537
  * The facet is the one member that depends on more than the declaration: a
1389
1538
  * facetable reference whose target declares a {@link FacetKeys facet policy}
1390
1539
  * facets a `${name}_facet` companion rather than itself, and only the `schema`
1391
- * can resolve that target ({@link inheritedFacetKeys}). Without one the field
1540
+ * can resolve that target ({@link inheritedFacetPolicies}). Without one the field
1392
1541
  * facets on its own name – the same reading the projection makes without a
1393
1542
  * schema, where it cannot re-key a reference either.
1394
1543
  */
@@ -1414,7 +1563,7 @@ export function physicalFields(field, schema) {
1414
1563
  // reference that names an `identity`, and a `local` lookup, whose own id is
1415
1564
  // otherwise a level further in than an engine can weld a condition to.
1416
1565
  const nestsAnObject = identityFieldOf(field, schema) !== undefined ||
1417
- (localLookupTypeOf(field, schema) !== undefined &&
1566
+ (localLookupTargetsOf(field, schema).length > 0 &&
1418
1567
  field.filterable === true);
1419
1568
  const identity = nestsAnObject ? identityFieldName(field.name) : undefined;
1420
1569
  const filtered = identity ?? field.name;
@@ -1423,16 +1572,20 @@ export function physicalFields(field, schema) {
1423
1572
  sort: [],
1424
1573
  facet: field.facetable !== true
1425
1574
  ? undefined
1426
- : inheritedFacetKeys(field, schema) === undefined
1575
+ : inheritedFacetPolicies(field, schema).size === 0
1427
1576
  ? filtered
1428
1577
  : `${filtered}_facet`,
1429
1578
  identity,
1430
1579
  };
1431
1580
  }
1432
1581
  /**
1433
- * The {@link FacetKeys facet policy} a field inherits: the `facetKeys` of the
1434
- * Root Type it names ({@link labelSourceNameOf}), when the field is a facetable
1435
- * reference and the schema resolves that type. The boundary is *naming the
1582
+ * The {@link FacetKeys facet policies} a field inherits, keyed by the `name` of
1583
+ * the Root Type declaring each: the `facetKeys` of every type the field names
1584
+ * ({@link labelSourceNamesOf}) that declares one, when the field is a facetable
1585
+ * reference and the schema resolves those types. Empty where nothing is
1586
+ * inherited. With several targets, an id is admitted to a bucket by the policy
1587
+ * of the target it belongs to, and unconditionally where that target declares
1588
+ * none. The boundary is *naming the
1436
1589
  * target* – the same line along which a reference is re-keyed and a join is
1437
1590
  * drawn – so a reference that names no type inherits nothing, whatever type
1438
1591
  * its values happen to point at; and a `derive`d reference, which produces its
@@ -1448,16 +1601,19 @@ export function physicalFields(field, schema) {
1448
1601
  * rather than declaring it twice is what keeps *which of a type’s ids deserve
1449
1602
  * a bucket* a fact about the type.
1450
1603
  */
1451
- export function inheritedFacetKeys(field, schema) {
1604
+ export function inheritedFacetPolicies(field, schema) {
1605
+ const policies = new Map();
1452
1606
  if (schema === undefined ||
1453
1607
  field.kind !== 'reference' ||
1454
1608
  field.facetable !== true ||
1455
1609
  field.derive !== undefined) {
1456
- return undefined;
1610
+ return policies;
1457
1611
  }
1458
1612
  const identity = identityFieldOf(field, schema);
1459
- const targetName = labelSourceNameOf(identity ?? field);
1460
- return targetName === undefined
1461
- ? undefined
1462
- : rootTypeNamed(schema, targetName)?.facetKeys;
1613
+ for (const target of referencedTargetsOf(identity ?? field, schema)) {
1614
+ if (target.facetKeys !== undefined) {
1615
+ policies.set(target.name, target.facetKeys);
1616
+ }
1617
+ }
1618
+ return policies;
1463
1619
  }
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAoBhD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAuRN"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAuBhD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAiUN"}
package/dist/testing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { filterOn, filterOperatorFor, } from './query.js';
3
- import { facetableFields, filterableFields, ID_FIELD, inheritedFacetKeys, nestedReferenceType, outputFields, } from './schema.js';
3
+ import { facetableFields, isRangeFacet, filterableFields, ID_FIELD, identityFieldOf, inheritedFacetPolicies, referencedTargetsOf, nestedReferenceType, outputFields, } from './schema.js';
4
4
  /**
5
5
  * The executable {@link SearchEngine} port contract (import from
6
6
  * `@lde/search/testing`): a vitest suite every engine adapter runs against a
@@ -136,8 +136,8 @@ export function describeSearchEngineContract(name, engine) {
136
136
  const booleanFacets = new Set(facetableFields(searchType)
137
137
  .filter((field) => field.kind === 'boolean')
138
138
  .map((field) => field.name));
139
- for (const [name, buckets] of Object.entries(outcome.facets)) {
140
- for (const bucket of buckets ?? []) {
139
+ for (const [name, facet] of Object.entries(outcome.facets)) {
140
+ for (const bucket of facet?.buckets ?? []) {
141
141
  expect(typeof bucket.value).toBe('string');
142
142
  expect(typeof bucket.count).toBe('number');
143
143
  // A boolean facet MUST carry `is`: a surface types it non-null
@@ -151,6 +151,34 @@ export function describeSearchEngineContract(name, engine) {
151
151
  }
152
152
  }
153
153
  });
154
+ it('reports a range facet’s extent, and no other facet’s', async () => {
155
+ // A slider is drawn from these bounds.
156
+ for (const searchType of types()) {
157
+ const fields = facetableFields(searchType);
158
+ const [outcome] = await engine().searchFacets(searchType, [
159
+ {
160
+ ...browse(searchType),
161
+ limit: 0,
162
+ facets: fields.map((field) => field.name),
163
+ },
164
+ ]);
165
+ if (outcome === undefined || 'error' in outcome) {
166
+ continue; // The batch test above fails on this already.
167
+ }
168
+ for (const field of fields) {
169
+ const facet = outcome.facets[field.name];
170
+ if (!isRangeFacet(field)) {
171
+ expect(facet?.extent).toBeUndefined();
172
+ continue;
173
+ }
174
+ const extent = facet?.extent;
175
+ if (extent !== undefined) {
176
+ expect(typeof extent.min).toBe('number');
177
+ expect(extent.min <= extent.max).toBe(true);
178
+ }
179
+ }
180
+ }
181
+ });
154
182
  it('resolves an empty searchFacets batch to an empty list', async () => {
155
183
  for (const searchType of types()) {
156
184
  await expect(engine().searchFacets(searchType, [])).resolves.toEqual([]);
@@ -212,8 +240,16 @@ export function describeSearchEngineContract(name, engine) {
212
240
  // second assertion bites.
213
241
  for (const searchType of types()) {
214
242
  for (const field of facetableFields(searchType)) {
215
- const policy = inheritedFacetKeys(field, engine().schema);
216
- if (policy === undefined) {
243
+ // A bucket keyed on an id of one target is admitted by that
244
+ // target’s policy, and a target declaring none admits every one of
245
+ // its ids. Which target a bucket belongs to is not visible here, so
246
+ // the contract is checked only where every target constrains: then
247
+ // a bucket failing every policy belongs to no admitting target.
248
+ const policies = [
249
+ ...inheritedFacetPolicies(field, engine().schema).values(),
250
+ ];
251
+ const targets = referencedTargetsOf(identityFieldOf(field, engine().schema) ?? field, engine().schema);
252
+ if (policies.length === 0 || policies.length < targets.length) {
217
253
  continue;
218
254
  }
219
255
  const result = await engine().search(searchType, {
@@ -221,9 +257,9 @@ export function describeSearchEngineContract(name, engine) {
221
257
  limit: 20,
222
258
  facets: [field.name],
223
259
  });
224
- const buckets = result.facets[field.name] ?? [];
260
+ const buckets = result.facets[field.name]?.buckets ?? [];
225
261
  for (const bucket of buckets) {
226
- expect(policy.only(bucket.value)).toBe(true);
262
+ expect(policies.some((policy) => policy.only(bucket.value))).toBe(true);
227
263
  expect(bucket.label).toBeDefined();
228
264
  }
229
265
  if (field.output !== true || field.filterable !== true) {
@@ -231,7 +267,7 @@ export function describeSearchEngineContract(name, engine) {
231
267
  }
232
268
  const excluded = result.hits
233
269
  .flatMap((hit) => referenceIds(hit.document[field.name]))
234
- .find((id) => !policy.only(id));
270
+ .find((id) => !policies.some((policy) => policy.only(id)));
235
271
  if (excluded === undefined) {
236
272
  continue;
237
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
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.7.12"
55
+ "n3": "^2.13.12"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vitest": ">=4.0.0"