@lde/search 0.18.3 → 0.20.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 +3 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/join-graph.d.ts +63 -0
- package/dist/join-graph.d.ts.map +1 -0
- package/dist/join-graph.js +192 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +28 -15
- package/dist/query.d.ts +58 -11
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +51 -11
- package/dist/schema.d.ts +109 -26
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +76 -9
- package/package.json +1 -1
package/dist/adapter.d.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* deployment author declaring a schema and running searches needs none of
|
|
7
7
|
* this; the main entry point carries that (small) authoring surface.
|
|
8
8
|
*/
|
|
9
|
-
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
9
|
+
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isAbsoluteIri, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
10
10
|
export type { PhysicalFields } from './schema.js';
|
|
11
11
|
export { physicalNameTokens } from './physical-name.js';
|
|
12
|
+
export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
|
|
13
|
+
export type { JoinGraph } from './join-graph.js';
|
|
12
14
|
export { filterOperatorFor, filterOperator, filterOn, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
|
13
15
|
export type { FilterOperator, QueryIssue } from './query.js';
|
|
14
16
|
//# sourceMappingURL=adapter.d.ts.map
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,MAAM,EACN,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,QAAQ,EACR,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,MAAM,EACN,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,QAAQ,EACR,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/adapter.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* deployment author declaring a schema and running searches needs none of
|
|
7
7
|
* this; the main entry point carries that (small) authoring surface.
|
|
8
8
|
*/
|
|
9
|
-
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
9
|
+
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isAbsoluteIri, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
10
10
|
export { physicalNameTokens } from './physical-name.js';
|
|
11
|
+
export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
|
|
11
12
|
export { filterOperatorFor, filterOperator, filterOn, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ export { projectRoots } from './project.js';
|
|
|
2
2
|
export type { ProjectedNode, ProjectionContext, SearchDocument, } from './project.js';
|
|
3
3
|
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, AND_KEY, OR_KEY, } from './schema.js';
|
|
4
4
|
export type { FieldKind, SearchField, SearchFieldBase, TextField, KeywordField, ReferenceField, NumericField, BooleanField, SearchType, SearchTypeBase, RootType, ReferenceType, RootTypeOf, SearchTypeIssue, SearchSchema, FacetRange, ProjectionValue, } from './schema.js';
|
|
5
|
+
export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
|
|
6
|
+
export type { JoinGraph } from './join-graph.js';
|
|
5
7
|
export { filterOn } from './query.js';
|
|
6
|
-
export type { SearchQuery, Filter, Criterion, Sort } from './query.js';
|
|
8
|
+
export type { SearchQuery, Filter, Criterion, CriterionBase, Sort, } from './query.js';
|
|
7
9
|
export type { SearchEngine, SearchResult, SearchHit, ResultDocument, SearchValue, LocalizedValue, Reference, NestedDocument, FacetBucket, FacetMap, FacetsOutcome, FacetFieldsOf, OutputFieldsOf, } from './engine.js';
|
|
8
10
|
export type { FramedNode } from './frame-by-type.js';
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,GACf,MAAM,cAAc,CAAC;AAKtB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,MAAM,GACP,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,UAAU,EACV,eAAe,EACf,YAAY,EACZ,UAAU,EACV,eAAe,GAChB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,GACf,MAAM,cAAc,CAAC;AAKtB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,MAAM,GACP,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,UAAU,EACV,eAAe,EACf,YAAY,EACZ,UAAU,EACV,eAAe,GAChB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAOjD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,WAAW,EACX,MAAM,EACN,SAAS,EACT,aAAa,EACb,IAAI,GACL,MAAM,YAAY,CAAC;AAIpB,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,cAAc,EACd,WAAW,EACX,cAAc,EACd,SAAS,EACT,cAAc,EACd,WAAW,EACX,QAAQ,EACR,aAAa,EACb,aAAa,EACb,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,10 @@ export { projectRoots } from './project.js';
|
|
|
11
11
|
// schema, query semantics and the GraphQL surface – a discriminated union by
|
|
12
12
|
// `kind`, validated again at runtime when the schema is built.
|
|
13
13
|
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, AND_KEY, OR_KEY, } from './schema.js';
|
|
14
|
+
// The declared join edges between root types: which types a query can filter
|
|
15
|
+
// across, and which collections must be rebuilt together. Built eagerly by
|
|
16
|
+
// `searchSchema`, so declaring a schema is what validates the joins.
|
|
17
|
+
export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
|
|
14
18
|
// Engine- and protocol-neutral query IR (what a `queryDefaults` policy or an
|
|
15
19
|
// in-process caller writes).
|
|
16
20
|
// `filterOn` builds the ordinary one-criterion clause – the shape a consumer
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type RootType, type SearchSchema, type SearchType } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* The deepest `on` path a query may state: `dataset → publisher → country` is
|
|
4
|
+
* three hops. Fixed rather than configurable, and enforced in the query IR
|
|
5
|
+
* ({@link validateQuery}) rather than in an adapter, so a later REST surface
|
|
6
|
+
* inherits the same ceiling. Typesense imposes no limit of its own, and the
|
|
7
|
+
* GraphQL max-depth guard counts selection sets rather than input nesting, so
|
|
8
|
+
* without this a `where` could nest a join arbitrarily deep and each hop is an
|
|
9
|
+
* extra collection scan.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_JOIN_DEPTH = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The declared join edges of a {@link SearchSchema}: which Root Types are
|
|
14
|
+
* reachable from which through {@link ReferenceField.joinable} references, and
|
|
15
|
+
* which of them must be rebuilt together.
|
|
16
|
+
*
|
|
17
|
+
* Two members, because a consumer needs exactly two answers. A **query**
|
|
18
|
+
* compiler asks *what does this path of field names reach* ({@link
|
|
19
|
+
* JoinGraph.resolve}); a **writer** asks *which collections come into existence
|
|
20
|
+
* together* ({@link JoinGraph.components}). Everything else – deriving the
|
|
21
|
+
* edges from `joinable` + `labelSource`, the one-edge-per-target rule, cycle
|
|
22
|
+
* rejection, the depth cap, and the asymmetry between undirected membership and
|
|
23
|
+
* directed creation order – is hidden here, so no consumer restates it.
|
|
24
|
+
*
|
|
25
|
+
* `resolve` returns a {@link RootType}, never a collection name: how a type is
|
|
26
|
+
* named in an engine is engine- and deployment-specific and stays in the
|
|
27
|
+
* adapter.
|
|
28
|
+
*/
|
|
29
|
+
export interface JoinGraph {
|
|
30
|
+
/**
|
|
31
|
+
* The **join components**: groups of Root Types that reference one another,
|
|
32
|
+
* directly or transitively, and therefore share a rebuild
|
|
33
|
+
* ([ADR 19](../../docs/decisions/0019-filter-across-collections-through-declared-joins.md)).
|
|
34
|
+
* Every Root Type appears in exactly one; a type with no joinable edge is a
|
|
35
|
+
* singleton and keeps per-collection isolation exactly as before.
|
|
36
|
+
*
|
|
37
|
+
* Membership is the **undirected** connected component – a referrer and its
|
|
38
|
+
* referent must rebuild together whichever way the edge points – while the
|
|
39
|
+
* order **within** a component is the **directed** topological sort,
|
|
40
|
+
* referenced first. That asymmetry is load-bearing: an engine cannot create a
|
|
41
|
+
* collection whose reference names a collection that does not exist yet, so
|
|
42
|
+
* the referent’s collection must be opened first.
|
|
43
|
+
*/
|
|
44
|
+
readonly components: readonly (readonly RootType[])[];
|
|
45
|
+
/**
|
|
46
|
+
* The Root Type a path of joinable field names reaches from `from`, or
|
|
47
|
+
* `undefined` when the path does not resolve – an unknown field name, a
|
|
48
|
+
* reference that is not `joinable`, or a path deeper than
|
|
49
|
+
* {@link MAX_JOIN_DEPTH}. An empty path resolves to `from` itself only when
|
|
50
|
+
* `from` is a Root Type; a Reference Type has no collection to join to.
|
|
51
|
+
*/
|
|
52
|
+
resolve(from: SearchType, path: readonly string[]): RootType | undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The join graph of a {@link SearchSchema}, built once per schema and cached:
|
|
56
|
+
* {@link searchSchema} builds it eagerly, so a schema whose joins do not hold
|
|
57
|
+
* up – two joinable fields at one target, a cycle – fails at startup rather
|
|
58
|
+
* than on the first query or the first rebuild.
|
|
59
|
+
*
|
|
60
|
+
* Calling it again for the same schema returns the same graph.
|
|
61
|
+
*/
|
|
62
|
+
export declare function joinGraph(schema: SearchSchema): JoinGraph;
|
|
63
|
+
//# sourceMappingURL=join-graph.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"join-graph.d.ts","sourceRoot":"","sources":["../src/join-graph.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC,SAAS,QAAQ,EAAE,CAAC,EAAE,CAAC;IACtD;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC1E;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,CAQzD"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { referenceFields, } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* The deepest `on` path a query may state: `dataset → publisher → country` is
|
|
4
|
+
* three hops. Fixed rather than configurable, and enforced in the query IR
|
|
5
|
+
* ({@link validateQuery}) rather than in an adapter, so a later REST surface
|
|
6
|
+
* inherits the same ceiling. Typesense imposes no limit of its own, and the
|
|
7
|
+
* GraphQL max-depth guard counts selection sets rather than input nesting, so
|
|
8
|
+
* without this a `where` could nest a join arbitrarily deep and each hop is an
|
|
9
|
+
* extra collection scan.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_JOIN_DEPTH = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The join graph of a {@link SearchSchema}, built once per schema and cached:
|
|
14
|
+
* {@link searchSchema} builds it eagerly, so a schema whose joins do not hold
|
|
15
|
+
* up – two joinable fields at one target, a cycle – fails at startup rather
|
|
16
|
+
* than on the first query or the first rebuild.
|
|
17
|
+
*
|
|
18
|
+
* Calling it again for the same schema returns the same graph.
|
|
19
|
+
*/
|
|
20
|
+
export function joinGraph(schema) {
|
|
21
|
+
const cached = graphsBySchema.get(schema);
|
|
22
|
+
if (cached !== undefined) {
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
const built = buildJoinGraph(schema);
|
|
26
|
+
graphsBySchema.set(schema, built);
|
|
27
|
+
return built;
|
|
28
|
+
}
|
|
29
|
+
/** One graph per schema, held beside it rather than on it, so a
|
|
30
|
+
* {@link SearchSchema} stays a plain branded `Map`. */
|
|
31
|
+
const graphsBySchema = new WeakMap();
|
|
32
|
+
/** The edges leaving one Root Type, keyed by field name. Total by
|
|
33
|
+
* construction: the index is built with an entry for EVERY Root Type, an empty
|
|
34
|
+
* map where a type declares none, so no caller needs a fallback it can never
|
|
35
|
+
* take. */
|
|
36
|
+
function edgesOf(edges, searchType) {
|
|
37
|
+
return edges.get(searchType.class);
|
|
38
|
+
}
|
|
39
|
+
/** The edges leaving one Root Type, in declaration order. */
|
|
40
|
+
function edgesFrom(edges, searchType) {
|
|
41
|
+
return edgesOf(edges, searchType).values();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Derive, validate and index the edges. Throws – rather than reporting issues –
|
|
45
|
+
* because it is called from {@link searchSchema}, whose contract is that an
|
|
46
|
+
* invalid declaration fails at startup.
|
|
47
|
+
*/
|
|
48
|
+
function buildJoinGraph(schema) {
|
|
49
|
+
const rootTypes = [...schema.values()];
|
|
50
|
+
const byName = new Map(rootTypes.map((type) => [type.name, type]));
|
|
51
|
+
const edges = new Map();
|
|
52
|
+
for (const searchType of rootTypes) {
|
|
53
|
+
edges.set(searchType.class, declaredEdges(searchType, byName));
|
|
54
|
+
}
|
|
55
|
+
assertAcyclic(rootTypes, edges);
|
|
56
|
+
return {
|
|
57
|
+
components: componentsOf(rootTypes, edges),
|
|
58
|
+
resolve(from, path) {
|
|
59
|
+
if (path.length > MAX_JOIN_DEPTH) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
// A Reference Type is never indexed, so it has no collection to join
|
|
63
|
+
// from; only a member of this schema’s class map can start a path.
|
|
64
|
+
const start = from.class === undefined ? undefined : schema.get(from.class);
|
|
65
|
+
if (start === undefined || start !== from) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
let current = start;
|
|
69
|
+
for (const name of path) {
|
|
70
|
+
const edge = edgesOf(edges, current).get(name);
|
|
71
|
+
if (edge === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
current = edge.target;
|
|
75
|
+
}
|
|
76
|
+
return current;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The joinable edges one Root Type declares, keyed by field name.
|
|
82
|
+
*
|
|
83
|
+
* Enforces the **one edge per (type, target)** rule here rather than in a
|
|
84
|
+
* consumer: a Typesense join addresses the referent by *collection*, not by
|
|
85
|
+
* field, so a second reference to the same collection is accepted, indexed and
|
|
86
|
+
* then unreachable – the engine resolves every join through the first field
|
|
87
|
+
* that matched and silently ignores the rest
|
|
88
|
+
* ({@link https://github.com/typesense/typesense/issues/3021}). `publisher` and
|
|
89
|
+
* `creator` both resolving to `Organization` is the ordinary case, so it must
|
|
90
|
+
* be a declaration error naming both fields rather than a surprise at query
|
|
91
|
+
* time.
|
|
92
|
+
*/
|
|
93
|
+
function declaredEdges(searchType, byName) {
|
|
94
|
+
const edges = new Map();
|
|
95
|
+
const claimedBy = new Map();
|
|
96
|
+
for (const field of referenceFields(searchType)) {
|
|
97
|
+
if (field.joinable !== true) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Both lookups always hit: `validateSearchType` rejects `joinable` without
|
|
101
|
+
// a `labelSource`, and a label source is always an indexed Root Type – it
|
|
102
|
+
// must declare a `searchable` `label` field, which a Reference Type cannot
|
|
103
|
+
// carry (a nested field is `output` only). So a joinable edge always has a
|
|
104
|
+
// collection at the far end without a rule of its own.
|
|
105
|
+
const target = byName.get(field.labelSource);
|
|
106
|
+
const claimed = claimedBy.get(target.name);
|
|
107
|
+
if (claimed !== undefined) {
|
|
108
|
+
throw new Error(`Search type “${searchType.name}” declares two joinable references to “${target.name}” (“${claimed}” and “${field.name}”); an engine addresses a join by collection, not by field, so only one reference per target can be joinable. Drop “joinable” from one of them – it keeps its labels, facets and id filtering.`);
|
|
109
|
+
}
|
|
110
|
+
claimedBy.set(target.name, field.name);
|
|
111
|
+
edges.set(field.name, { field, target });
|
|
112
|
+
}
|
|
113
|
+
return edges;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reject a cycle in the **directed** join graph. A cycle would make the
|
|
117
|
+
* topological creation order below undefinable – there is no collection to
|
|
118
|
+
* create first – and, with the component as the unit of rebuild, no order in
|
|
119
|
+
* which a component’s collections could come into existence at all.
|
|
120
|
+
*/
|
|
121
|
+
function assertAcyclic(rootTypes, edges) {
|
|
122
|
+
const done = new Set();
|
|
123
|
+
const visit = (searchType, onPath) => {
|
|
124
|
+
if (onPath.some((ancestor) => ancestor.class === searchType.class)) {
|
|
125
|
+
const cycle = [...onPath, searchType]
|
|
126
|
+
.map((type) => `“${type.name}”`)
|
|
127
|
+
.join(' → ');
|
|
128
|
+
throw new Error(`Join cycle ${cycle}; the joinable references between search types must be acyclic, so a component’s collections have an order to be created in.`);
|
|
129
|
+
}
|
|
130
|
+
if (done.has(searchType.class)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const extended = [...onPath, searchType];
|
|
134
|
+
for (const edge of edgesFrom(edges, searchType)) {
|
|
135
|
+
visit(edge.target, extended);
|
|
136
|
+
}
|
|
137
|
+
done.add(searchType.class);
|
|
138
|
+
};
|
|
139
|
+
for (const searchType of rootTypes) {
|
|
140
|
+
visit(searchType, []);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Partition the Root Types into join components, each topologically ordered.
|
|
145
|
+
*
|
|
146
|
+
* Membership is the undirected connected component (union-find over the
|
|
147
|
+
* edges); the order within one is a depth-first topological sort over the
|
|
148
|
+
* directed edges, so a type is emitted only after everything it references.
|
|
149
|
+
* Components come out in declaration order of their first member, so a schema’s
|
|
150
|
+
* rebuild order is stable across runs.
|
|
151
|
+
*/
|
|
152
|
+
function componentsOf(rootTypes, edges) {
|
|
153
|
+
const parent = new Map(rootTypes.map((type) => [type.class, type.class]));
|
|
154
|
+
const find = (iri) => {
|
|
155
|
+
let root = iri;
|
|
156
|
+
while (parent.get(root) !== root) {
|
|
157
|
+
root = parent.get(root);
|
|
158
|
+
}
|
|
159
|
+
return root;
|
|
160
|
+
};
|
|
161
|
+
for (const searchType of rootTypes) {
|
|
162
|
+
for (const edge of edgesFrom(edges, searchType)) {
|
|
163
|
+
parent.set(find(searchType.class), find(edge.target.class));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const members = new Map();
|
|
167
|
+
for (const searchType of rootTypes) {
|
|
168
|
+
const root = find(searchType.class);
|
|
169
|
+
members.set(root, [...(members.get(root) ?? []), searchType]);
|
|
170
|
+
}
|
|
171
|
+
return [...members.values()].map((component) => topologicallyOrdered(component, edges));
|
|
172
|
+
}
|
|
173
|
+
/** One component’s members, everything a type references before the type
|
|
174
|
+
* itself. Terminates because {@link assertAcyclic} ran first. */
|
|
175
|
+
function topologicallyOrdered(component, edges) {
|
|
176
|
+
const ordered = [];
|
|
177
|
+
const emitted = new Set();
|
|
178
|
+
const visit = (searchType) => {
|
|
179
|
+
if (emitted.has(searchType.class)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
emitted.add(searchType.class);
|
|
183
|
+
for (const edge of edgesFrom(edges, searchType)) {
|
|
184
|
+
visit(edge.target);
|
|
185
|
+
}
|
|
186
|
+
ordered.push(searchType);
|
|
187
|
+
};
|
|
188
|
+
for (const searchType of component) {
|
|
189
|
+
visit(searchType);
|
|
190
|
+
}
|
|
191
|
+
return ordered;
|
|
192
|
+
}
|
package/dist/project.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,
|
|
1
|
+
{"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAYL,KAAK,eAAe,EAEpB,KAAK,QAAQ,EAEb,KAAK,YAAY,EACjB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEpD,+DAA+D;AAC/D,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAAC;AAE5D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAkB,SAAQ,OAAO,CAChD,QAAQ,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAC1C;IACC,4CAA4C;IAC5C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,EACtB,MAAM,CAAC,EAAE,YAAY,EACrB,OAAO,GAAE,iBAAyC,GACjD,cAAc,CAgBhB;AA2ED;;;;;;;;;;;;GAYG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,EACrB,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,QAAQ,EACpB,OAAO,GAAE,iBAAyC,GACjD,aAAa,CAAC,cAAc,CAAC,CAW/B"}
|
package/dist/project.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fold } from '@lde/text-normalization';
|
|
2
2
|
import { buildSubjectIndex, frameSubjects, } from './frame-by-type.js';
|
|
3
|
-
import { assertTypeInSchema, displayFieldName, inlineFramingDepth, irAlias, isInternalField, isInlineReference, isoToUnixSeconds, physicalFields, referenceTypeNamed, } from './schema.js';
|
|
3
|
+
import { assertTypeInSchema, displayFieldName, inlineFramingDepth, irAlias, isAbsoluteIri, isInternalField, isInlineReference, isoToUnixSeconds, physicalFields, referenceTypeNamed, } from './schema.js';
|
|
4
4
|
/** The context a projection runs with when its caller supplied none. */
|
|
5
5
|
const NO_PROJECTION_CONTEXT = {};
|
|
6
6
|
/**
|
|
@@ -19,7 +19,7 @@ const NO_PROJECTION_CONTEXT = {};
|
|
|
19
19
|
*/
|
|
20
20
|
export function projectDocument(node, searchType, schema, context = NO_PROJECTION_CONTEXT) {
|
|
21
21
|
if (documentKey(node) === undefined) {
|
|
22
|
-
throw new Error(`Cannot project a “${searchType.name}” node
|
|
22
|
+
throw new Error(`Cannot project a “${searchType.name}” node whose @id is not an absolute IRI (got ${JSON.stringify(node['@id']) ?? 'none'}; a blank node label or a relative reference is not one): every search document needs a stable key, and an empty one would collide with other keyless nodes.`);
|
|
23
23
|
}
|
|
24
24
|
// The guard above is what makes this a SearchDocument: projectFields sets `id`
|
|
25
25
|
// for exactly the nodes documentKey resolves.
|
|
@@ -76,15 +76,17 @@ function projectFields(node, searchType, schema, context) {
|
|
|
76
76
|
return document;
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* The document key a framed node carries, if any
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
79
|
+
* The document key a framed node carries, if any – an {@link isAbsoluteIri
|
|
80
|
+
* absolute IRI}, the same rule {@link iriString} applies to a referent, so a
|
|
81
|
+
* root and a reference cannot disagree about what counts as identity. A blank
|
|
82
|
+
* node label (`_:b0`) is not one: framing mints it per call, so it recurs across
|
|
83
|
+
* documents and can change when unrelated triples do. A node bearing one is
|
|
84
|
+
* therefore projected as if framing had pruned its `@id` – which it does
|
|
85
|
+
* whenever the label occurs only once in the framing results.
|
|
84
86
|
*/
|
|
85
87
|
function documentKey(node) {
|
|
86
88
|
const id = node['@id'];
|
|
87
|
-
return typeof id === 'string' &&
|
|
89
|
+
return typeof id === 'string' && isAbsoluteIri(id) ? id : undefined;
|
|
88
90
|
}
|
|
89
91
|
/**
|
|
90
92
|
* Project a single type over a known set of `roots` – the per-type, roots-given
|
|
@@ -362,14 +364,25 @@ function literalString(value) {
|
|
|
362
364
|
}
|
|
363
365
|
return undefined;
|
|
364
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* The IRI a reference value carries, or `undefined` when it carries none. A
|
|
369
|
+
* value may arrive as a bare string – `schema:sameAs`, `contentUrl`,
|
|
370
|
+
* `thumbnailUrl` and `landingPage` all range on `schema:URL`, which a source may
|
|
371
|
+
* emit as a literal rather than a node – or as a node object.
|
|
372
|
+
*
|
|
373
|
+
* **A referent with no IRI yields nothing**, the same rule {@link documentKey}
|
|
374
|
+
* applies to a root and for the same reason: what a `labelOnly`/`idOnly`
|
|
375
|
+
* reference stores is a selection key, and a blank node label is not one.
|
|
376
|
+
* Framing mints it per call, so it recurs across documents and changes when
|
|
377
|
+
* unrelated triples do – indexing it would key a facet bucket on a value that
|
|
378
|
+
* neither groups what is equal nor separates what is not. An **inline**
|
|
379
|
+
* reference is untouched by this: it carries the referent’s fields rather than
|
|
380
|
+
* its identity ({@link applyInlineReference}), so a blank-node referent nests
|
|
381
|
+
* exactly as before.
|
|
382
|
+
*/
|
|
365
383
|
function iriString(value) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
if (isObject(value) && typeof value['@id'] === 'string') {
|
|
370
|
-
return value['@id'];
|
|
371
|
-
}
|
|
372
|
-
return undefined;
|
|
384
|
+
const iri = isObject(value) ? value['@id'] : value;
|
|
385
|
+
return typeof iri === 'string' && isAbsoluteIri(iri) ? iri : undefined;
|
|
373
386
|
}
|
|
374
387
|
function toInteger(literal) {
|
|
375
388
|
return literal === undefined ? undefined : Math.trunc(Number(literal));
|
package/dist/query.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SearchType } from './schema.js';
|
|
2
|
+
import { type JoinGraph } from './join-graph.js';
|
|
2
3
|
/**
|
|
3
4
|
* The engine- and protocol-neutral query IR. Every API surface compiles its
|
|
4
5
|
* input into this; every engine adapter compiles it into an engine query. One
|
|
@@ -20,6 +21,35 @@ export interface SearchQuery {
|
|
|
20
21
|
/** Selects the per-locale fields to query/sort on (from `Accept-Language`). */
|
|
21
22
|
readonly locale: string;
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* What every {@link Criterion} carries, whatever its operator: the field it
|
|
26
|
+
* constrains, and – for a criterion that constrains a *joined* document rather
|
|
27
|
+
* than this one – the path of joinable references to walk to reach it.
|
|
28
|
+
*/
|
|
29
|
+
export interface CriterionBase {
|
|
30
|
+
/** The logical field name, on the type the {@link CriterionBase.on} path
|
|
31
|
+
* reaches (or on the searched type when there is none). */
|
|
32
|
+
readonly field: string;
|
|
33
|
+
/**
|
|
34
|
+
* A **join path**: the names of the {@link ReferenceField.joinable}
|
|
35
|
+
* references to follow, in order, to the type this criterion constrains.
|
|
36
|
+
* `['dataset', 'publisher']` on a `CreativeWork` criterion means *the
|
|
37
|
+
* publisher of the dataset this work belongs to*. Omitted (or empty) for the
|
|
38
|
+
* ordinary case: the criterion constrains the searched type itself.
|
|
39
|
+
*
|
|
40
|
+
* A path, deliberately, and not boolean structure – so the criterion stays an
|
|
41
|
+
* **atom** and `where` stays the flat conjunction of disjunctions
|
|
42
|
+
* [ADR 18](../../docs/decisions/0018-filter-across-several-fields-with-one-clause.md)
|
|
43
|
+
* made it. `on` sits on the criterion rather than on the {@link Filter} for
|
|
44
|
+
* the same reason a criterion carries its own operator: a `Filter`-level
|
|
45
|
+
* `on` would scope the whole disjunction and make
|
|
46
|
+
* `“published by X, or titled Y”` inexpressible.
|
|
47
|
+
*
|
|
48
|
+
* Capped at {@link MAX_JOIN_DEPTH} hops and resolved by
|
|
49
|
+
* {@link validateQuery} against the schema’s {@link JoinGraph}.
|
|
50
|
+
*/
|
|
51
|
+
readonly on?: readonly string[];
|
|
52
|
+
}
|
|
23
53
|
/**
|
|
24
54
|
* One criterion on one field. The operator is fixed by that field’s
|
|
25
55
|
* {@link FieldKind} ({@link filterOperatorFor}): keyword/reference use `in` (OR
|
|
@@ -29,21 +59,20 @@ export interface SearchQuery {
|
|
|
29
59
|
* Criteria are the atoms a {@link Filter} is built from, so each carries its own
|
|
30
60
|
* field AND its own operator: one clause may range over a `reference` and a
|
|
31
61
|
* `keyword` field at once, and each field is constrained the way its kind
|
|
32
|
-
* allows.
|
|
62
|
+
* allows. Each equally carries its own {@link CriterionBase.on} join path, so
|
|
63
|
+
* one clause may mix a condition on this document with one on a joined
|
|
64
|
+
* document.
|
|
33
65
|
*/
|
|
34
|
-
export type Criterion = {
|
|
35
|
-
readonly field: string;
|
|
66
|
+
export type Criterion = (CriterionBase & {
|
|
36
67
|
readonly in: readonly string[];
|
|
37
|
-
} | {
|
|
38
|
-
readonly field: string;
|
|
68
|
+
}) | (CriterionBase & {
|
|
39
69
|
readonly range: {
|
|
40
70
|
readonly min?: number | string;
|
|
41
71
|
readonly max?: number | string;
|
|
42
72
|
};
|
|
43
|
-
} | {
|
|
44
|
-
readonly field: string;
|
|
73
|
+
}) | (CriterionBase & {
|
|
45
74
|
readonly is: boolean;
|
|
46
|
-
};
|
|
75
|
+
});
|
|
47
76
|
/**
|
|
48
77
|
* One `where` clause: a disjunction, matching a document that satisfies **any**
|
|
49
78
|
* of its criteria. An ordinary single-field filter is the one-criterion case,
|
|
@@ -92,6 +121,10 @@ export declare function filterOn(criterion: Criterion): Filter;
|
|
|
92
121
|
* is a disjunction that happens to include identity, not an enumeration of
|
|
93
122
|
* wanted documents, so it stays the vacuous no-op every other empty `in` is.
|
|
94
123
|
*
|
|
124
|
+
* A joined identity clause (`{ on: […], field: 'id', in: [] }`) reads the same
|
|
125
|
+
* way one hop out: it enumerates the *referents* wanted, and no referent means
|
|
126
|
+
* no document can match through that edge.
|
|
127
|
+
*
|
|
95
128
|
* An adapter MUST answer an unsatisfiable query with an empty result rather
|
|
96
129
|
* than dispatching it.
|
|
97
130
|
*/
|
|
@@ -108,7 +141,12 @@ export declare function isUnsatisfiable(query: SearchQuery): boolean;
|
|
|
108
141
|
export interface QueryIssue {
|
|
109
142
|
readonly part: 'where' | 'facets' | 'orderBy';
|
|
110
143
|
readonly field: string;
|
|
111
|
-
readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable'
|
|
144
|
+
readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable'
|
|
145
|
+
/** The criterion’s `on` path is longer than {@link MAX_JOIN_DEPTH}. */
|
|
146
|
+
| 'join-too-deep'
|
|
147
|
+
/** The `on` path does not resolve: a name that is not a field, a reference
|
|
148
|
+
* that is not `joinable`, or no join graph to resolve it against. */
|
|
149
|
+
| 'unknown-join';
|
|
112
150
|
}
|
|
113
151
|
/**
|
|
114
152
|
* Structurally validate a query against its search type: **every criterion of
|
|
@@ -121,15 +159,24 @@ export interface QueryIssue {
|
|
|
121
159
|
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
122
160
|
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
123
161
|
*
|
|
162
|
+
* A criterion carrying an {@link CriterionBase.on} join path is checked against
|
|
163
|
+
* the type that path reaches instead: the path must be at most
|
|
164
|
+
* {@link MAX_JOIN_DEPTH} hops (`join-too-deep`) and must resolve through
|
|
165
|
+
* `joins` (`unknown-join`), and the leaf field must then be filterable on the
|
|
166
|
+
* *target* type. The cap lives here, in the IR, rather than in an adapter or a
|
|
167
|
+
* surface, so every surface inherits it. `joins` is optional because a query
|
|
168
|
+
* with no join path needs none; a joined criterion validated without one is an
|
|
169
|
+
* `unknown-join`, never a silently unchecked filter.
|
|
170
|
+
*
|
|
124
171
|
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
125
172
|
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
126
173
|
* garbage to its engine, so validation holds for every caller – including
|
|
127
174
|
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
128
175
|
*/
|
|
129
|
-
export declare function validateQuery(query: SearchQuery, searchType: SearchType): readonly QueryIssue[];
|
|
176
|
+
export declare function validateQuery(query: SearchQuery, searchType: SearchType, joins?: JoinGraph): readonly QueryIssue[];
|
|
130
177
|
/** Throw on the first structurally invalid query part ({@link validateQuery}),
|
|
131
178
|
* naming every issue. The always-on entry point for engine adapters. */
|
|
132
|
-
export declare function assertValidQuery(query: SearchQuery, searchType: SearchType): void;
|
|
179
|
+
export declare function assertValidQuery(query: SearchQuery, searchType: SearchType, joins?: JoinGraph): void;
|
|
133
180
|
/**
|
|
134
181
|
* The 1-based page an `offset` falls on – the numbered-pagination presentation
|
|
135
182
|
* of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
|
package/dist/query.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"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"}
|
package/dist/query.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fieldNamed, filterOperatorFor, ID_FIELD, } from './schema.js';
|
|
2
|
+
import { MAX_JOIN_DEPTH } from './join-graph.js';
|
|
2
3
|
// The kind→operator table lives with the field model in schema.ts (one source
|
|
3
4
|
// for the query IR, the surfaces, the adapters AND declaration validation);
|
|
4
5
|
// re-exported here so the query-IR module stays the natural import site.
|
|
@@ -32,6 +33,10 @@ export function filterOn(criterion) {
|
|
|
32
33
|
* is a disjunction that happens to include identity, not an enumeration of
|
|
33
34
|
* wanted documents, so it stays the vacuous no-op every other empty `in` is.
|
|
34
35
|
*
|
|
36
|
+
* A joined identity clause (`{ on: […], field: 'id', in: [] }`) reads the same
|
|
37
|
+
* way one hop out: it enumerates the *referents* wanted, and no referent means
|
|
38
|
+
* no document can match through that edge.
|
|
39
|
+
*
|
|
35
40
|
* An adapter MUST answer an unsatisfiable query with an empty result rather
|
|
36
41
|
* than dispatching it.
|
|
37
42
|
*/
|
|
@@ -45,6 +50,13 @@ export function isUnsatisfiable(query) {
|
|
|
45
50
|
criterion.in.length === 0);
|
|
46
51
|
});
|
|
47
52
|
}
|
|
53
|
+
/** The `field` a joined issue is reported under: the path and the leaf name
|
|
54
|
+
* together, so a message says which hop and which field, not just which
|
|
55
|
+
* field. `dataset.publisher.id` reads the way the criterion was written. */
|
|
56
|
+
function issueField(criterion) {
|
|
57
|
+
const on = criterion.on ?? [];
|
|
58
|
+
return on.length === 0 ? criterion.field : [...on, criterion.field].join('.');
|
|
59
|
+
}
|
|
48
60
|
/**
|
|
49
61
|
* Structurally validate a query against its search type: **every criterion of
|
|
50
62
|
* every `where` clause** names a declared, `filterable` field whose kind accepts
|
|
@@ -56,12 +68,21 @@ export function isUnsatisfiable(query) {
|
|
|
56
68
|
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
57
69
|
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
58
70
|
*
|
|
71
|
+
* A criterion carrying an {@link CriterionBase.on} join path is checked against
|
|
72
|
+
* the type that path reaches instead: the path must be at most
|
|
73
|
+
* {@link MAX_JOIN_DEPTH} hops (`join-too-deep`) and must resolve through
|
|
74
|
+
* `joins` (`unknown-join`), and the leaf field must then be filterable on the
|
|
75
|
+
* *target* type. The cap lives here, in the IR, rather than in an adapter or a
|
|
76
|
+
* surface, so every surface inherits it. `joins` is optional because a query
|
|
77
|
+
* with no join path needs none; a joined criterion validated without one is an
|
|
78
|
+
* `unknown-join`, never a silently unchecked filter.
|
|
79
|
+
*
|
|
59
80
|
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
60
81
|
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
61
82
|
* garbage to its engine, so validation holds for every caller – including
|
|
62
83
|
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
63
84
|
*/
|
|
64
|
-
export function validateQuery(query, searchType) {
|
|
85
|
+
export function validateQuery(query, searchType, joins) {
|
|
65
86
|
const issues = [];
|
|
66
87
|
for (const filter of query.where) {
|
|
67
88
|
// Each criterion is checked on its own, so a multi-criterion clause needs
|
|
@@ -71,6 +92,25 @@ export function validateQuery(query, searchType) {
|
|
|
71
92
|
// a `reference` membership with a `date` range without either being wrong.
|
|
72
93
|
for (const criterion of filter.or) {
|
|
73
94
|
const name = criterion.field;
|
|
95
|
+
const field = issueField(criterion);
|
|
96
|
+
// A criterion may constrain a JOINED document, and then every rule below
|
|
97
|
+
// is about the type its `on` path reaches, not about the searched one.
|
|
98
|
+
// Resolving the path needs the schema’s join graph, so a caller that
|
|
99
|
+
// supplied none cannot serve a joined criterion at all.
|
|
100
|
+
const on = criterion.on ?? [];
|
|
101
|
+
let constrained = searchType;
|
|
102
|
+
if (on.length > 0) {
|
|
103
|
+
if (on.length > MAX_JOIN_DEPTH) {
|
|
104
|
+
issues.push({ part: 'where', field, reason: 'join-too-deep' });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const target = joins?.resolve(searchType, on);
|
|
108
|
+
if (target === undefined) {
|
|
109
|
+
issues.push({ part: 'where', field, reason: 'unknown-join' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
constrained = target;
|
|
113
|
+
}
|
|
74
114
|
// `id` is filterable on every type without being declared by any: it is
|
|
75
115
|
// the document’s IRI, so the lookup exists wherever documents do.
|
|
76
116
|
// Membership only – an IRI has no range and no truth value.
|
|
@@ -78,23 +118,23 @@ export function validateQuery(query, searchType) {
|
|
|
78
118
|
if (filterOperator(criterion) !== 'in') {
|
|
79
119
|
issues.push({
|
|
80
120
|
part: 'where',
|
|
81
|
-
field
|
|
121
|
+
field,
|
|
82
122
|
reason: 'operator-mismatch',
|
|
83
123
|
});
|
|
84
124
|
}
|
|
85
125
|
continue;
|
|
86
126
|
}
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
issues.push({ part: 'where', field
|
|
127
|
+
const declared = fieldNamed(constrained, name);
|
|
128
|
+
if (declared === undefined) {
|
|
129
|
+
issues.push({ part: 'where', field, reason: 'unknown-field' });
|
|
90
130
|
}
|
|
91
|
-
else if (
|
|
92
|
-
issues.push({ part: 'where', field
|
|
131
|
+
else if (declared.filterable !== true) {
|
|
132
|
+
issues.push({ part: 'where', field, reason: 'not-filterable' });
|
|
93
133
|
}
|
|
94
|
-
else if (filterOperatorFor(
|
|
134
|
+
else if (filterOperatorFor(declared.kind) !== filterOperator(criterion)) {
|
|
95
135
|
issues.push({
|
|
96
136
|
part: 'where',
|
|
97
|
-
field
|
|
137
|
+
field,
|
|
98
138
|
reason: 'operator-mismatch',
|
|
99
139
|
});
|
|
100
140
|
}
|
|
@@ -123,8 +163,8 @@ export function validateQuery(query, searchType) {
|
|
|
123
163
|
}
|
|
124
164
|
/** Throw on the first structurally invalid query part ({@link validateQuery}),
|
|
125
165
|
* naming every issue. The always-on entry point for engine adapters. */
|
|
126
|
-
export function assertValidQuery(query, searchType) {
|
|
127
|
-
const issues = validateQuery(query, searchType);
|
|
166
|
+
export function assertValidQuery(query, searchType, joins) {
|
|
167
|
+
const issues = validateQuery(query, searchType, joins);
|
|
128
168
|
if (issues.length > 0) {
|
|
129
169
|
const detail = issues
|
|
130
170
|
.map((issue) => `${issue.part}: “${issue.field}” (${issue.reason})`)
|
package/dist/schema.d.ts
CHANGED
|
@@ -189,28 +189,76 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
|
|
|
189
189
|
* resolution.
|
|
190
190
|
*/
|
|
191
191
|
readonly labelSource?: string;
|
|
192
|
-
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
/**
|
|
193
|
+
* Turn this reference into an **engine-level join**, so a query can filter
|
|
194
|
+
* this type by a condition on the referent – `“every object published by
|
|
195
|
+
* institution X”` in one round-trip instead of two. Valid only alongside
|
|
196
|
+
* {@link ReferenceField.labelSource}, which already asserts that this
|
|
197
|
+
* field’s values are ids of documents in that type’s collection – exactly
|
|
198
|
+
* the fact a join needs.
|
|
199
|
+
*
|
|
200
|
+
* A capability flag in the vocabulary of `filterable`/`facetable`/`sortable`,
|
|
201
|
+
* and deliberately not derived from `labelSource`: an engine may refuse to
|
|
202
|
+
* index a mutual reference, so auto-deriving would make an existing schema
|
|
203
|
+
* silently lose a field. A `labelSource` added for display therefore costs
|
|
204
|
+
* nothing it does not cost today; only a `joinable` edge pays the
|
|
205
|
+
* component-scoped rebuild coupling.
|
|
206
|
+
*
|
|
207
|
+
* Never on an `inline` reference: that carries its referent as a nested
|
|
208
|
+
* object rather than as an id an engine can point a reference field at.
|
|
209
|
+
*
|
|
210
|
+
* At most ONE joinable field per (type, label source) – see
|
|
211
|
+
* {@link joinGraph}.
|
|
212
|
+
*/
|
|
213
|
+
readonly joinable?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* The referenced entity’s shape and how much of it to carry. Required when
|
|
216
|
+
* the field is `output` – the strategy is what decides the output shape, so
|
|
217
|
+
* a surfaced reference must state it; optional for a facet- or filter-only
|
|
218
|
+
* reference.
|
|
219
|
+
*
|
|
220
|
+
* A **discriminated union by `strategy`**, because `typeName` is load-bearing
|
|
221
|
+
* for two of the three and meaningless for the third: a `labelOnly` reference
|
|
222
|
+
* needs a name to emit its id-plus-label type under, an `inline` one needs a
|
|
223
|
+
* name that resolves to a declared {@link ReferenceType}, and an `idOnly` one
|
|
224
|
+
* carries a bare IRI that may belong to no declared type at all (`sameAs`
|
|
225
|
+
* points at a vocabulary nobody indexes). Declaring `typeName` on an `idOnly`
|
|
226
|
+
* reference is still useful where a target *is* nameable – an API surface can
|
|
227
|
+
* then tell IRIs of that target apart from IRIs at large.
|
|
228
|
+
*/
|
|
195
229
|
readonly ref?: {
|
|
196
230
|
/** Logical API type name of the referenced entity (PascalCase, e.g.
|
|
197
|
-
* `Organization`) – names the reference’s type in the API surfaces,
|
|
198
|
-
* way {@link SearchType.name} names a root type; fields sharing it
|
|
199
|
-
* one emitted type. For a `labelOnly
|
|
200
|
-
* not a key: it need not correspond to any indexed root type, and it
|
|
201
|
-
* name one (`creator` → `Person`, with the labels resolved from
|
|
202
|
-
* root’s collection via `labelSource`) – an API surface then
|
|
203
|
-
* reference under a derived name (GraphQL:
|
|
204
|
-
* type names unique. An `inline`
|
|
205
|
-
*
|
|
231
|
+
* `Organization`) – names the reference’s type in the API surfaces,
|
|
232
|
+
* the way {@link SearchType.name} names a root type; fields sharing it
|
|
233
|
+
* share one emitted type. For a `labelOnly` reference it is a name,
|
|
234
|
+
* not a key: it need not correspond to any indexed root type, and it
|
|
235
|
+
* may name one (`creator` → `Person`, with the labels resolved from
|
|
236
|
+
* that root’s collection via `labelSource`) – an API surface then
|
|
237
|
+
* serves the reference under a derived name (GraphQL:
|
|
238
|
+
* `‹Name›Reference`) to keep type names unique. An `inline`
|
|
239
|
+
* reference’s typeName instead resolves to a declared
|
|
240
|
+
* {@link ReferenceType}, so it can never name a root type. */
|
|
206
241
|
readonly typeName: string;
|
|
207
|
-
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* {@link
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
/** `labelOnly` carries the id plus a display label resolved from a
|
|
243
|
+
* label source; `inline` carries the referent’s own projected fields
|
|
244
|
+
* through a declared {@link ReferenceType} (see
|
|
245
|
+
* {@link isInlineReference}). */
|
|
246
|
+
readonly strategy: 'labelOnly' | 'inline';
|
|
247
|
+
} | {
|
|
248
|
+
/** Optional here, unlike the other strategies: an `idOnly` reference
|
|
249
|
+
* emits no type of its own, so a name is not needed to emit one under.
|
|
250
|
+
* Declare it when the referent’s IRIs form a nameable set an API
|
|
251
|
+
* surface should distinguish; omit it for IRIs that belong to no such
|
|
252
|
+
* set. */
|
|
253
|
+
readonly typeName?: string;
|
|
254
|
+
/** The IRI, and nothing else – no label resolution, no nested fields.
|
|
255
|
+
* Surfaces as a bare IRI rather than an object, which is what makes it
|
|
256
|
+
* the right strategy for a reference whose referent is not an entity
|
|
257
|
+
* this deployment describes: a canonical vocabulary URI, a licence, a
|
|
258
|
+
* content URL. A {@link ReferenceField.labelSource} is still honoured
|
|
259
|
+
* for facet bucket labels – the strategy governs the output shape, not
|
|
260
|
+
* whether a bucket can be labelled. */
|
|
261
|
+
readonly strategy: 'idOnly';
|
|
214
262
|
};
|
|
215
263
|
}
|
|
216
264
|
/**
|
|
@@ -351,7 +399,8 @@ export interface SearchSchema<Types extends readonly SearchType[] = readonly Sea
|
|
|
351
399
|
*
|
|
352
400
|
* Every declaration is validated ({@link assertValidSearchType}) – the
|
|
353
401
|
* declaration-time counterpart of the port’s `assertValidQuery` – and the
|
|
354
|
-
* schema-wide invariants are enforced
|
|
402
|
+
* schema-wide invariants are enforced – including the join rules, by building
|
|
403
|
+
* the {@link joinGraph} eagerly: no two Root Types may share a `class` IRI
|
|
355
404
|
* (they would silently overwrite each other in the map) and no two types may
|
|
356
405
|
* share a `name` (names key the API surfaces, across Root and Reference Types
|
|
357
406
|
* alike). Every inline reference must resolve to a declared Reference Type, and
|
|
@@ -435,7 +484,7 @@ export declare function labelFieldOf(searchType: SearchType): TextField | undefi
|
|
|
435
484
|
*/
|
|
436
485
|
export interface SearchTypeIssue {
|
|
437
486
|
readonly field: string;
|
|
438
|
-
readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'from-not-allowed' | 'from-with-path' | 'from-with-derive' | 'from-with-inline-ref' | 'unknown-projection-value' | 'duplicate-projection-value' | 'text-not-filterable' | 'text-not-facetable' | 'reserved-field-name';
|
|
487
|
+
readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'from-not-allowed' | 'from-with-path' | 'from-with-derive' | 'from-with-inline-ref' | 'unknown-projection-value' | 'duplicate-projection-value' | 'text-not-filterable' | 'text-not-facetable' | 'joinable-not-allowed' | 'joinable-without-label-source' | 'joinable-with-inline-ref' | 'reserved-field-name';
|
|
439
488
|
}
|
|
440
489
|
/**
|
|
441
490
|
* The reserved logical name of a document’s IRI – the one field every indexed
|
|
@@ -478,6 +527,13 @@ export declare const OR_KEY = "or";
|
|
|
478
527
|
* reserved name↔locale separator);
|
|
479
528
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
|
480
529
|
* need the reference type name); `ref` on any other kind is meaningless;
|
|
530
|
+
* - `joinable` only on a `reference` field, and only alongside a `labelSource`
|
|
531
|
+
* – the join addresses the referent’s collection, which is the one the label
|
|
532
|
+
* source names, so without it the flag states an edge to nowhere – and never
|
|
533
|
+
* on an `inline` reference, which is stored as a nested object rather than as
|
|
534
|
+
* an id a reference field can point at. The schema-wide half of the rule (at
|
|
535
|
+
* most one joinable field per (type, label source), no cycles) is
|
|
536
|
+
* {@link joinGraph}’s;
|
|
481
537
|
* - a `text` field declares at least one locale (`und` = untagged; projection and
|
|
482
538
|
* result reconstruction have no representation for unlocalized text – use
|
|
483
539
|
* `keyword` for untagged strings); `locales` on any other kind is
|
|
@@ -583,11 +639,20 @@ export declare function sortableFields(searchType: SearchType): readonly SearchF
|
|
|
583
639
|
export declare function outputFields(searchType: SearchType): readonly SearchField[];
|
|
584
640
|
/**
|
|
585
641
|
* Whether a field declares **no** role – none of `output`, `searchable`,
|
|
586
|
-
* `filterable`, `facetable`, `sortable`. Such a field is an
|
|
587
|
-
* the projection populates it (so a later
|
|
588
|
-
* read it), then prunes it before the
|
|
589
|
-
*
|
|
590
|
-
*
|
|
642
|
+
* `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
|
|
643
|
+
* **internal field**: the projection populates it (so a later
|
|
644
|
+
* {@link SearchFieldBase.derive} can read it), then prunes it before the
|
|
645
|
+
* document reaches a writer, and the engine collection definition omits it
|
|
646
|
+
* entirely – not stored, not indexed, no RAM. Absence of a role declares that
|
|
647
|
+
* intent; there is no separate marker flag.
|
|
648
|
+
*
|
|
649
|
+
* {@link ReferenceField.joinable} counts as a role for exactly the reason the
|
|
650
|
+
* others do: it is a promise about what the *engine* can do with the field, and
|
|
651
|
+
* an engine can only join through a value it actually stores. Leaving it out
|
|
652
|
+
* would let a joinable-but-otherwise-role-less reference declare an edge the
|
|
653
|
+
* schema resolves, the query validates and the compiler emits – against a
|
|
654
|
+
* collection that never stored the column, so every such query fails at the
|
|
655
|
+
* engine.
|
|
591
656
|
*
|
|
592
657
|
* The single predicate the projection and the collection definition share, so
|
|
593
658
|
* they cannot disagree on what is internal. See the Search context’s load-bearing
|
|
@@ -638,6 +703,24 @@ export declare function irAlias(searchType: SearchType, field: SearchField): str
|
|
|
638
703
|
export declare function isRangeFacet(field: SearchField): field is NumericField & {
|
|
639
704
|
readonly facetRanges: readonly FacetRange[];
|
|
640
705
|
};
|
|
706
|
+
/**
|
|
707
|
+
* Whether a value is an **absolute IRI**: a scheme, then anything with no
|
|
708
|
+
* whitespace. The single definition the projection (which
|
|
709
|
+
* drops a reference value failing it) and an API surface (which types such a
|
|
710
|
+
* value as an IRI, and rejects one that is not) both read, so the two cannot
|
|
711
|
+
* disagree about what a reference holds – the argument {@link physicalFields}
|
|
712
|
+
* makes for the physical fanout.
|
|
713
|
+
*
|
|
714
|
+
* Deliberately a **scheme check, not an `http(s)` one**. `urn:`, `doi:`, `ark:`,
|
|
715
|
+
* `tag:`, `mailto:` and a deployment’s own minted scheme (`urn:lde:…`) are
|
|
716
|
+
* ordinary Linked Data, and rejecting them would make this package refuse
|
|
717
|
+
* conformant data. What the check does exclude is what a reference must never
|
|
718
|
+
* hold: a blank node label (`_:b0` – a scheme must start with a letter), a bare
|
|
719
|
+
* token (`boerenbont`), and a relative reference. Full RFC 3987 parsing would
|
|
720
|
+
* buy nothing over that while starting to reject real data with a character
|
|
721
|
+
* someone forgot to percent-encode.
|
|
722
|
+
*/
|
|
723
|
+
export declare function isAbsoluteIri(value: string): boolean;
|
|
641
724
|
/**
|
|
642
725
|
* The engine storage codec for `date` fields: stored as Unix seconds (a
|
|
643
726
|
* sortable, range-filterable int64), ISO 8601 at the API edges. One pair for
|
package/dist/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
|
package/dist/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { joinGraph } from './join-graph.js';
|
|
1
2
|
const OPERATOR_BY_KIND = {
|
|
2
3
|
text: undefined,
|
|
3
4
|
keyword: 'in',
|
|
@@ -49,7 +50,8 @@ function isRootType(searchType) {
|
|
|
49
50
|
*
|
|
50
51
|
* Every declaration is validated ({@link assertValidSearchType}) – the
|
|
51
52
|
* declaration-time counterpart of the port’s `assertValidQuery` – and the
|
|
52
|
-
* schema-wide invariants are enforced
|
|
53
|
+
* schema-wide invariants are enforced – including the join rules, by building
|
|
54
|
+
* the {@link joinGraph} eagerly: no two Root Types may share a `class` IRI
|
|
53
55
|
* (they would silently overwrite each other in the map) and no two types may
|
|
54
56
|
* share a `name` (names key the API surfaces, across Root and Reference Types
|
|
55
57
|
* alike). Every inline reference must resolve to a declared Reference Type, and
|
|
@@ -84,6 +86,12 @@ export function searchSchema(...types) {
|
|
|
84
86
|
.filter(isRootType)
|
|
85
87
|
.map((searchType) => [searchType.class, searchType]));
|
|
86
88
|
referenceTypesBySchema.set(schema, referenceTypes);
|
|
89
|
+
// Build the join graph eagerly and discard it: it is cached per schema, and
|
|
90
|
+
// building it is what enforces the schema-wide join rules (one joinable
|
|
91
|
+
// reference per target, every target an indexed Root Type, no cycles). A
|
|
92
|
+
// schema whose joins do not hold up therefore fails HERE, not on the first
|
|
93
|
+
// query or – worse – halfway through the first rebuild.
|
|
94
|
+
joinGraph(schema);
|
|
87
95
|
return schema;
|
|
88
96
|
}
|
|
89
97
|
/**
|
|
@@ -385,6 +393,13 @@ const LOCALE_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
|
|
|
385
393
|
* reserved name↔locale separator);
|
|
386
394
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
|
387
395
|
* need the reference type name); `ref` on any other kind is meaningless;
|
|
396
|
+
* - `joinable` only on a `reference` field, and only alongside a `labelSource`
|
|
397
|
+
* – the join addresses the referent’s collection, which is the one the label
|
|
398
|
+
* source names, so without it the flag states an edge to nowhere – and never
|
|
399
|
+
* on an `inline` reference, which is stored as a nested object rather than as
|
|
400
|
+
* an id a reference field can point at. The schema-wide half of the rule (at
|
|
401
|
+
* most one joinable field per (type, label source), no cycles) is
|
|
402
|
+
* {@link joinGraph}’s;
|
|
388
403
|
* - a `text` field declares at least one locale (`und` = untagged; projection and
|
|
389
404
|
* result reconstruction have no representation for unlocalized text – use
|
|
390
405
|
* `keyword` for untagged strings); `locales` on any other kind is
|
|
@@ -444,9 +459,27 @@ export function validateSearchType(searchType) {
|
|
|
444
459
|
if (field.output === true && field.ref === undefined) {
|
|
445
460
|
issue('missing-ref');
|
|
446
461
|
}
|
|
462
|
+
// A join addresses the referent’s collection, which is the collection the
|
|
463
|
+
// label source names: without one the flag states an edge to nowhere.
|
|
464
|
+
if (field.joinable === true && field.labelSource === undefined) {
|
|
465
|
+
issue('joinable-without-label-source');
|
|
466
|
+
}
|
|
467
|
+
// An inline reference is stored as a NESTED OBJECT, not as an id an
|
|
468
|
+
// engine can point a reference field at, so the two cannot both hold:
|
|
469
|
+
// the collection definition would emit the nesting and silently drop the
|
|
470
|
+
// reference, leaving a schema whose joins validate, compile and then fail
|
|
471
|
+
// at the engine. Carry the referent inline or join to it, not both.
|
|
472
|
+
if (field.joinable === true && field.ref?.strategy === 'inline') {
|
|
473
|
+
issue('joinable-with-inline-ref');
|
|
474
|
+
}
|
|
447
475
|
}
|
|
448
|
-
else
|
|
449
|
-
|
|
476
|
+
else {
|
|
477
|
+
if (field.ref !== undefined) {
|
|
478
|
+
issue('ref-not-allowed');
|
|
479
|
+
}
|
|
480
|
+
if (field.joinable === true) {
|
|
481
|
+
issue('joinable-not-allowed');
|
|
482
|
+
}
|
|
450
483
|
}
|
|
451
484
|
if (field.kind === 'text') {
|
|
452
485
|
if ((field.locales ?? []).length === 0) {
|
|
@@ -622,11 +655,20 @@ export function outputFields(searchType) {
|
|
|
622
655
|
}
|
|
623
656
|
/**
|
|
624
657
|
* Whether a field declares **no** role – none of `output`, `searchable`,
|
|
625
|
-
* `filterable`, `facetable`, `sortable`. Such a field is an
|
|
626
|
-
* the projection populates it (so a later
|
|
627
|
-
* read it), then prunes it before the
|
|
628
|
-
*
|
|
629
|
-
*
|
|
658
|
+
* `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
|
|
659
|
+
* **internal field**: the projection populates it (so a later
|
|
660
|
+
* {@link SearchFieldBase.derive} can read it), then prunes it before the
|
|
661
|
+
* document reaches a writer, and the engine collection definition omits it
|
|
662
|
+
* entirely – not stored, not indexed, no RAM. Absence of a role declares that
|
|
663
|
+
* intent; there is no separate marker flag.
|
|
664
|
+
*
|
|
665
|
+
* {@link ReferenceField.joinable} counts as a role for exactly the reason the
|
|
666
|
+
* others do: it is a promise about what the *engine* can do with the field, and
|
|
667
|
+
* an engine can only join through a value it actually stores. Leaving it out
|
|
668
|
+
* would let a joinable-but-otherwise-role-less reference declare an edge the
|
|
669
|
+
* schema resolves, the query validates and the compiler emits – against a
|
|
670
|
+
* collection that never stored the column, so every such query fails at the
|
|
671
|
+
* engine.
|
|
630
672
|
*
|
|
631
673
|
* The single predicate the projection and the collection definition share, so
|
|
632
674
|
* they cannot disagree on what is internal. See the Search context’s load-bearing
|
|
@@ -637,7 +679,8 @@ export function isInternalField(field) {
|
|
|
637
679
|
field.searchable === undefined &&
|
|
638
680
|
field.filterable !== true &&
|
|
639
681
|
field.facetable !== true &&
|
|
640
|
-
field.sortable !== true
|
|
682
|
+
field.sortable !== true &&
|
|
683
|
+
(field.kind !== 'reference' || field.joinable !== true));
|
|
641
684
|
}
|
|
642
685
|
/** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */
|
|
643
686
|
export function referenceFields(searchType) {
|
|
@@ -692,6 +735,30 @@ export function irAlias(searchType, field) {
|
|
|
692
735
|
export function isRangeFacet(field) {
|
|
693
736
|
return field.facetRanges !== undefined && field.facetRanges.length > 0;
|
|
694
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Whether a value is an **absolute IRI**: a scheme, then anything with no
|
|
740
|
+
* whitespace. The single definition the projection (which
|
|
741
|
+
* drops a reference value failing it) and an API surface (which types such a
|
|
742
|
+
* value as an IRI, and rejects one that is not) both read, so the two cannot
|
|
743
|
+
* disagree about what a reference holds – the argument {@link physicalFields}
|
|
744
|
+
* makes for the physical fanout.
|
|
745
|
+
*
|
|
746
|
+
* Deliberately a **scheme check, not an `http(s)` one**. `urn:`, `doi:`, `ark:`,
|
|
747
|
+
* `tag:`, `mailto:` and a deployment’s own minted scheme (`urn:lde:…`) are
|
|
748
|
+
* ordinary Linked Data, and rejecting them would make this package refuse
|
|
749
|
+
* conformant data. What the check does exclude is what a reference must never
|
|
750
|
+
* hold: a blank node label (`_:b0` – a scheme must start with a letter), a bare
|
|
751
|
+
* token (`boerenbont`), and a relative reference. Full RFC 3987 parsing would
|
|
752
|
+
* buy nothing over that while starting to reject real data with a character
|
|
753
|
+
* someone forgot to percent-encode.
|
|
754
|
+
*/
|
|
755
|
+
export function isAbsoluteIri(value) {
|
|
756
|
+
return ABSOLUTE_IRI.test(value);
|
|
757
|
+
}
|
|
758
|
+
/** Scheme (RFC 3986 `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`), then any
|
|
759
|
+
* run of non-whitespace. Non-ASCII is deliberately allowed: an IRI is not
|
|
760
|
+
* restricted to ASCII. */
|
|
761
|
+
const ABSOLUTE_IRI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
|
|
695
762
|
/**
|
|
696
763
|
* The engine storage codec for `date` fields: stored as Unix seconds (a
|
|
697
764
|
* sortable, range-filterable int64), ISO 8601 at the API edges. One pair for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|