@lde/search 0.19.0 → 0.21.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 +2 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +1 -0
- 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.js +29 -2
- 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 +49 -8
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +66 -10
- package/package.json +1 -1
package/dist/adapter.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
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,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,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
|
@@ -8,4 +8,5 @@
|
|
|
8
8
|
*/
|
|
9
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.js
CHANGED
|
@@ -121,7 +121,16 @@ function applyField(document, node, field, searchType, schema, context) {
|
|
|
121
121
|
return applyProjectionValue(document, field, field.from, context);
|
|
122
122
|
}
|
|
123
123
|
if (field.derive !== undefined) {
|
|
124
|
-
const
|
|
124
|
+
const derived = field.derive(document, context);
|
|
125
|
+
// A derived `reference` is held to the same rule as a read one: what it
|
|
126
|
+
// stores is a selection key, and the surface types it `IRI`. ADR 19 sends
|
|
127
|
+
// an IRI-valued `keyword` derive here by converting it to `reference`, so
|
|
128
|
+
// this is the busiest way a non-IRI could otherwise reach the index. A
|
|
129
|
+
// value that survives nothing leaves the field absent, exactly as a derive
|
|
130
|
+
// returning `undefined` does.
|
|
131
|
+
const value = field.kind === 'reference' && derived !== undefined
|
|
132
|
+
? derivedIris(derived)
|
|
133
|
+
: derived;
|
|
125
134
|
if (value !== undefined) {
|
|
126
135
|
document[field.name] = value;
|
|
127
136
|
}
|
|
@@ -236,7 +245,14 @@ function foldedSearchValue(values) {
|
|
|
236
245
|
* document – a value the API cannot serialize.
|
|
237
246
|
*/
|
|
238
247
|
function applyFacet(document, raw, field) {
|
|
239
|
-
|
|
248
|
+
// A `reference` stores identity, so what it stores must be an IRI whatever
|
|
249
|
+
// route the value arrived by. {@link iriString} guards the graph path, but a
|
|
250
|
+
// `transform` runs after it and a `from` projection value never passes it at
|
|
251
|
+
// all – so both are checked here, at the one point they share. Without this
|
|
252
|
+
// the surface would promise `IRI` over a value the projection let through.
|
|
253
|
+
const referenced = field.kind === 'reference';
|
|
254
|
+
const transformed = field.transform ? raw.map(field.transform) : raw;
|
|
255
|
+
const values = dedupe(referenced ? transformed.filter(isAbsoluteIri) : transformed);
|
|
240
256
|
const folded = dedupe(values.map((value) => fold(value)));
|
|
241
257
|
const searchField = physicalFields(field).search[0];
|
|
242
258
|
if (field.array === true) {
|
|
@@ -384,6 +400,17 @@ function iriString(value) {
|
|
|
384
400
|
const iri = isObject(value) ? value['@id'] : value;
|
|
385
401
|
return typeof iri === 'string' && isAbsoluteIri(iri) ? iri : undefined;
|
|
386
402
|
}
|
|
403
|
+
/** What a `derive` on a `reference` field is allowed to have produced: the
|
|
404
|
+
* absolute IRIs among its return value, keeping the shape the declaration
|
|
405
|
+
* promises (a list stays a list, a single value stays single) so `array` still
|
|
406
|
+
* decides the stored shape. A lone non-IRI becomes `undefined`, i.e. absent –
|
|
407
|
+
* the same outcome the graph path gives it. */
|
|
408
|
+
function derivedIris(value) {
|
|
409
|
+
if (Array.isArray(value)) {
|
|
410
|
+
return value.filter((entry) => typeof entry === 'string' && isAbsoluteIri(entry));
|
|
411
|
+
}
|
|
412
|
+
return typeof value === 'string' && isAbsoluteIri(value) ? value : undefined;
|
|
413
|
+
}
|
|
387
414
|
function toInteger(literal) {
|
|
388
415
|
return literal === undefined ? undefined : Math.trunc(Number(literal));
|
|
389
416
|
}
|
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,6 +189,28 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
|
|
|
189
189
|
* resolution.
|
|
190
190
|
*/
|
|
191
191
|
readonly labelSource?: string;
|
|
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;
|
|
192
214
|
/**
|
|
193
215
|
* The referenced entity’s shape and how much of it to carry. Required when
|
|
194
216
|
* the field is `output` – the strategy is what decides the output shape, so
|
|
@@ -377,7 +399,8 @@ export interface SearchSchema<Types extends readonly SearchType[] = readonly Sea
|
|
|
377
399
|
*
|
|
378
400
|
* Every declaration is validated ({@link assertValidSearchType}) – the
|
|
379
401
|
* declaration-time counterpart of the port’s `assertValidQuery` – and the
|
|
380
|
-
* 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
|
|
381
404
|
* (they would silently overwrite each other in the map) and no two types may
|
|
382
405
|
* share a `name` (names key the API surfaces, across Root and Reference Types
|
|
383
406
|
* alike). Every inline reference must resolve to a declared Reference Type, and
|
|
@@ -461,7 +484,7 @@ export declare function labelFieldOf(searchType: SearchType): TextField | undefi
|
|
|
461
484
|
*/
|
|
462
485
|
export interface SearchTypeIssue {
|
|
463
486
|
readonly field: string;
|
|
464
|
-
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' | 'missing-ref-type-name' | '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';
|
|
465
488
|
}
|
|
466
489
|
/**
|
|
467
490
|
* The reserved logical name of a document’s IRI – the one field every indexed
|
|
@@ -503,7 +526,16 @@ export declare const OR_KEY = "or";
|
|
|
503
526
|
* - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
|
|
504
527
|
* reserved name↔locale separator);
|
|
505
528
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
|
506
|
-
* need the reference type name)
|
|
529
|
+
* need the reference type name), and – unless its strategy is `idOnly`, which
|
|
530
|
+
* emits no type of its own – that `ref` declares a `typeName`; `ref` on any
|
|
531
|
+
* other kind is meaningless;
|
|
532
|
+
* - `joinable` only on a `reference` field, and only alongside a `labelSource`
|
|
533
|
+
* – the join addresses the referent’s collection, which is the one the label
|
|
534
|
+
* source names, so without it the flag states an edge to nowhere – and never
|
|
535
|
+
* on an `inline` reference, which is stored as a nested object rather than as
|
|
536
|
+
* an id a reference field can point at. The schema-wide half of the rule (at
|
|
537
|
+
* most one joinable field per (type, label source), no cycles) is
|
|
538
|
+
* {@link joinGraph}’s;
|
|
507
539
|
* - a `text` field declares at least one locale (`und` = untagged; projection and
|
|
508
540
|
* result reconstruction have no representation for unlocalized text – use
|
|
509
541
|
* `keyword` for untagged strings); `locales` on any other kind is
|
|
@@ -609,11 +641,20 @@ export declare function sortableFields(searchType: SearchType): readonly SearchF
|
|
|
609
641
|
export declare function outputFields(searchType: SearchType): readonly SearchField[];
|
|
610
642
|
/**
|
|
611
643
|
* Whether a field declares **no** role – none of `output`, `searchable`,
|
|
612
|
-
* `filterable`, `facetable`, `sortable`. Such a field is an
|
|
613
|
-
* the projection populates it (so a later
|
|
614
|
-
* read it), then prunes it before the
|
|
615
|
-
*
|
|
616
|
-
*
|
|
644
|
+
* `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
|
|
645
|
+
* **internal field**: the projection populates it (so a later
|
|
646
|
+
* {@link SearchFieldBase.derive} can read it), then prunes it before the
|
|
647
|
+
* document reaches a writer, and the engine collection definition omits it
|
|
648
|
+
* entirely – not stored, not indexed, no RAM. Absence of a role declares that
|
|
649
|
+
* intent; there is no separate marker flag.
|
|
650
|
+
*
|
|
651
|
+
* {@link ReferenceField.joinable} counts as a role for exactly the reason the
|
|
652
|
+
* others do: it is a promise about what the *engine* can do with the field, and
|
|
653
|
+
* an engine can only join through a value it actually stores. Leaving it out
|
|
654
|
+
* would let a joinable-but-otherwise-role-less reference declare an edge the
|
|
655
|
+
* schema resolves, the query validates and the compiler emits – against a
|
|
656
|
+
* collection that never stored the column, so every such query fails at the
|
|
657
|
+
* engine.
|
|
617
658
|
*
|
|
618
659
|
* The single predicate the projection and the collection definition share, so
|
|
619
660
|
* they cannot disagree on what is internal. See the Search context’s load-bearing
|
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
|
/**
|
|
@@ -384,7 +392,16 @@ const LOCALE_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
|
|
|
384
392
|
* - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
|
|
385
393
|
* reserved name↔locale separator);
|
|
386
394
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
|
387
|
-
* need the reference type name)
|
|
395
|
+
* need the reference type name), and – unless its strategy is `idOnly`, which
|
|
396
|
+
* emits no type of its own – that `ref` declares a `typeName`; `ref` on any
|
|
397
|
+
* other kind is meaningless;
|
|
398
|
+
* - `joinable` only on a `reference` field, and only alongside a `labelSource`
|
|
399
|
+
* – the join addresses the referent’s collection, which is the one the label
|
|
400
|
+
* source names, so without it the flag states an edge to nowhere – and never
|
|
401
|
+
* on an `inline` reference, which is stored as a nested object rather than as
|
|
402
|
+
* an id a reference field can point at. The schema-wide half of the rule (at
|
|
403
|
+
* most one joinable field per (type, label source), no cycles) is
|
|
404
|
+
* {@link joinGraph}’s;
|
|
388
405
|
* - a `text` field declares at least one locale (`und` = untagged; projection and
|
|
389
406
|
* result reconstruction have no representation for unlocalized text – use
|
|
390
407
|
* `keyword` for untagged strings); `locales` on any other kind is
|
|
@@ -444,9 +461,38 @@ export function validateSearchType(searchType) {
|
|
|
444
461
|
if (field.output === true && field.ref === undefined) {
|
|
445
462
|
issue('missing-ref');
|
|
446
463
|
}
|
|
464
|
+
// `typeName` is optional only for `idOnly`, which emits no type and so
|
|
465
|
+
// needs no name to emit one under. Every other surfaced strategy is
|
|
466
|
+
// served AS a named type, and without a name an API surface has nothing
|
|
467
|
+
// to emit – the GraphQL one silently prints `undefined` as the field’s
|
|
468
|
+
// type, publishing a contract that is not a schema.
|
|
469
|
+
if (field.output === true &&
|
|
470
|
+
field.ref !== undefined &&
|
|
471
|
+
field.ref.strategy !== 'idOnly' &&
|
|
472
|
+
field.ref.typeName === undefined) {
|
|
473
|
+
issue('missing-ref-type-name');
|
|
474
|
+
}
|
|
475
|
+
// A join addresses the referent’s collection, which is the collection the
|
|
476
|
+
// label source names: without one the flag states an edge to nowhere.
|
|
477
|
+
if (field.joinable === true && field.labelSource === undefined) {
|
|
478
|
+
issue('joinable-without-label-source');
|
|
479
|
+
}
|
|
480
|
+
// An inline reference is stored as a NESTED OBJECT, not as an id an
|
|
481
|
+
// engine can point a reference field at, so the two cannot both hold:
|
|
482
|
+
// the collection definition would emit the nesting and silently drop the
|
|
483
|
+
// reference, leaving a schema whose joins validate, compile and then fail
|
|
484
|
+
// at the engine. Carry the referent inline or join to it, not both.
|
|
485
|
+
if (field.joinable === true && field.ref?.strategy === 'inline') {
|
|
486
|
+
issue('joinable-with-inline-ref');
|
|
487
|
+
}
|
|
447
488
|
}
|
|
448
|
-
else
|
|
449
|
-
|
|
489
|
+
else {
|
|
490
|
+
if (field.ref !== undefined) {
|
|
491
|
+
issue('ref-not-allowed');
|
|
492
|
+
}
|
|
493
|
+
if (field.joinable === true) {
|
|
494
|
+
issue('joinable-not-allowed');
|
|
495
|
+
}
|
|
450
496
|
}
|
|
451
497
|
if (field.kind === 'text') {
|
|
452
498
|
if ((field.locales ?? []).length === 0) {
|
|
@@ -622,11 +668,20 @@ export function outputFields(searchType) {
|
|
|
622
668
|
}
|
|
623
669
|
/**
|
|
624
670
|
* 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
|
-
*
|
|
671
|
+
* `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
|
|
672
|
+
* **internal field**: the projection populates it (so a later
|
|
673
|
+
* {@link SearchFieldBase.derive} can read it), then prunes it before the
|
|
674
|
+
* document reaches a writer, and the engine collection definition omits it
|
|
675
|
+
* entirely – not stored, not indexed, no RAM. Absence of a role declares that
|
|
676
|
+
* intent; there is no separate marker flag.
|
|
677
|
+
*
|
|
678
|
+
* {@link ReferenceField.joinable} counts as a role for exactly the reason the
|
|
679
|
+
* others do: it is a promise about what the *engine* can do with the field, and
|
|
680
|
+
* an engine can only join through a value it actually stores. Leaving it out
|
|
681
|
+
* would let a joinable-but-otherwise-role-less reference declare an edge the
|
|
682
|
+
* schema resolves, the query validates and the compiler emits – against a
|
|
683
|
+
* collection that never stored the column, so every such query fails at the
|
|
684
|
+
* engine.
|
|
630
685
|
*
|
|
631
686
|
* The single predicate the projection and the collection definition share, so
|
|
632
687
|
* they cannot disagree on what is internal. See the Search context’s load-bearing
|
|
@@ -637,7 +692,8 @@ export function isInternalField(field) {
|
|
|
637
692
|
field.searchable === undefined &&
|
|
638
693
|
field.filterable !== true &&
|
|
639
694
|
field.facetable !== true &&
|
|
640
|
-
field.sortable !== true
|
|
695
|
+
field.sortable !== true &&
|
|
696
|
+
(field.kind !== 'reference' || field.joinable !== true));
|
|
641
697
|
}
|
|
642
698
|
/** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */
|
|
643
699
|
export function referenceFields(searchType) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|