@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/adapter.d.ts +1 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +1 -1
- package/dist/engine.d.ts +46 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/join-graph.js +4 -2
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +139 -42
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +33 -16
- package/dist/schema.d.ts +111 -46
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +239 -83
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +44 -8
- package/package.json +2 -2
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
266
|
-
*
|
|
267
|
-
function
|
|
268
|
-
if (
|
|
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
|
-
|
|
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
|
|
616
|
-
* `target`, an `idOnly`’s
|
|
617
|
-
* when it resolves none. One
|
|
618
|
-
*
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
659
|
-
? field.ref.target
|
|
660
|
-
: labelSource;
|
|
661
|
-
if (sourceName === undefined) {
|
|
686
|
+
if (field.kind !== 'reference') {
|
|
662
687
|
continue;
|
|
663
688
|
}
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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 = [
|
|
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' &&
|
|
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
|
-
|
|
843
|
-
|
|
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
|
|
1044
|
-
* label
|
|
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
|
|
1048
|
-
* same reason: the companion holds that field’s ids, so the
|
|
1049
|
-
* those ids
|
|
1050
|
-
* cannot inherit a policy from one type and its labels from another –
|
|
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
|
|
1192
|
+
export function labelTargetNamesOf(field, schema) {
|
|
1054
1193
|
if (field.kind !== 'reference') {
|
|
1055
|
-
return
|
|
1194
|
+
return [];
|
|
1056
1195
|
}
|
|
1057
|
-
return
|
|
1196
|
+
return labelSourceNamesOf(identityFieldOf(field, schema) ?? field);
|
|
1058
1197
|
}
|
|
1059
1198
|
/**
|
|
1060
|
-
* The Root
|
|
1061
|
-
* referents through, or
|
|
1062
|
-
* stores nested documents shaped by the **target’s own
|
|
1063
|
-
* needs no reference type of its own, and reconstructs
|
|
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
|
|
1067
|
-
if (
|
|
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
|
|
1211
|
+
return [];
|
|
1072
1212
|
}
|
|
1073
|
-
return
|
|
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
|
|
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
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
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
|
|
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
|
-
(
|
|
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
|
-
:
|
|
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
|
|
1434
|
-
* Root Type
|
|
1435
|
-
*
|
|
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
|
|
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
|
|
1610
|
+
return policies;
|
|
1457
1611
|
}
|
|
1458
1612
|
const identity = identityFieldOf(field, schema);
|
|
1459
|
-
const
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
}
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -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;
|
|
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,
|
|
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,
|
|
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
|
-
|
|
216
|
-
|
|
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.
|
|
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.
|
|
55
|
+
"n3": "^2.13.12"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"vitest": ">=4.0.0"
|