@dorsk/tsumikit 0.9.6 → 0.10.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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/query/ast.d.ts +31 -3
- package/dist/query/ast.js +36 -6
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +1 -1
- package/dist/query/parser.js +171 -56
- package/dist/query/query.d.ts +1 -1
- package/dist/query/query.js +130 -48
- package/dist/query/suggest.d.ts +0 -7
- package/dist/query/suggest.js +19 -8
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
|
50
50
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
51
51
|
export { type Column, default as DataTable } from './components/organisms/DataTable.svelte';
|
|
52
52
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
53
|
-
export { activeToken, compilePredicate, defaultOperator, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, OPERATORS, type Operator, type OperatorId, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueOption, type ValueProvider, } from './query';
|
|
53
|
+
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueOption, type ValueProvider, walk, } from './query';
|
|
54
54
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
55
55
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
|
56
56
|
export { type Toast, type ToastTone, toasts } from './stores/toast.svelte';
|
package/dist/index.js
CHANGED
|
@@ -62,7 +62,7 @@ export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
|
62
62
|
export { default as DataTable } from './components/organisms/DataTable.svelte';
|
|
63
63
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
64
64
|
// ---- query core (headless: schema / parser / AST / suggest / compilers) ----
|
|
65
|
-
export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, } from './query';
|
|
65
|
+
export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, walk, } from './query';
|
|
66
66
|
export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
|
67
67
|
// ---- stores / actions ----
|
|
68
68
|
export { THEMES, theme } from './stores/theme.svelte';
|
package/dist/query/ast.d.ts
CHANGED
|
@@ -15,10 +15,38 @@ export interface TextNode {
|
|
|
15
15
|
value: string;
|
|
16
16
|
span: [number, number];
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** Conjunction — every child must match. Implicit between adjacent clauses. */
|
|
19
|
+
export interface AndNode {
|
|
20
|
+
kind: 'and';
|
|
21
|
+
children: ExprNode[];
|
|
22
|
+
}
|
|
23
|
+
/** Disjunction — at least one child must match. Written with `OR`. */
|
|
24
|
+
export interface OrNode {
|
|
25
|
+
kind: 'or';
|
|
26
|
+
children: ExprNode[];
|
|
27
|
+
}
|
|
28
|
+
/** Boolean negation of a whole sub-expression. Written with `NOT`. */
|
|
29
|
+
export interface NotNode {
|
|
30
|
+
kind: 'not';
|
|
31
|
+
child: ExprNode;
|
|
32
|
+
}
|
|
33
|
+
/** A leaf of the expression tree. */
|
|
34
|
+
export type LeafNode = FilterNode | TextNode;
|
|
35
|
+
/** Any node in the boolean expression tree. */
|
|
36
|
+
export type ExprNode = AndNode | OrNode | NotNode | LeafNode;
|
|
37
|
+
/**
|
|
38
|
+
* Backwards-compatible alias for a leaf node. Historically `QueryNode` was the
|
|
39
|
+
* only node type (the tree was a flat list of these); it now names the leaf
|
|
40
|
+
* union so existing type imports keep resolving.
|
|
41
|
+
*/
|
|
42
|
+
export type QueryNode = LeafNode;
|
|
19
43
|
export interface Query {
|
|
20
|
-
/**
|
|
21
|
-
|
|
44
|
+
/** Root of the boolean expression tree, or `null` for an empty query. */
|
|
45
|
+
root: ExprNode | null;
|
|
22
46
|
}
|
|
47
|
+
/** Depth-first walk of every node in the tree (leaves included). */
|
|
48
|
+
export declare function walk(node: ExprNode | null, visit: (n: ExprNode) => void): void;
|
|
49
|
+
/** Every filter leaf in the tree, in source order. */
|
|
23
50
|
export declare function filters(q: Query): FilterNode[];
|
|
51
|
+
/** All free-text leaves joined — the flat full-text fragment. */
|
|
24
52
|
export declare function freeText(q: Query): string;
|
package/dist/query/ast.js
CHANGED
|
@@ -3,14 +3,44 @@
|
|
|
3
3
|
// the autocomplete UI and the backend compiler consume. The frontend never
|
|
4
4
|
// asks the server to re-parse free text; it ships this tree (or the server
|
|
5
5
|
// runs the same parser and gets the same tree).
|
|
6
|
+
//
|
|
7
|
+
// The tree is a boolean expression: `And` / `Or` / `Not` nodes wrap the leaf
|
|
8
|
+
// `FilterNode` / `TextNode`. The leaf shapes are unchanged from the flat-AND
|
|
9
|
+
// era, so `schema.ts` and value providers are untouched. A query with no
|
|
10
|
+
// explicit `OR` / `NOT` / parens parses to a single `And` of its leaves — the
|
|
11
|
+
// same result set as the old implicit-AND clause list.
|
|
6
12
|
// ─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
/** Depth-first walk of every node in the tree (leaves included). */
|
|
14
|
+
export function walk(node, visit) {
|
|
15
|
+
if (!node)
|
|
16
|
+
return;
|
|
17
|
+
visit(node);
|
|
18
|
+
switch (node.kind) {
|
|
19
|
+
case 'and':
|
|
20
|
+
case 'or':
|
|
21
|
+
for (const c of node.children)
|
|
22
|
+
walk(c, visit);
|
|
23
|
+
break;
|
|
24
|
+
case 'not':
|
|
25
|
+
walk(node.child, visit);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Every filter leaf in the tree, in source order. */
|
|
7
30
|
export function filters(q) {
|
|
8
|
-
|
|
31
|
+
const out = [];
|
|
32
|
+
walk(q.root, (n) => {
|
|
33
|
+
if (n.kind === 'filter')
|
|
34
|
+
out.push(n);
|
|
35
|
+
});
|
|
36
|
+
return out;
|
|
9
37
|
}
|
|
38
|
+
/** All free-text leaves joined — the flat full-text fragment. */
|
|
10
39
|
export function freeText(q) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
40
|
+
const parts = [];
|
|
41
|
+
walk(q.root, (n) => {
|
|
42
|
+
if (n.kind === 'text')
|
|
43
|
+
parts.push(n.value);
|
|
44
|
+
});
|
|
45
|
+
return parts.join(' ').trim();
|
|
16
46
|
}
|
package/dist/query/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { FilterNode, Query, QueryNode, TextNode } from './ast';
|
|
2
|
-
export { filters, freeText } from './ast';
|
|
1
|
+
export type { AndNode, ExprNode, FilterNode, LeafNode, NotNode, OrNode, Query, QueryNode, TextNode, } from './ast';
|
|
2
|
+
export { filters, freeText, walk } from './ast';
|
|
3
3
|
export { parse } from './parser';
|
|
4
4
|
export { compilePredicate, serialize, serializeFilter, toSql } from './query';
|
|
5
5
|
export type { FieldDef, FieldType, Operator, OperatorId, Schema, ValueOption, ValueProvider, } from './schema';
|
package/dist/query/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// serialisable AST, autocomplete engine and AST compilers. Framework-agnostic:
|
|
3
3
|
// no Svelte, no DOM. A host app supplies a `Schema` (optionally with async
|
|
4
4
|
// `ValueProvider`s) and consumes the `Query` AST against its own backend.
|
|
5
|
-
export { filters, freeText } from './ast';
|
|
5
|
+
export { filters, freeText, walk } from './ast';
|
|
6
6
|
export { parse } from './parser';
|
|
7
7
|
export { compilePredicate, serialize, serializeFilter, toSql } from './query';
|
|
8
8
|
export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
|
package/dist/query/parser.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// Layer 2 — a tolerant, error-recovering parser.
|
|
2
|
+
// Layer 2 — a tolerant, error-recovering recursive-descent parser.
|
|
3
3
|
//
|
|
4
|
-
// Hand-written
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Hand-written (not a grammar generator) precisely BECAUSE the input is "free
|
|
5
|
+
// for all": we must parse partial input (`artist:` with no value, for
|
|
6
|
+
// autocomplete) and slightly-wrong input (a malformed clause must degrade to
|
|
7
|
+
// free text, never throw). Output is the serialisable expression tree in
|
|
8
|
+
// ast.ts.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
+
// Grammar (precedence NOT > AND > OR; AND is implicit between adjacent terms):
|
|
11
|
+
// orExpr := andExpr ( OR andExpr )*
|
|
12
|
+
// andExpr := notExpr ( (AND)? notExpr )*
|
|
13
|
+
// notExpr := (NOT)? primary
|
|
14
|
+
// primary := '(' orExpr ')' | filter | text-word
|
|
15
|
+
//
|
|
16
|
+
// Leaf textual forms understood:
|
|
10
17
|
// artist:"Daft Punk" → contains
|
|
11
18
|
// artist!:bootleg → not_contains
|
|
12
19
|
// year>=2021 released<2020 → numeric/date comparisons
|
|
@@ -14,16 +21,26 @@
|
|
|
14
21
|
// genre in (house, techno) → any of
|
|
15
22
|
// year..2021..2024 → range (field..a..b) also field:a..b
|
|
16
23
|
// free floating words → TextNode (full-text)
|
|
17
|
-
//
|
|
24
|
+
//
|
|
25
|
+
// Error recovery: unmatched `)` is ignored, a missing `)` closes at end of
|
|
26
|
+
// input, a dangling AND/OR/NOT with no operand collapses to its operand, and an
|
|
27
|
+
// empty group yields nothing. The parser never throws.
|
|
18
28
|
// ─────────────────────────────────────────────────────────────────────────
|
|
19
29
|
import { findField, operatorByCode, operatorsFor } from './schema';
|
|
20
30
|
const IDENT = /[A-Za-z_][\w-]*/y;
|
|
21
31
|
// Longest operator codes first so `>=` wins over `>`.
|
|
22
32
|
const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
|
|
33
|
+
const KEYWORDS = new Set(['AND', 'OR', 'NOT']);
|
|
23
34
|
function skipWs(sc) {
|
|
24
35
|
while (sc.i < sc.s.length && /\s/.test(sc.s[sc.i]))
|
|
25
36
|
sc.i++;
|
|
26
37
|
}
|
|
38
|
+
// Only skip spaces/tabs; used so `artist: foo` (space after colon) still
|
|
39
|
+
// attaches `foo` as the value without crossing a real token boundary.
|
|
40
|
+
function skipWsInline(sc) {
|
|
41
|
+
while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
|
|
42
|
+
sc.i++;
|
|
43
|
+
}
|
|
27
44
|
/** Read a value token: a "quoted string", or a bare run up to whitespace. */
|
|
28
45
|
function readValue(sc) {
|
|
29
46
|
skipWsInline(sc);
|
|
@@ -42,17 +59,12 @@ function readValue(sc) {
|
|
|
42
59
|
!/\s/.test(sc.s[sc.i]) &&
|
|
43
60
|
sc.s[sc.i] !== ',' &&
|
|
44
61
|
sc.s[sc.i] !== ')' &&
|
|
62
|
+
sc.s[sc.i] !== '(' &&
|
|
45
63
|
// stop at a `..` range separator so `a..b` splits into two values
|
|
46
64
|
!sc.s.startsWith('..', sc.i))
|
|
47
65
|
sc.i++;
|
|
48
66
|
return { value: sc.s.slice(start, sc.i), end: sc.i };
|
|
49
67
|
}
|
|
50
|
-
// Only skip spaces/tabs, never cross to a new token boundary semantics; used so
|
|
51
|
-
// `artist: foo` (space after colon) still attaches `foo` as the value.
|
|
52
|
-
function skipWsInline(sc) {
|
|
53
|
-
while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
|
|
54
|
-
sc.i++;
|
|
55
|
-
}
|
|
56
68
|
function matchOp(s, i) {
|
|
57
69
|
for (const code of OP_CODES) {
|
|
58
70
|
if (s.startsWith(code, i))
|
|
@@ -60,57 +72,160 @@ function matchOp(s, i) {
|
|
|
60
72
|
}
|
|
61
73
|
return null;
|
|
62
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Peek at an upcoming connective keyword (AND / OR / NOT) without consuming it.
|
|
77
|
+
* A keyword only counts when it stands alone — i.e. it is NOT immediately
|
|
78
|
+
* followed by an operator, so `and:foo` or `or=x` stay field clauses. Returns
|
|
79
|
+
* the keyword and the index just past it, or null.
|
|
80
|
+
*/
|
|
81
|
+
function peekKeyword(sc) {
|
|
82
|
+
let j = sc.i;
|
|
83
|
+
while (j < sc.s.length && /\s/.test(sc.s[j]))
|
|
84
|
+
j++;
|
|
85
|
+
IDENT.lastIndex = j;
|
|
86
|
+
const m = IDENT.exec(sc.s);
|
|
87
|
+
if (!m || m.index !== j)
|
|
88
|
+
return null;
|
|
89
|
+
const word = m[0].toUpperCase();
|
|
90
|
+
if (!KEYWORDS.has(word))
|
|
91
|
+
return null;
|
|
92
|
+
// If an operator follows the ident it is a field name, not a connective.
|
|
93
|
+
let k = j + m[0].length;
|
|
94
|
+
while (sc.s[k] === ' ')
|
|
95
|
+
k++;
|
|
96
|
+
if (matchOp(sc.s, k))
|
|
97
|
+
return null;
|
|
98
|
+
return { word, next: j + m[0].length };
|
|
99
|
+
}
|
|
63
100
|
export function parse(input, schema) {
|
|
64
101
|
const sc = { s: input, i: 0 };
|
|
65
|
-
const
|
|
66
|
-
|
|
102
|
+
const root = parseOr(sc, schema);
|
|
103
|
+
return { root };
|
|
104
|
+
}
|
|
105
|
+
// orExpr := andExpr ( OR andExpr )*
|
|
106
|
+
function parseOr(sc, schema) {
|
|
107
|
+
const children = [];
|
|
108
|
+
const first = parseAnd(sc, schema);
|
|
109
|
+
if (first)
|
|
110
|
+
children.push(first);
|
|
111
|
+
for (;;) {
|
|
112
|
+
const kw = peekKeyword(sc);
|
|
113
|
+
if (!kw || kw.word !== 'OR')
|
|
114
|
+
break;
|
|
115
|
+
sc.i = kw.next;
|
|
116
|
+
const next = parseAnd(sc, schema);
|
|
117
|
+
if (next)
|
|
118
|
+
children.push(next);
|
|
119
|
+
}
|
|
120
|
+
if (children.length === 0)
|
|
121
|
+
return first ?? null;
|
|
122
|
+
if (children.length === 1)
|
|
123
|
+
return children[0];
|
|
124
|
+
return { kind: 'or', children };
|
|
125
|
+
}
|
|
126
|
+
// andExpr := notExpr ( (AND)? notExpr )*
|
|
127
|
+
function parseAnd(sc, schema) {
|
|
128
|
+
const children = [];
|
|
129
|
+
for (;;) {
|
|
67
130
|
skipWs(sc);
|
|
68
131
|
if (sc.i >= sc.s.length)
|
|
69
132
|
break;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
sc.i = afterIdent;
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const field = findField(schema, ident);
|
|
83
|
-
// Look for an operator right after the identifier (allow inline spaces).
|
|
84
|
-
let j = afterIdent;
|
|
85
|
-
while (sc.s[j] === ' ')
|
|
86
|
-
j++;
|
|
87
|
-
const inKeyword = sc.s.slice(j, j + 3).toLowerCase() === 'in ' || sc.s.slice(j) === 'in';
|
|
88
|
-
const opMatch = matchOp(sc.s, j);
|
|
89
|
-
if (field && (opMatch || inKeyword)) {
|
|
90
|
-
const node = readFilter(sc, field, clauseStart, j, opMatch, inKeyword);
|
|
91
|
-
if (node) {
|
|
92
|
-
nodes.push(node);
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
133
|
+
if (sc.s[sc.i] === ')')
|
|
134
|
+
break;
|
|
135
|
+
const kw = peekKeyword(sc);
|
|
136
|
+
if (kw?.word === 'OR')
|
|
137
|
+
break; // let the OR level handle it
|
|
138
|
+
let explicitAnd = false;
|
|
139
|
+
if (kw?.word === 'AND') {
|
|
140
|
+
sc.i = kw.next;
|
|
141
|
+
explicitAnd = true;
|
|
96
142
|
}
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
143
|
+
const before = sc.i;
|
|
144
|
+
const node = parseNot(sc, schema);
|
|
145
|
+
if (!node) {
|
|
146
|
+
// No progress → avoid an infinite loop on stray tokens.
|
|
147
|
+
if (sc.i === before)
|
|
148
|
+
break;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Merge adjacent implicit-AND free-text words into one TextNode so a bare
|
|
152
|
+
// `foo bar` still matches the contiguous phrase (old flat-AND behaviour).
|
|
153
|
+
const last = children[children.length - 1];
|
|
154
|
+
if (!explicitAnd && node.kind === 'text' && last && last.kind === 'text') {
|
|
155
|
+
last.value += ` ${node.value}`;
|
|
156
|
+
last.span[1] = node.span[1];
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
children.push(node);
|
|
160
|
+
}
|
|
161
|
+
if (children.length === 0)
|
|
162
|
+
return null;
|
|
163
|
+
if (children.length === 1)
|
|
164
|
+
return children[0];
|
|
165
|
+
return { kind: 'and', children };
|
|
166
|
+
}
|
|
167
|
+
// notExpr := (NOT)? primary
|
|
168
|
+
function parseNot(sc, schema) {
|
|
169
|
+
const kw = peekKeyword(sc);
|
|
170
|
+
if (kw?.word === 'NOT') {
|
|
171
|
+
sc.i = kw.next;
|
|
172
|
+
const child = parseNot(sc, schema);
|
|
173
|
+
if (!child)
|
|
174
|
+
return null; // dangling NOT → nothing
|
|
175
|
+
return { kind: 'not', child };
|
|
176
|
+
}
|
|
177
|
+
return parsePrimary(sc, schema);
|
|
178
|
+
}
|
|
179
|
+
// primary := '(' orExpr ')' | filter | text-word
|
|
180
|
+
function parsePrimary(sc, schema) {
|
|
181
|
+
skipWs(sc);
|
|
182
|
+
if (sc.i >= sc.s.length)
|
|
183
|
+
return null;
|
|
184
|
+
// Parenthesised group.
|
|
185
|
+
if (sc.s[sc.i] === '(') {
|
|
186
|
+
sc.i++;
|
|
187
|
+
const inner = parseOr(sc, schema);
|
|
188
|
+
skipWs(sc);
|
|
189
|
+
if (sc.s[sc.i] === ')')
|
|
190
|
+
sc.i++; // tolerate a missing close paren
|
|
191
|
+
return inner; // empty group → null
|
|
192
|
+
}
|
|
193
|
+
if (sc.s[sc.i] === ')')
|
|
194
|
+
return null;
|
|
195
|
+
// A connective keyword here isn't a leaf; let the caller handle it.
|
|
196
|
+
if (peekKeyword(sc))
|
|
197
|
+
return null;
|
|
198
|
+
const leaf = readLeaf(sc, schema);
|
|
199
|
+
return leaf;
|
|
200
|
+
}
|
|
201
|
+
/** Read a single leaf: a `field op value` filter, or one free-text word. */
|
|
202
|
+
function readLeaf(sc, schema) {
|
|
203
|
+
const clauseStart = sc.i;
|
|
204
|
+
IDENT.lastIndex = sc.i;
|
|
205
|
+
const m = IDENT.exec(sc.s);
|
|
206
|
+
if (m && m.index === sc.i) {
|
|
207
|
+
const ident = m[0];
|
|
208
|
+
const afterIdent = sc.i + ident.length;
|
|
209
|
+
const field = findField(schema, ident);
|
|
210
|
+
let j = afterIdent;
|
|
211
|
+
while (sc.s[j] === ' ')
|
|
212
|
+
j++;
|
|
213
|
+
const inKeyword = sc.s.slice(j, j + 3).toLowerCase() === 'in ' || sc.s.slice(j) === 'in';
|
|
214
|
+
const opMatch = matchOp(sc.s, j);
|
|
215
|
+
if (field && (opMatch || inKeyword)) {
|
|
216
|
+
const node = readFilter(sc, field, clauseStart, j, opMatch, inKeyword);
|
|
217
|
+
if (node)
|
|
218
|
+
return node;
|
|
111
219
|
}
|
|
112
220
|
}
|
|
113
|
-
|
|
221
|
+
// Fallback: consume one word (up to whitespace or a paren) as free text.
|
|
222
|
+
const wStart = sc.i;
|
|
223
|
+
while (sc.i < sc.s.length && !/\s/.test(sc.s[sc.i]) && sc.s[sc.i] !== '(' && sc.s[sc.i] !== ')')
|
|
224
|
+
sc.i++;
|
|
225
|
+
const word = sc.s.slice(wStart, sc.i);
|
|
226
|
+
if (!word)
|
|
227
|
+
return null;
|
|
228
|
+
return { kind: 'text', value: word, span: [wStart, sc.i] };
|
|
114
229
|
}
|
|
115
230
|
function readFilter(sc, field, clauseStart, opPos, opMatch, inKeyword) {
|
|
116
231
|
const legal = operatorsFor(field);
|
package/dist/query/query.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { FilterNode, Query } from './ast';
|
|
2
2
|
/** AST → canonical textual query (round-trips through the parser). */
|
|
3
3
|
export declare function serialize(q: Query): string;
|
|
4
4
|
export declare function serializeFilter(f: FilterNode): string;
|
package/dist/query/query.js
CHANGED
|
@@ -2,22 +2,51 @@
|
|
|
2
2
|
// AST consumers: serialise back to the textual query (for the code editor and
|
|
3
3
|
// the URL), compile to a human-readable SQL-ish WHERE (to prove the filter
|
|
4
4
|
// "properly propagates to backend & database"), and compile to a JS predicate
|
|
5
|
-
// (our fake in-memory backend).
|
|
5
|
+
// (our fake in-memory backend). All three walk the boolean expression tree with
|
|
6
|
+
// correct precedence / grouping / short-circuiting.
|
|
6
7
|
// ─────────────────────────────────────────────────────────────────────────
|
|
7
|
-
import { filters, freeText } from './ast';
|
|
8
8
|
import { operatorById } from './schema';
|
|
9
9
|
function quoteIfNeeded(v) {
|
|
10
10
|
return /[\s,()]/.test(v) || v === '' ? `"${v}"` : v;
|
|
11
11
|
}
|
|
12
|
+
// ── Serialiser (AST → canonical textual query, round-trips through parse) ────
|
|
13
|
+
/** Precedence rank: higher binds tighter. Leaves are atomic. */
|
|
14
|
+
function rank(node) {
|
|
15
|
+
switch (node.kind) {
|
|
16
|
+
case 'or':
|
|
17
|
+
return 1;
|
|
18
|
+
case 'and':
|
|
19
|
+
return 2;
|
|
20
|
+
case 'not':
|
|
21
|
+
return 3;
|
|
22
|
+
default:
|
|
23
|
+
return 4; // leaf
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Serialise `node`, wrapping in parens when its precedence is below `min`. */
|
|
27
|
+
function serializeNode(node, min) {
|
|
28
|
+
const s = serializeBare(node);
|
|
29
|
+
return rank(node) < min ? `(${s})` : s;
|
|
30
|
+
}
|
|
31
|
+
function serializeBare(node) {
|
|
32
|
+
switch (node.kind) {
|
|
33
|
+
case 'filter':
|
|
34
|
+
return serializeFilter(node);
|
|
35
|
+
case 'text':
|
|
36
|
+
return node.value;
|
|
37
|
+
case 'not':
|
|
38
|
+
// Child must bind at least as tight as NOT, else parenthesise.
|
|
39
|
+
return `NOT ${serializeNode(node.child, 3)}`;
|
|
40
|
+
case 'and':
|
|
41
|
+
// Children need to sit above OR precedence; AND itself is implicit (space).
|
|
42
|
+
return node.children.map((c) => serializeNode(c, 2)).join(' ');
|
|
43
|
+
case 'or':
|
|
44
|
+
return node.children.map((c) => serializeNode(c, 1)).join(' OR ');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
12
47
|
/** AST → canonical textual query (round-trips through the parser). */
|
|
13
48
|
export function serialize(q) {
|
|
14
|
-
|
|
15
|
-
for (const f of filters(q))
|
|
16
|
-
parts.push(serializeFilter(f));
|
|
17
|
-
const text = freeText(q);
|
|
18
|
-
if (text)
|
|
19
|
-
parts.push(text);
|
|
20
|
-
return parts.join(' ');
|
|
49
|
+
return q.root ? serializeBare(q.root) : '';
|
|
21
50
|
}
|
|
22
51
|
export function serializeFilter(f) {
|
|
23
52
|
const op = operatorById(f.op);
|
|
@@ -38,32 +67,41 @@ const SQL_OP = {
|
|
|
38
67
|
lt: '<',
|
|
39
68
|
lte: '<=',
|
|
40
69
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
clauses.push(`${col} BETWEEN ${v(f.values[0])} AND ${v(f.values[1])}`);
|
|
58
|
-
break;
|
|
59
|
-
default:
|
|
60
|
-
clauses.push(`${col} ${SQL_OP[f.op]} ${v(f.values[0] ?? '')}`);
|
|
61
|
-
}
|
|
70
|
+
function sqlLiteral(s) {
|
|
71
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
72
|
+
}
|
|
73
|
+
function filterToSql(f) {
|
|
74
|
+
const col = f.field;
|
|
75
|
+
switch (f.op) {
|
|
76
|
+
case 'contains':
|
|
77
|
+
return `${col} ILIKE '%${f.values[0]}%'`;
|
|
78
|
+
case 'not_contains':
|
|
79
|
+
return `${col} NOT ILIKE '%${f.values[0]}%'`;
|
|
80
|
+
case 'in':
|
|
81
|
+
return `${col} IN (${f.values.map(sqlLiteral).join(', ')})`;
|
|
82
|
+
case 'range':
|
|
83
|
+
return `${col} BETWEEN ${sqlLiteral(f.values[0])} AND ${sqlLiteral(f.values[1])}`;
|
|
84
|
+
default:
|
|
85
|
+
return `${col} ${SQL_OP[f.op]} ${sqlLiteral(f.values[0] ?? '')}`;
|
|
62
86
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
}
|
|
88
|
+
/** Serialise a node to a SQL boolean expression, parenthesising sub-groups. */
|
|
89
|
+
function nodeToSql(node) {
|
|
90
|
+
switch (node.kind) {
|
|
91
|
+
case 'filter':
|
|
92
|
+
return filterToSql(node);
|
|
93
|
+
case 'text':
|
|
94
|
+
return `to_tsvector(title) @@ plainto_tsquery(${sqlLiteral(node.value)})`;
|
|
95
|
+
case 'not':
|
|
96
|
+
return `NOT (${nodeToSql(node.child)})`;
|
|
97
|
+
case 'and':
|
|
98
|
+
return node.children.map((c) => `(${nodeToSql(c)})`).join(' AND ');
|
|
99
|
+
case 'or':
|
|
100
|
+
return node.children.map((c) => `(${nodeToSql(c)})`).join(' OR ');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function toSql(q, table = 'tracks') {
|
|
104
|
+
const where = q.root ? nodeToSql(q.root) : 'TRUE';
|
|
67
105
|
return `SELECT * FROM ${table}\nWHERE ${where};`;
|
|
68
106
|
}
|
|
69
107
|
function asNumber(v) {
|
|
@@ -108,21 +146,65 @@ function matchFilter(f, row) {
|
|
|
108
146
|
return true;
|
|
109
147
|
}
|
|
110
148
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Is this leaf still incomplete (mid-typing)? Such leaves are neutral — they
|
|
151
|
+
* neither match nor reject — so a half-written clause never changes the result
|
|
152
|
+
* set, matching the old flat-AND "ignore incomplete clauses" behaviour.
|
|
153
|
+
*/
|
|
154
|
+
function isIncomplete(node) {
|
|
155
|
+
if (node.kind === 'filter')
|
|
156
|
+
return node.values.every((v) => v === '');
|
|
157
|
+
if (node.kind === 'text')
|
|
158
|
+
return node.value.trim() === '';
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/** Evaluate a node against a row. `null` = neutral (skip / don't constrain). */
|
|
162
|
+
function evalNode(node, row) {
|
|
163
|
+
if (isIncomplete(node))
|
|
164
|
+
return null;
|
|
165
|
+
switch (node.kind) {
|
|
166
|
+
case 'filter':
|
|
167
|
+
return matchFilter(node, row);
|
|
168
|
+
case 'text': {
|
|
122
169
|
const hay = Object.values(row).join(' ').toLowerCase();
|
|
123
|
-
|
|
124
|
-
|
|
170
|
+
return hay.includes(node.value.trim().toLowerCase());
|
|
171
|
+
}
|
|
172
|
+
case 'not': {
|
|
173
|
+
const inner = evalNode(node.child, row);
|
|
174
|
+
return inner === null ? null : !inner;
|
|
125
175
|
}
|
|
126
|
-
|
|
176
|
+
case 'and': {
|
|
177
|
+
let sawReal = false;
|
|
178
|
+
for (const c of node.children) {
|
|
179
|
+
const r = evalNode(c, row);
|
|
180
|
+
if (r === null)
|
|
181
|
+
continue; // neutral
|
|
182
|
+
sawReal = true;
|
|
183
|
+
if (!r)
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return sawReal ? true : null;
|
|
187
|
+
}
|
|
188
|
+
case 'or': {
|
|
189
|
+
let sawReal = false;
|
|
190
|
+
for (const c of node.children) {
|
|
191
|
+
const r = evalNode(c, row);
|
|
192
|
+
if (r === null)
|
|
193
|
+
continue;
|
|
194
|
+
sawReal = true;
|
|
195
|
+
if (r)
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return sawReal ? false : null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export function compilePredicate(q) {
|
|
203
|
+
const root = q.root;
|
|
204
|
+
return (row) => {
|
|
205
|
+
if (!root)
|
|
206
|
+
return true;
|
|
207
|
+
const r = evalNode(root, row);
|
|
208
|
+
return r === null ? true : r;
|
|
127
209
|
};
|
|
128
210
|
}
|
package/dist/query/suggest.d.ts
CHANGED
|
@@ -20,13 +20,6 @@ export interface SuggestState {
|
|
|
20
20
|
span: [number, number];
|
|
21
21
|
items: Suggestion[];
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* The token under the caret. Whitespace ends a token EXCEPT inside an open
|
|
25
|
-
* quote, so a quoted value containing spaces (`album:"ok computer`) stays one
|
|
26
|
-
* token and keeps the caret in the value step rather than collapsing to an
|
|
27
|
-
* empty token (which would wrongly re-offer fields). A closed quote followed by
|
|
28
|
-
* whitespace starts a fresh token (so the next `field:` can be chained).
|
|
29
|
-
*/
|
|
30
23
|
export declare function activeToken(s: string, pos: number): {
|
|
31
24
|
start: number;
|
|
32
25
|
end: number;
|
package/dist/query/suggest.js
CHANGED
|
@@ -10,15 +10,19 @@ import { findField, operatorsFor, resolveValues, } from './schema';
|
|
|
10
10
|
// Longest first so `>=`/`!:` win over `>`/`:`.
|
|
11
11
|
const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
|
|
12
12
|
/**
|
|
13
|
-
* The token under the caret. Whitespace
|
|
14
|
-
* quote, so a quoted value containing spaces
|
|
15
|
-
* token and keeps the caret in the value step
|
|
16
|
-
* empty token (which would wrongly re-offer
|
|
17
|
-
*
|
|
13
|
+
* The token under the caret. Whitespace — and boolean grouping parens `(` `)` —
|
|
14
|
+
* end a token EXCEPT inside an open quote, so a quoted value containing spaces
|
|
15
|
+
* (`album:"ok computer`) stays one token and keeps the caret in the value step
|
|
16
|
+
* rather than collapsing to an empty token (which would wrongly re-offer
|
|
17
|
+
* fields). Breaking on parens means `(artist:` offers operators for a clean
|
|
18
|
+
* `artist`, and autocomplete fires at the right spot inside a group.
|
|
18
19
|
*/
|
|
20
|
+
function isBoundary(c) {
|
|
21
|
+
return /\s/.test(c) || c === '(' || c === ')';
|
|
22
|
+
}
|
|
19
23
|
export function activeToken(s, pos) {
|
|
20
24
|
// Walk from the start so we can track quote state; the last unquoted
|
|
21
|
-
//
|
|
25
|
+
// boundary before the caret is the token start.
|
|
22
26
|
let start = 0;
|
|
23
27
|
let inQuote = false;
|
|
24
28
|
let q = '';
|
|
@@ -32,7 +36,7 @@ export function activeToken(s, pos) {
|
|
|
32
36
|
inQuote = true;
|
|
33
37
|
q = c;
|
|
34
38
|
}
|
|
35
|
-
else if (
|
|
39
|
+
else if (isBoundary(c)) {
|
|
36
40
|
start = i + 1;
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -40,7 +44,7 @@ export function activeToken(s, pos) {
|
|
|
40
44
|
let end = pos;
|
|
41
45
|
for (let i = pos; i < s.length; i++) {
|
|
42
46
|
const c = s[i];
|
|
43
|
-
if (!inQuote &&
|
|
47
|
+
if (!inQuote && isBoundary(c))
|
|
44
48
|
break;
|
|
45
49
|
if (inQuote) {
|
|
46
50
|
if (c === q)
|
|
@@ -54,6 +58,9 @@ export function activeToken(s, pos) {
|
|
|
54
58
|
}
|
|
55
59
|
return { start, end, raw: s.slice(start, end) };
|
|
56
60
|
}
|
|
61
|
+
// The boolean connectives are not fields/operators/values; when the caret sits
|
|
62
|
+
// on one there is nothing to autocomplete (the next token gets fresh fields).
|
|
63
|
+
const CONNECTIVES = new Set(['AND', 'OR', 'NOT']);
|
|
57
64
|
/** Split a token into field / operator-code / value parts (earliest op wins). */
|
|
58
65
|
function splitToken(raw) {
|
|
59
66
|
let best = -1;
|
|
@@ -139,6 +146,10 @@ export async function suggest(schema, s, pos) {
|
|
|
139
146
|
if (raw === '')
|
|
140
147
|
return fieldSuggestions(schema, '', span);
|
|
141
148
|
const { field: fieldFrag, opCode, value } = splitToken(raw);
|
|
149
|
+
// The caret is parked on a bare boolean connective (`AND`/`OR`/`NOT`) — not a
|
|
150
|
+
// field, so suppress the dropdown until the next token begins.
|
|
151
|
+
if (opCode === null && CONNECTIVES.has(fieldFrag.toUpperCase()))
|
|
152
|
+
return null;
|
|
142
153
|
const field = findField(schema, fieldFrag);
|
|
143
154
|
// No operator typed yet.
|
|
144
155
|
if (opCode === null) {
|
package/package.json
CHANGED