@lde/search 0.1.2 → 0.2.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/README.md +249 -119
- package/dist/adapter.d.ts +13 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +10 -0
- package/dist/engine.d.ts +124 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/project.d.ts +12 -84
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +91 -53
- package/dist/query.d.ts +87 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +89 -0
- package/dist/schema.d.ts +343 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +250 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/package.json +22 -2
package/dist/project.d.ts
CHANGED
|
@@ -1,98 +1,26 @@
|
|
|
1
1
|
import type { Quad } from '@rdfjs/types';
|
|
2
2
|
import { type FramedNode } from './frame-by-type.js';
|
|
3
|
+
import { type SearchSchema, type SearchType } from './schema.js';
|
|
3
4
|
/** A flat search document. `id` is the engine document key. */
|
|
4
5
|
export type SearchDocument = {
|
|
5
6
|
id: string;
|
|
6
7
|
} & Record<string, unknown>;
|
|
7
|
-
/**
|
|
8
|
-
* How one framed-IR property projects into search fields. The vocabulary mirrors
|
|
9
|
-
* SHACL so a generator can later emit it from shapes + search annotations:
|
|
10
|
-
* `path` is `sh:path`, and the kind is derivable from `sh:datatype`/`sh:nodeKind`
|
|
11
|
-
* /`sh:maxCount` plus the search annotations.
|
|
12
|
-
*/
|
|
13
|
-
export type FieldKind = LangTextKind | FacetKind | NumberKind | DateKind;
|
|
14
|
-
/**
|
|
15
|
-
* Language-tagged text, projected per locale. `locales` is the single source of
|
|
16
|
-
* truth for which languages this field emits; `display`, `search` and `sort` are
|
|
17
|
-
* three independent opt-in families that each fan out over it:
|
|
18
|
-
* - `display` → `${name}_${locale}` display label, accents preserved;
|
|
19
|
-
* - `search` → `${name}_search_${locale}` folded match field (one per locale so
|
|
20
|
-
* the engine can tokenize/stem each language and the query can rank the user’s
|
|
21
|
-
* locale higher);
|
|
22
|
-
* - `sort` → `${name}_sort_${locale}` folded sort key (one per locale so a
|
|
23
|
-
* locale-switching UI sorts on the active language).
|
|
24
|
-
*
|
|
25
|
-
* All three default off — a field emits exactly the families it opts into (e.g.
|
|
26
|
-
* `search` alone is a search-only field, shown via a separate label). Only listed
|
|
27
|
-
* locales are projected: a value whose language tag is not in `locales` (and is
|
|
28
|
-
* not mapped in by `untaggedLanguage`) is not indexed at all.
|
|
29
|
-
*/
|
|
30
|
-
export interface LangTextKind {
|
|
31
|
-
readonly type: 'langText';
|
|
32
|
-
/** The languages to project; drives whichever of the families are enabled. */
|
|
33
|
-
readonly locales: readonly string[];
|
|
34
|
-
/** Emit the per-locale display labels `${name}_${locale}` (accents preserved). */
|
|
35
|
-
readonly display?: boolean;
|
|
36
|
-
/** Emit a folded `${name}_search_${locale}` per locale (matchable). */
|
|
37
|
-
readonly search?: boolean;
|
|
38
|
-
/** Emit a folded `${name}_sort_${locale}` per locale (sortable). */
|
|
39
|
-
readonly sort?: boolean;
|
|
40
|
-
}
|
|
41
|
-
/** A faceted multi-value field, optionally also folded for search. */
|
|
42
|
-
export interface FacetKind {
|
|
43
|
-
readonly type: 'facet';
|
|
44
|
-
/** Read IRI references (`@id`) rather than literal values. */
|
|
45
|
-
readonly iri?: boolean;
|
|
46
|
-
/** Also emit a folded `${name}_search` array. */
|
|
47
|
-
readonly search?: boolean;
|
|
48
|
-
/** Transform each value before faceting (e.g. strip a media-type prefix). */
|
|
49
|
-
readonly transform?: (value: string) => string;
|
|
50
|
-
}
|
|
51
|
-
/** A numeric scalar. */
|
|
52
|
-
export interface NumberKind {
|
|
53
|
-
readonly type: 'number';
|
|
54
|
-
}
|
|
55
|
-
/** An ISO date-time, parsed into Unix seconds. */
|
|
56
|
-
export interface DateKind {
|
|
57
|
-
readonly type: 'date';
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* One field of a projection: an output `name`, the framed-IR predicate `path` to
|
|
61
|
-
* read (the SHACL `sh:path`), and the kind-specific config discriminated by
|
|
62
|
-
* `type`.
|
|
63
|
-
*/
|
|
64
|
-
export type FieldSpec = {
|
|
65
|
-
/** Output field base name; per-kind suffixes are appended. */
|
|
66
|
-
readonly name: string;
|
|
67
|
-
/** Framed-IR predicate IRI to read (the SHACL `sh:path`). */
|
|
68
|
-
readonly path: string;
|
|
69
|
-
} & FieldKind;
|
|
70
|
-
/** A computed field that is not a direct projection of a single path
|
|
71
|
-
* (e.g. a status rank, or a group derived from a code table). */
|
|
72
|
-
export type Derivation = (document: SearchDocument, node: FramedNode) => void;
|
|
73
|
-
/**
|
|
74
|
-
* One root type’s complete projection — the runtime form of a single SHACL
|
|
75
|
-
* NodeShape: `type` is its `sh:targetClass` (and the framed node’s `@type`),
|
|
76
|
-
* `fields` are its property shapes, and `derivations` are its `sh:rule`-shaped
|
|
77
|
-
* computed fields. A generator emits one of these per NodeShape.
|
|
78
|
-
*/
|
|
79
|
-
export interface Projection {
|
|
80
|
-
readonly type: string;
|
|
81
|
-
readonly fields: readonly FieldSpec[];
|
|
82
|
-
readonly derivations?: readonly Derivation[];
|
|
83
|
-
}
|
|
84
8
|
/**
|
|
85
9
|
* Project one framed JSON-LD node into a flat search document: apply each field
|
|
86
|
-
*
|
|
10
|
+
* of the type in declaration order. A field with a `derive` function computes
|
|
11
|
+
* its value from the node and the document as populated so far (so a derived
|
|
12
|
+
* field may read fields declared before it). The physical field names a field
|
|
13
|
+
* fans out to come from {@link physicalFields}, the single source shared with
|
|
14
|
+
* the engine collection schema and the query compiler.
|
|
87
15
|
*/
|
|
88
|
-
export declare function projectDocument(node: FramedNode,
|
|
16
|
+
export declare function projectDocument(node: FramedNode, searchType: SearchType): SearchDocument;
|
|
89
17
|
/**
|
|
90
|
-
* Frame `quads` for every
|
|
91
|
-
* type’s
|
|
92
|
-
* so memory stays flat. The IR maps to a
|
|
93
|
-
* is adding a `
|
|
18
|
+
* Frame `quads` for every root type in the schema and project each node with its
|
|
19
|
+
* type’s declaration — the multi-shape pipeline. Streams one document at a time
|
|
20
|
+
* so memory stays flat. The IR maps to a declaration by type, so adding a shape
|
|
21
|
+
* is adding a `SearchType` to the schema (no engine change).
|
|
94
22
|
*/
|
|
95
|
-
export declare function projectGraph(quads: readonly Quad[],
|
|
23
|
+
export declare function projectGraph(quads: readonly Quad[], schema: SearchSchema): AsyncIterable<SearchDocument>;
|
|
96
24
|
export declare function literalsOf(node: FramedNode, path: string): string[];
|
|
97
25
|
export declare function firstLiteralOf(node: FramedNode, path: string): string | undefined;
|
|
98
26
|
export declare function irisOf(node: FramedNode, path: string): string[];
|
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,EAAe,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;
|
|
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,EAAe,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAClE,OAAO,EAML,KAAK,YAAY,EACjB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAErB,+DAA+D;AAC/D,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtE;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,GACrB,cAAc,CAYhB;AAED;;;;;GAKG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,MAAM,EAAE,YAAY,GACnB,aAAa,CAAC,cAAc,CAAC,CAM/B;AAsID,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAInE;AAED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAI/D"}
|
package/dist/project.js
CHANGED
|
@@ -1,81 +1,122 @@
|
|
|
1
1
|
import { fold } from '@lde/text-normalization';
|
|
2
2
|
import { frameByType } from './frame-by-type.js';
|
|
3
|
+
import { isoToUnixSeconds, physicalFields, } from './schema.js';
|
|
3
4
|
/**
|
|
4
5
|
* Project one framed JSON-LD node into a flat search document: apply each field
|
|
5
|
-
*
|
|
6
|
+
* of the type in declaration order. A field with a `derive` function computes
|
|
7
|
+
* its value from the node and the document as populated so far (so a derived
|
|
8
|
+
* field may read fields declared before it). The physical field names a field
|
|
9
|
+
* fans out to come from {@link physicalFields}, the single source shared with
|
|
10
|
+
* the engine collection schema and the query compiler.
|
|
6
11
|
*/
|
|
7
|
-
export function projectDocument(node,
|
|
12
|
+
export function projectDocument(node, searchType) {
|
|
8
13
|
const id = node['@id'];
|
|
9
14
|
if (typeof id !== 'string') {
|
|
10
|
-
throw new Error(`Cannot project a ${
|
|
15
|
+
throw new Error(`Cannot project a ${searchType.type} node without an @id: every search document needs a stable key, and an empty one would collide with other keyless nodes.`);
|
|
11
16
|
}
|
|
12
17
|
const document = { id };
|
|
13
|
-
for (const field of
|
|
18
|
+
for (const field of searchType.fields) {
|
|
14
19
|
applyField(document, node, field);
|
|
15
20
|
}
|
|
16
|
-
for (const derive of projection.derivations ?? []) {
|
|
17
|
-
derive(document, node);
|
|
18
|
-
}
|
|
19
21
|
return document;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
|
-
* Frame `quads` for every
|
|
23
|
-
* type’s
|
|
24
|
-
* so memory stays flat. The IR maps to a
|
|
25
|
-
* is adding a `
|
|
24
|
+
* Frame `quads` for every root type in the schema and project each node with its
|
|
25
|
+
* type’s declaration — the multi-shape pipeline. Streams one document at a time
|
|
26
|
+
* so memory stays flat. The IR maps to a declaration by type, so adding a shape
|
|
27
|
+
* is adding a `SearchType` to the schema (no engine change).
|
|
26
28
|
*/
|
|
27
|
-
export async function* projectGraph(quads,
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
yield projectDocument(node, projection);
|
|
29
|
+
export async function* projectGraph(quads, schema) {
|
|
30
|
+
for (const searchType of schema.values()) {
|
|
31
|
+
for await (const node of frameByType(quads, searchType.type)) {
|
|
32
|
+
yield projectDocument(node, searchType);
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
function applyField(document, node, field) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
if (field.derive !== undefined) {
|
|
38
|
+
const value = field.derive(node, document);
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
document[field.name] = value;
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const path = field.path;
|
|
45
|
+
if (path === undefined) {
|
|
46
|
+
// Neither path nor derive: populated outside the projection, if at all.
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
switch (field.kind) {
|
|
50
|
+
case 'text':
|
|
51
|
+
return applyText(document, langValuesOf(node, path), field);
|
|
52
|
+
case 'keyword':
|
|
53
|
+
return applyFacet(document, literalsOf(node, path), field);
|
|
54
|
+
case 'reference':
|
|
55
|
+
return applyFacet(document, irisOf(node, path), field);
|
|
56
|
+
case 'integer':
|
|
57
|
+
return setNumber(document, field.name, toInteger(firstLiteralOf(node, path)));
|
|
41
58
|
case 'number':
|
|
42
|
-
return setNumber(document, field.name,
|
|
43
|
-
case 'date':
|
|
44
|
-
|
|
59
|
+
return setNumber(document, field.name, toNumber(firstLiteralOf(node, path)));
|
|
60
|
+
case 'date': {
|
|
61
|
+
const literal = firstLiteralOf(node, path);
|
|
62
|
+
return setNumber(document, field.name, literal === undefined ? undefined : isoToUnixSeconds(literal));
|
|
63
|
+
}
|
|
64
|
+
case 'boolean': {
|
|
65
|
+
// The xsd:boolean lexical space: true/false/1/0.
|
|
66
|
+
const literal = firstLiteralOf(node, path);
|
|
67
|
+
if (literal !== undefined) {
|
|
68
|
+
document[field.name] = literal === 'true' || literal === '1';
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
45
72
|
}
|
|
46
73
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Project a text field per locale (untagged literals land in the `und`
|
|
76
|
+
* locale). Display shows one label (accents preserved) when the field is
|
|
77
|
+
* `output`; sort keys off that same primary value (folded) when `sortable`;
|
|
78
|
+
* search folds every value of the locale when `searchable`, so all are
|
|
79
|
+
* matchable. Absent locales emit nothing.
|
|
80
|
+
*/
|
|
81
|
+
function applyText(document, values, field) {
|
|
82
|
+
// Empty `locales` is rejected at declaration time (`validateSearchType`);
|
|
83
|
+
// here it simply projects nothing.
|
|
84
|
+
const locales = field.locales;
|
|
85
|
+
const names = physicalFields(field);
|
|
86
|
+
locales.forEach((locale, index) => {
|
|
52
87
|
const localeValues = values
|
|
53
88
|
.filter((value) => value.lang === locale)
|
|
54
89
|
.map((value) => value.value);
|
|
55
90
|
if (localeValues.length === 0) {
|
|
56
|
-
|
|
91
|
+
return;
|
|
57
92
|
}
|
|
58
|
-
// Display shows one label (accents preserved); sort keys off that same
|
|
59
|
-
// primary value (folded); search folds every value of the locale so all
|
|
60
|
-
// are matchable. Absent locales emit nothing (the field stays optional).
|
|
61
93
|
const [primary] = localeValues;
|
|
62
|
-
if (
|
|
63
|
-
setString(document,
|
|
94
|
+
if (field.output) {
|
|
95
|
+
setString(document, names.display[index], primary);
|
|
64
96
|
}
|
|
65
|
-
if (
|
|
66
|
-
setString(document,
|
|
97
|
+
if (field.searchable) {
|
|
98
|
+
setString(document, names.search[index], foldedSearchValue(localeValues));
|
|
67
99
|
}
|
|
68
|
-
if (
|
|
69
|
-
setString(document,
|
|
100
|
+
if (field.sortable) {
|
|
101
|
+
setString(document, names.sort[index], fold(primary));
|
|
70
102
|
}
|
|
71
|
-
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** The projection’s definition of a folded free-text search value. */
|
|
106
|
+
function foldedSearchValue(values) {
|
|
107
|
+
return fold(values.join(' ')).trim();
|
|
72
108
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Project a faceted multi-value field: dedupe (after the optional transform),
|
|
111
|
+
* write the value field, and — when `searchable` — a folded `${name}_search`
|
|
112
|
+
* array. `keyword` reads literals; `reference` reads IRIs (the caller passes the
|
|
113
|
+
* already-read raw values).
|
|
114
|
+
*/
|
|
115
|
+
function applyFacet(document, raw, field) {
|
|
116
|
+
const values = dedupe(field.transform ? raw.map(field.transform) : raw);
|
|
117
|
+
setArray(document, field.name, values);
|
|
118
|
+
if (field.searchable) {
|
|
119
|
+
setArray(document, physicalFields(field).search[0], dedupe(values.map((value) => fold(value))));
|
|
79
120
|
}
|
|
80
121
|
}
|
|
81
122
|
function langValuesOf(node, path) {
|
|
@@ -108,9 +149,10 @@ function toLangValue(value) {
|
|
|
108
149
|
if (literal === undefined) {
|
|
109
150
|
return undefined;
|
|
110
151
|
}
|
|
152
|
+
// Untagged literals (JSON-LD @none) land in the reserved `und` locale.
|
|
111
153
|
const lang = isObject(value) && typeof value['@language'] === 'string'
|
|
112
154
|
? value['@language']
|
|
113
|
-
: '';
|
|
155
|
+
: 'und';
|
|
114
156
|
return { value: literal, lang };
|
|
115
157
|
}
|
|
116
158
|
function literalString(value) {
|
|
@@ -140,12 +182,8 @@ function iriString(value) {
|
|
|
140
182
|
function toInteger(literal) {
|
|
141
183
|
return literal === undefined ? undefined : Math.trunc(Number(literal));
|
|
142
184
|
}
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
const millis = new Date(iso).getTime();
|
|
148
|
-
return Number.isNaN(millis) ? undefined : Math.trunc(millis / 1000);
|
|
185
|
+
function toNumber(literal) {
|
|
186
|
+
return literal === undefined ? undefined : Number(literal);
|
|
149
187
|
}
|
|
150
188
|
function setNumber(document, field, value) {
|
|
151
189
|
if (value !== undefined && !Number.isNaN(value)) {
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type SearchType } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* The engine- and protocol-neutral query IR. Every API surface compiles its
|
|
4
|
+
* input into this; every engine adapter compiles it into an engine query. One
|
|
5
|
+
* shared representation in the middle keeps the GraphQL surface, a later REST
|
|
6
|
+
* surface and the adapter from drifting.
|
|
7
|
+
*/
|
|
8
|
+
export interface SearchQuery {
|
|
9
|
+
/** Free-text query; `undefined`/`''` means browse (no text ranking). */
|
|
10
|
+
readonly text?: string;
|
|
11
|
+
/** AND across fields. */
|
|
12
|
+
readonly where: readonly Filter[];
|
|
13
|
+
/** Primary public sort plus any server tie-breaks, in precedence order. */
|
|
14
|
+
readonly orderBy: readonly Sort[];
|
|
15
|
+
/** Numbered pagination. */
|
|
16
|
+
readonly limit: number;
|
|
17
|
+
readonly offset: number;
|
|
18
|
+
/** Logical field names to return facet buckets for. */
|
|
19
|
+
readonly facets: readonly string[];
|
|
20
|
+
/** Selects the per-locale fields to query/sort on (from `Accept-Language`). */
|
|
21
|
+
readonly locale: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One `where` clause. The operator is fixed by the target field’s {@link FieldKind}
|
|
25
|
+
* ({@link filterOperatorFor}): keyword/reference use `in` (OR within the field),
|
|
26
|
+
* the numeric/date kinds use an inclusive `range`, boolean uses `is`. Bounds are
|
|
27
|
+
* inclusive only — no `gt`/`gte`/`lt`/`lte`.
|
|
28
|
+
*/
|
|
29
|
+
export type Filter = {
|
|
30
|
+
readonly field: string;
|
|
31
|
+
readonly in: readonly string[];
|
|
32
|
+
} | {
|
|
33
|
+
readonly field: string;
|
|
34
|
+
readonly range: {
|
|
35
|
+
readonly min?: number | string;
|
|
36
|
+
readonly max?: number | string;
|
|
37
|
+
};
|
|
38
|
+
} | {
|
|
39
|
+
readonly field: string;
|
|
40
|
+
readonly is: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** A single sort dimension. */
|
|
43
|
+
export interface Sort {
|
|
44
|
+
readonly field: string;
|
|
45
|
+
readonly direction: 'asc' | 'desc';
|
|
46
|
+
}
|
|
47
|
+
export { filterOperatorFor } from './schema.js';
|
|
48
|
+
export type { FilterOperator } from './schema.js';
|
|
49
|
+
import type { FilterOperator } from './schema.js';
|
|
50
|
+
/** The operator a {@link Filter} value carries, from its discriminating key. */
|
|
51
|
+
export declare function filterOperator(filter: Filter): FilterOperator;
|
|
52
|
+
/**
|
|
53
|
+
* One structural problem {@link validateQuery} found: the query references a
|
|
54
|
+
* field the search type does not declare, or uses it in a role it does not
|
|
55
|
+
* opt into. Vacuous-but-valid clauses (an empty `in` list, a `range` with no
|
|
56
|
+
* bound) are NOT issues — a compiler skips those as no-ops.
|
|
57
|
+
*/
|
|
58
|
+
export interface QueryIssue {
|
|
59
|
+
readonly part: 'where' | 'facets' | 'orderBy';
|
|
60
|
+
readonly field: string;
|
|
61
|
+
readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Structurally validate a query against its search type: every `where` clause
|
|
65
|
+
* targets a declared, `filterable` field with the operator its kind accepts
|
|
66
|
+
* ({@link filterOperatorFor}); every requested facet is a declared, `facetable`
|
|
67
|
+
* field; every sort is `relevance` or a declared field. Sorting deliberately
|
|
68
|
+
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
69
|
+
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
70
|
+
*
|
|
71
|
+
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
72
|
+
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
73
|
+
* garbage to its engine, so validation holds for every caller — including
|
|
74
|
+
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
75
|
+
*/
|
|
76
|
+
export declare function validateQuery(query: SearchQuery, searchType: SearchType): readonly QueryIssue[];
|
|
77
|
+
/** Throw on the first structurally invalid query part ({@link validateQuery}),
|
|
78
|
+
* naming every issue. The always-on entry point for engine adapters. */
|
|
79
|
+
export declare function assertValidQuery(query: SearchQuery, searchType: SearchType): void;
|
|
80
|
+
/**
|
|
81
|
+
* The 1-based page an `offset` falls on — the numbered-pagination presentation
|
|
82
|
+
* of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
|
|
83
|
+
* query) fetches no hits and has no meaningful page, so it pins to 1 rather
|
|
84
|
+
* than dividing by zero.
|
|
85
|
+
*/
|
|
86
|
+
export declare function pageForOffset(offset: number, limit: number): number;
|
|
87
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAE7E;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,yBAAyB;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,SAAS,IAAI,EAAE,CAAC;IAClC,2BAA2B;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GACd;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC1D;IACE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KAChC,CAAC;CACH,GACD;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC;AAErD,+BAA+B;AAC/B,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;CACpC;AAKD,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAE7D;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EACX,eAAe,GACf,gBAAgB,GAChB,mBAAmB,GACnB,eAAe,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,SAAS,UAAU,EAAE,CA6CvB;AAED;yEACyE;AACzE,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,IAAI,CAUN;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE"}
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { fieldNamed, filterOperatorFor } from './schema.js';
|
|
2
|
+
// The kind→operator table lives with the field model in schema.ts (one source
|
|
3
|
+
// for the query IR, the surfaces, the adapters AND declaration validation);
|
|
4
|
+
// re-exported here so the query-IR module stays the natural import site.
|
|
5
|
+
export { filterOperatorFor } from './schema.js';
|
|
6
|
+
/** The operator a {@link Filter} value carries, from its discriminating key. */
|
|
7
|
+
export function filterOperator(filter) {
|
|
8
|
+
return 'in' in filter ? 'in' : 'range' in filter ? 'range' : 'is';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Structurally validate a query against its search type: every `where` clause
|
|
12
|
+
* targets a declared, `filterable` field with the operator its kind accepts
|
|
13
|
+
* ({@link filterOperatorFor}); every requested facet is a declared, `facetable`
|
|
14
|
+
* field; every sort is `relevance` or a declared field. Sorting deliberately
|
|
15
|
+
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
16
|
+
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
17
|
+
*
|
|
18
|
+
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
19
|
+
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
20
|
+
* garbage to its engine, so validation holds for every caller — including
|
|
21
|
+
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
22
|
+
*/
|
|
23
|
+
export function validateQuery(query, searchType) {
|
|
24
|
+
const issues = [];
|
|
25
|
+
for (const filter of query.where) {
|
|
26
|
+
const field = fieldNamed(searchType, filter.field);
|
|
27
|
+
if (field === undefined) {
|
|
28
|
+
issues.push({
|
|
29
|
+
part: 'where',
|
|
30
|
+
field: filter.field,
|
|
31
|
+
reason: 'unknown-field',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else if (field.filterable !== true) {
|
|
35
|
+
issues.push({
|
|
36
|
+
part: 'where',
|
|
37
|
+
field: filter.field,
|
|
38
|
+
reason: 'not-filterable',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else if (filterOperatorFor(field.kind) !== filterOperator(filter)) {
|
|
42
|
+
issues.push({
|
|
43
|
+
part: 'where',
|
|
44
|
+
field: filter.field,
|
|
45
|
+
reason: 'operator-mismatch',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const name of query.facets) {
|
|
50
|
+
const field = fieldNamed(searchType, name);
|
|
51
|
+
if (field === undefined) {
|
|
52
|
+
issues.push({ part: 'facets', field: name, reason: 'unknown-field' });
|
|
53
|
+
}
|
|
54
|
+
else if (field.facetable !== true) {
|
|
55
|
+
issues.push({ part: 'facets', field: name, reason: 'not-facetable' });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const sort of query.orderBy) {
|
|
59
|
+
if (sort.field !== 'relevance' &&
|
|
60
|
+
fieldNamed(searchType, sort.field) === undefined) {
|
|
61
|
+
issues.push({
|
|
62
|
+
part: 'orderBy',
|
|
63
|
+
field: sort.field,
|
|
64
|
+
reason: 'unknown-field',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
/** Throw on the first structurally invalid query part ({@link validateQuery}),
|
|
71
|
+
* naming every issue. The always-on entry point for engine adapters. */
|
|
72
|
+
export function assertValidQuery(query, searchType) {
|
|
73
|
+
const issues = validateQuery(query, searchType);
|
|
74
|
+
if (issues.length > 0) {
|
|
75
|
+
const detail = issues
|
|
76
|
+
.map((issue) => `${issue.part}: “${issue.field}” (${issue.reason})`)
|
|
77
|
+
.join(', ');
|
|
78
|
+
throw new Error(`Invalid search query for “${searchType.name}”: ${detail}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The 1-based page an `offset` falls on — the numbered-pagination presentation
|
|
83
|
+
* of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
|
|
84
|
+
* query) fetches no hits and has no meaningful page, so it pins to 1 rather
|
|
85
|
+
* than dividing by zero.
|
|
86
|
+
*/
|
|
87
|
+
export function pageForOffset(offset, limit) {
|
|
88
|
+
return limit > 0 ? Math.floor(offset / limit) + 1 : 1;
|
|
89
|
+
}
|