@svgrid/enterprise 2.2.1 → 2.3.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 +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-text expression parsing, stringifying, and validation.
|
|
3
|
+
*
|
|
4
|
+
* The structured builder in `SvExpressionEditor` writes the AST directly; this
|
|
5
|
+
* module powers the optional free-text mode and the "explain this rule" display.
|
|
6
|
+
* The parser is a small recursive-descent / precedence parser - deliberately
|
|
7
|
+
* dependency-free.
|
|
8
|
+
*
|
|
9
|
+
* Grammar (informal):
|
|
10
|
+
* predicate := or
|
|
11
|
+
* or := and ( "OR" and )*
|
|
12
|
+
* and := not ( "AND" not )*
|
|
13
|
+
* not := "NOT" not | primary
|
|
14
|
+
* primary := "(" predicate ")" | comparison | boolLiteral
|
|
15
|
+
* comparison := scalar ( symOp scalar
|
|
16
|
+
* | "CONTAINS"|"STARTSWITH"|"ENDSWITH"|"MATCHES" scalar
|
|
17
|
+
* | "IN" "(" scalarList ")"
|
|
18
|
+
* | "BETWEEN" scalar "AND" scalar
|
|
19
|
+
* | "ISBLANK" | "ISNOTBLANK" )
|
|
20
|
+
* scalar := add ; add := mul (("+"|"-") mul)* ; mul := unary (("*"|"/"|"%") unary)*
|
|
21
|
+
* unary := "-" unary | atom
|
|
22
|
+
* atom := number | string | TRUE|FALSE|NULL | colRef
|
|
23
|
+
* | ("SUM"|"AVG"|"COUNT") "(" colRef ")" | ident "(" args ")" | "(" scalar ")"
|
|
24
|
+
* colRef := ident | "[" ... "]"
|
|
25
|
+
*/
|
|
26
|
+
import type { ExcelFilterOperator } from '@svgrid/grid'
|
|
27
|
+
import type {
|
|
28
|
+
ComparisonOp,
|
|
29
|
+
PredicateExpr,
|
|
30
|
+
ScalarExpr,
|
|
31
|
+
} from './expression-types'
|
|
32
|
+
import {
|
|
33
|
+
columnRefText,
|
|
34
|
+
resolveColumnRef,
|
|
35
|
+
type ExprColumn,
|
|
36
|
+
} from './expression-columns'
|
|
37
|
+
import { isBuiltinFunction } from './evaluate'
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Tokenizer
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
type TokKind = 'num' | 'str' | 'ident' | 'bracket' | 'kw' | 'op' | 'eof'
|
|
44
|
+
type Token = { kind: TokKind; value: string; pos: number }
|
|
45
|
+
|
|
46
|
+
const KEYWORDS = new Set([
|
|
47
|
+
'AND', 'OR', 'NOT', 'TRUE', 'FALSE', 'NULL',
|
|
48
|
+
'CONTAINS', 'STARTSWITH', 'ENDSWITH', 'MATCHES',
|
|
49
|
+
'IN', 'BETWEEN', 'ISBLANK', 'ISNOTBLANK',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const AGG_KEYWORDS = new Set(['SUM', 'AVG', 'COUNT'])
|
|
53
|
+
|
|
54
|
+
function tokenize(input: string): Token[] {
|
|
55
|
+
const tokens: Token[] = []
|
|
56
|
+
let i = 0
|
|
57
|
+
const n = input.length
|
|
58
|
+
while (i < n) {
|
|
59
|
+
const c = input[i] as string // i < n, so always defined
|
|
60
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
61
|
+
i++
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const start = i
|
|
65
|
+
// Bracketed column reference: [ ... ]
|
|
66
|
+
if (c === '[') {
|
|
67
|
+
const end = input.indexOf(']', i + 1)
|
|
68
|
+
if (end < 0) throw new ExpressionParseError('Unclosed "[" reference', start)
|
|
69
|
+
tokens.push({ kind: 'bracket', value: input.slice(i + 1, end), pos: start })
|
|
70
|
+
i = end + 1
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
// String literal (single or double quote)
|
|
74
|
+
if (c === '"' || c === "'") {
|
|
75
|
+
let j = i + 1
|
|
76
|
+
let out = ''
|
|
77
|
+
while (j < n && input[j] !== c) {
|
|
78
|
+
if (input[j] === '\\' && j + 1 < n) {
|
|
79
|
+
out += input[j + 1]
|
|
80
|
+
j += 2
|
|
81
|
+
} else {
|
|
82
|
+
out += input[j]
|
|
83
|
+
j++
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (j >= n) throw new ExpressionParseError('Unterminated string', start)
|
|
87
|
+
tokens.push({ kind: 'str', value: out, pos: start })
|
|
88
|
+
i = j + 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// Number literal
|
|
92
|
+
if (isDigit(c) || (c === '.' && isDigit(input[i + 1] ?? ''))) {
|
|
93
|
+
let j = i
|
|
94
|
+
while (j < n && (isDigit(input[j]) || input[j] === '.')) j++
|
|
95
|
+
tokens.push({ kind: 'num', value: input.slice(i, j), pos: start })
|
|
96
|
+
i = j
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
// Identifier / keyword
|
|
100
|
+
if (isIdentStart(c)) {
|
|
101
|
+
let j = i
|
|
102
|
+
while (j < n && isIdentPart(input[j])) j++
|
|
103
|
+
const raw = input.slice(i, j)
|
|
104
|
+
const upper = raw.toUpperCase()
|
|
105
|
+
tokens.push({ kind: KEYWORDS.has(upper) ? 'kw' : 'ident', value: KEYWORDS.has(upper) ? upper : raw, pos: start })
|
|
106
|
+
i = j
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
// Multi-char operators
|
|
110
|
+
const two = input.slice(i, i + 2)
|
|
111
|
+
if (two === '>=' || two === '<=' || two === '!=' || two === '<>' || two === '==') {
|
|
112
|
+
tokens.push({ kind: 'op', value: two === '<>' ? '!=' : two === '==' ? '=' : two, pos: start })
|
|
113
|
+
i += 2
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if ('=<>+-*/%(),'.includes(c)) {
|
|
117
|
+
tokens.push({ kind: 'op', value: c, pos: start })
|
|
118
|
+
i++
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
throw new ExpressionParseError(`Unexpected character "${c}"`, start)
|
|
122
|
+
}
|
|
123
|
+
tokens.push({ kind: 'eof', value: '', pos: n })
|
|
124
|
+
return tokens
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isDigit(c: string | undefined): boolean {
|
|
128
|
+
return c != null && c >= '0' && c <= '9'
|
|
129
|
+
}
|
|
130
|
+
function isIdentStart(c: string | undefined): boolean {
|
|
131
|
+
return c != null && /[A-Za-z_$]/.test(c)
|
|
132
|
+
}
|
|
133
|
+
function isIdentPart(c: string | undefined): boolean {
|
|
134
|
+
return c != null && /[\w$.]/.test(c)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Thrown by `parsePredicate` with a character offset into the source. */
|
|
138
|
+
export class ExpressionParseError extends Error {
|
|
139
|
+
pos: number
|
|
140
|
+
constructor(message: string, pos: number) {
|
|
141
|
+
super(message)
|
|
142
|
+
this.name = 'ExpressionParseError'
|
|
143
|
+
this.pos = pos
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Parser
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const SYM_TO_CMP: Partial<Record<string, ExcelFilterOperator>> = {
|
|
152
|
+
'=': 'equals',
|
|
153
|
+
'!=': 'notEquals',
|
|
154
|
+
'>': 'greaterThan',
|
|
155
|
+
'<': 'lessThan',
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class Parser {
|
|
159
|
+
private pos = 0
|
|
160
|
+
constructor(
|
|
161
|
+
private readonly tokens: Token[],
|
|
162
|
+
private readonly columns: ReadonlyArray<ExprColumn>,
|
|
163
|
+
) {}
|
|
164
|
+
|
|
165
|
+
private peek(): Token {
|
|
166
|
+
// The token stream always ends with an `eof` token, so `pos` is in range.
|
|
167
|
+
return this.tokens[this.pos] as Token
|
|
168
|
+
}
|
|
169
|
+
private next(): Token {
|
|
170
|
+
return this.tokens[this.pos++] as Token
|
|
171
|
+
}
|
|
172
|
+
private eat(kind: TokKind, value?: string): Token {
|
|
173
|
+
const t = this.peek()
|
|
174
|
+
if (t.kind !== kind || (value != null && t.value !== value)) {
|
|
175
|
+
throw new ExpressionParseError(
|
|
176
|
+
`Expected ${value ?? kind}${t.kind === 'eof' ? ' but reached the end' : `, got "${t.value || t.kind}"`}`,
|
|
177
|
+
t.pos,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
return this.next()
|
|
181
|
+
}
|
|
182
|
+
private isKw(v: string): boolean {
|
|
183
|
+
const t = this.peek()
|
|
184
|
+
return t.kind === 'kw' && t.value === v
|
|
185
|
+
}
|
|
186
|
+
private isOp(v: string): boolean {
|
|
187
|
+
const t = this.peek()
|
|
188
|
+
return t.kind === 'op' && t.value === v
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
parse(): PredicateExpr {
|
|
192
|
+
const expr = this.parseOr()
|
|
193
|
+
if (this.peek().kind !== 'eof') {
|
|
194
|
+
throw new ExpressionParseError(`Unexpected "${this.peek().value}"`, this.peek().pos)
|
|
195
|
+
}
|
|
196
|
+
return expr
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private parseOr(): PredicateExpr {
|
|
200
|
+
const parts = [this.parseAnd()]
|
|
201
|
+
while (this.isKw('OR')) {
|
|
202
|
+
this.next()
|
|
203
|
+
parts.push(this.parseAnd())
|
|
204
|
+
}
|
|
205
|
+
return parts.length === 1 ? (parts[0] as PredicateExpr) : { kind: 'or', parts }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private parseAnd(): PredicateExpr {
|
|
209
|
+
const parts = [this.parseNot()]
|
|
210
|
+
while (this.isKw('AND')) {
|
|
211
|
+
this.next()
|
|
212
|
+
parts.push(this.parseNot())
|
|
213
|
+
}
|
|
214
|
+
return parts.length === 1 ? (parts[0] as PredicateExpr) : { kind: 'and', parts }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private parseNot(): PredicateExpr {
|
|
218
|
+
if (this.isKw('NOT')) {
|
|
219
|
+
this.next()
|
|
220
|
+
return { kind: 'not', expr: this.parseNot() }
|
|
221
|
+
}
|
|
222
|
+
return this.parsePrimary()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private parsePrimary(): PredicateExpr {
|
|
226
|
+
if (this.isOp('(')) {
|
|
227
|
+
// Could be a grouped predicate or a parenthesised scalar comparison.
|
|
228
|
+
const save = this.pos
|
|
229
|
+
this.next()
|
|
230
|
+
// Try predicate group first.
|
|
231
|
+
try {
|
|
232
|
+
const inner = this.parseOr()
|
|
233
|
+
if (this.isOp(')')) {
|
|
234
|
+
this.next()
|
|
235
|
+
return inner
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// fall through to scalar comparison
|
|
239
|
+
}
|
|
240
|
+
this.pos = save
|
|
241
|
+
}
|
|
242
|
+
return this.parseComparison()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private parseComparison(): PredicateExpr {
|
|
246
|
+
const left = this.parseScalar()
|
|
247
|
+
const t = this.peek()
|
|
248
|
+
|
|
249
|
+
// Postfix valueless keyword ops.
|
|
250
|
+
if (this.isKw('ISBLANK') || this.isKw('ISNOTBLANK')) {
|
|
251
|
+
const op: ExcelFilterOperator = this.next().value === 'ISBLANK' ? 'isBlank' : 'isNotBlank'
|
|
252
|
+
return { kind: 'cmp', column: this.expectColumn(left), op }
|
|
253
|
+
}
|
|
254
|
+
// Keyword text/set/range ops.
|
|
255
|
+
if (t.kind === 'kw') {
|
|
256
|
+
switch (t.value) {
|
|
257
|
+
case 'CONTAINS':
|
|
258
|
+
case 'STARTSWITH':
|
|
259
|
+
case 'ENDSWITH':
|
|
260
|
+
case 'MATCHES': {
|
|
261
|
+
this.next()
|
|
262
|
+
const rhs = this.parseScalar()
|
|
263
|
+
const op: ExcelFilterOperator =
|
|
264
|
+
t.value === 'CONTAINS' ? 'contains'
|
|
265
|
+
: t.value === 'STARTSWITH' ? 'startsWith'
|
|
266
|
+
: t.value === 'ENDSWITH' ? 'endsWith'
|
|
267
|
+
: 'regex'
|
|
268
|
+
return { kind: 'cmp', column: this.expectColumn(left), op, value: literalValue(rhs) }
|
|
269
|
+
}
|
|
270
|
+
case 'IN': {
|
|
271
|
+
this.next()
|
|
272
|
+
this.eat('op', '(')
|
|
273
|
+
const items: unknown[] = []
|
|
274
|
+
if (!this.isOp(')')) {
|
|
275
|
+
items.push(literalValue(this.parseScalar()))
|
|
276
|
+
while (this.isOp(',')) {
|
|
277
|
+
this.next()
|
|
278
|
+
items.push(literalValue(this.parseScalar()))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
this.eat('op', ')')
|
|
282
|
+
return { kind: 'cmp', column: this.expectColumn(left), op: 'in', value: items.map((v) => String(v ?? '')) }
|
|
283
|
+
}
|
|
284
|
+
case 'BETWEEN': {
|
|
285
|
+
this.next()
|
|
286
|
+
const lo = literalValue(this.parseScalar())
|
|
287
|
+
this.eat('kw', 'AND')
|
|
288
|
+
const hi = literalValue(this.parseScalar())
|
|
289
|
+
return { kind: 'cmp', column: this.expectColumn(left), op: 'between', value: lo, valueTo: hi }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Symbolic comparators.
|
|
294
|
+
if (t.kind === 'op' && ['=', '!=', '>', '<', '>=', '<='].includes(t.value)) {
|
|
295
|
+
const opTok = this.next().value
|
|
296
|
+
const right = this.parseScalar()
|
|
297
|
+
// Prefer a `cmp` (grid-filter semantics) for `col OP literal`; otherwise
|
|
298
|
+
// fall back to a general scalar comparison.
|
|
299
|
+
const cmpOp = SYM_TO_CMP[opTok]
|
|
300
|
+
if (cmpOp && left.kind === 'col' && right.kind === 'lit') {
|
|
301
|
+
return { kind: 'cmp', column: left.id, op: cmpOp, value: right.value }
|
|
302
|
+
}
|
|
303
|
+
return { kind: 'scalarCmp', left, op: opTok as ComparisonOp, right }
|
|
304
|
+
}
|
|
305
|
+
// Bare boolean literal is a constant predicate.
|
|
306
|
+
if (left.kind === 'lit' && typeof left.value === 'boolean') {
|
|
307
|
+
return { kind: 'const', value: left.value }
|
|
308
|
+
}
|
|
309
|
+
throw new ExpressionParseError('Expected a comparison operator', t.pos)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private expectColumn(expr: ScalarExpr): string {
|
|
313
|
+
if (expr.kind !== 'col') {
|
|
314
|
+
throw new ExpressionParseError('This operator requires a column on its left', this.peek().pos)
|
|
315
|
+
}
|
|
316
|
+
return expr.id
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Scalar precedence climbing --------------------------------------------
|
|
320
|
+
|
|
321
|
+
private parseScalar(): ScalarExpr {
|
|
322
|
+
return this.parseAdd()
|
|
323
|
+
}
|
|
324
|
+
private parseAdd(): ScalarExpr {
|
|
325
|
+
let left = this.parseMul()
|
|
326
|
+
while (this.isOp('+') || this.isOp('-')) {
|
|
327
|
+
const op = this.next().value as '+' | '-'
|
|
328
|
+
left = { kind: 'bin', op, left, right: this.parseMul() }
|
|
329
|
+
}
|
|
330
|
+
return left
|
|
331
|
+
}
|
|
332
|
+
private parseMul(): ScalarExpr {
|
|
333
|
+
let left = this.parseUnary()
|
|
334
|
+
while (this.isOp('*') || this.isOp('/') || this.isOp('%')) {
|
|
335
|
+
const op = this.next().value as '*' | '/' | '%'
|
|
336
|
+
left = { kind: 'bin', op, left, right: this.parseUnary() }
|
|
337
|
+
}
|
|
338
|
+
return left
|
|
339
|
+
}
|
|
340
|
+
private parseUnary(): ScalarExpr {
|
|
341
|
+
if (this.isOp('-')) {
|
|
342
|
+
this.next()
|
|
343
|
+
return { kind: 'neg', expr: this.parseUnary() }
|
|
344
|
+
}
|
|
345
|
+
return this.parseAtom()
|
|
346
|
+
}
|
|
347
|
+
private parseAtom(): ScalarExpr {
|
|
348
|
+
const t = this.peek()
|
|
349
|
+
if (t.kind === 'num') {
|
|
350
|
+
this.next()
|
|
351
|
+
return { kind: 'lit', value: Number(t.value) }
|
|
352
|
+
}
|
|
353
|
+
if (t.kind === 'str') {
|
|
354
|
+
this.next()
|
|
355
|
+
return { kind: 'lit', value: t.value }
|
|
356
|
+
}
|
|
357
|
+
if (t.kind === 'bracket') {
|
|
358
|
+
this.next()
|
|
359
|
+
return { kind: 'col', id: resolveColumnRef(this.columns, t.value) }
|
|
360
|
+
}
|
|
361
|
+
if (t.kind === 'kw' && (t.value === 'TRUE' || t.value === 'FALSE')) {
|
|
362
|
+
this.next()
|
|
363
|
+
return { kind: 'lit', value: t.value === 'TRUE' }
|
|
364
|
+
}
|
|
365
|
+
if (t.kind === 'kw' && t.value === 'NULL') {
|
|
366
|
+
this.next()
|
|
367
|
+
return { kind: 'lit', value: null }
|
|
368
|
+
}
|
|
369
|
+
if (this.isOp('(')) {
|
|
370
|
+
this.next()
|
|
371
|
+
const inner = this.parseScalar()
|
|
372
|
+
this.eat('op', ')')
|
|
373
|
+
return inner
|
|
374
|
+
}
|
|
375
|
+
if (t.kind === 'ident') {
|
|
376
|
+
this.next()
|
|
377
|
+
// Function or aggregate call?
|
|
378
|
+
if (this.isOp('(')) {
|
|
379
|
+
this.next()
|
|
380
|
+
const args: ScalarExpr[] = []
|
|
381
|
+
if (!this.isOp(')')) {
|
|
382
|
+
args.push(this.parseScalar())
|
|
383
|
+
while (this.isOp(',')) {
|
|
384
|
+
this.next()
|
|
385
|
+
args.push(this.parseScalar())
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.eat('op', ')')
|
|
389
|
+
const upper = t.value.toUpperCase()
|
|
390
|
+
if (AGG_KEYWORDS.has(upper)) {
|
|
391
|
+
const arg = args[0]
|
|
392
|
+
if (args.length !== 1 || !arg || arg.kind !== 'col') {
|
|
393
|
+
throw new ExpressionParseError(`${upper}() takes a single column reference`, t.pos)
|
|
394
|
+
}
|
|
395
|
+
return { kind: 'agg', fn: upper.toLowerCase() as 'sum' | 'avg' | 'count', column: arg.id }
|
|
396
|
+
}
|
|
397
|
+
return { kind: 'func', name: t.value, args }
|
|
398
|
+
}
|
|
399
|
+
// Bare identifier is a column reference.
|
|
400
|
+
return { kind: 'col', id: resolveColumnRef(this.columns, t.value) }
|
|
401
|
+
}
|
|
402
|
+
throw new ExpressionParseError(`Unexpected "${t.value || t.kind}"`, t.pos)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function literalValue(expr: ScalarExpr): unknown {
|
|
407
|
+
if (expr.kind === 'lit') return expr.value
|
|
408
|
+
// Non-literal RHS for a keyword op: fall back to its text form.
|
|
409
|
+
return stringifyScalar(expr, [])
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Parse a free-text predicate into an AST. Column references (`[Name]` or a
|
|
414
|
+
* bare id) are resolved against `columns`. Throws `ExpressionParseError`
|
|
415
|
+
* (carrying a source offset) on a syntax error.
|
|
416
|
+
*/
|
|
417
|
+
export function parsePredicate(
|
|
418
|
+
text: string,
|
|
419
|
+
columns: ReadonlyArray<ExprColumn> = [],
|
|
420
|
+
): PredicateExpr {
|
|
421
|
+
const trimmed = text.trim()
|
|
422
|
+
if (!trimmed) throw new ExpressionParseError('Expression is empty', 0)
|
|
423
|
+
return new Parser(tokenize(trimmed), columns).parse()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Stringify
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
function litText(value: unknown): string {
|
|
431
|
+
if (value == null) return 'null'
|
|
432
|
+
if (typeof value === 'number') return String(value)
|
|
433
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
434
|
+
return `"${String(value).replace(/"/g, '\\"')}"`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Render a scalar expression back to source text. */
|
|
438
|
+
export function stringifyScalar(
|
|
439
|
+
expr: ScalarExpr,
|
|
440
|
+
columns: ReadonlyArray<ExprColumn>,
|
|
441
|
+
): string {
|
|
442
|
+
switch (expr.kind) {
|
|
443
|
+
case 'lit':
|
|
444
|
+
return litText(expr.value)
|
|
445
|
+
case 'col':
|
|
446
|
+
return columnRefText(columns, expr.id)
|
|
447
|
+
case 'neg':
|
|
448
|
+
return `-${wrapScalar(expr.expr, columns)}`
|
|
449
|
+
case 'bin':
|
|
450
|
+
return `${wrapScalar(expr.left, columns)} ${expr.op} ${wrapScalar(expr.right, columns)}`
|
|
451
|
+
case 'agg':
|
|
452
|
+
return `${expr.fn.toUpperCase()}(${columnRefText(columns, expr.column)})`
|
|
453
|
+
case 'func':
|
|
454
|
+
return `${expr.name.toUpperCase()}(${expr.args.map((a) => stringifyScalar(a, columns)).join(', ')})`
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function wrapScalar(expr: ScalarExpr, columns: ReadonlyArray<ExprColumn>): string {
|
|
459
|
+
const s = stringifyScalar(expr, columns)
|
|
460
|
+
return expr.kind === 'bin' ? `(${s})` : s
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const CMP_SYMBOL: Partial<Record<ExcelFilterOperator, string>> = {
|
|
464
|
+
equals: '=',
|
|
465
|
+
notEquals: '!=',
|
|
466
|
+
greaterThan: '>',
|
|
467
|
+
lessThan: '<',
|
|
468
|
+
}
|
|
469
|
+
const CMP_KEYWORD: Partial<Record<ExcelFilterOperator, string>> = {
|
|
470
|
+
contains: 'CONTAINS',
|
|
471
|
+
notContains: 'NOT CONTAINS',
|
|
472
|
+
startsWith: 'STARTSWITH',
|
|
473
|
+
endsWith: 'ENDSWITH',
|
|
474
|
+
regex: 'MATCHES',
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Render a predicate expression back to readable source text. */
|
|
478
|
+
export function stringifyPredicate(
|
|
479
|
+
expr: PredicateExpr,
|
|
480
|
+
columns: ReadonlyArray<ExprColumn> = [],
|
|
481
|
+
): string {
|
|
482
|
+
switch (expr.kind) {
|
|
483
|
+
case 'const':
|
|
484
|
+
return expr.value ? 'true' : 'false'
|
|
485
|
+
case 'not':
|
|
486
|
+
return `NOT ${wrapPred(expr.expr, columns)}`
|
|
487
|
+
case 'and':
|
|
488
|
+
return expr.parts.map((p) => wrapPred(p, columns)).join(' AND ')
|
|
489
|
+
case 'or':
|
|
490
|
+
return expr.parts.map((p) => wrapPred(p, columns)).join(' OR ')
|
|
491
|
+
case 'scalarCmp':
|
|
492
|
+
return `${stringifyScalar(expr.left, columns)} ${expr.op} ${stringifyScalar(expr.right, columns)}`
|
|
493
|
+
case 'cmp': {
|
|
494
|
+
const col = columnRefText(columns, expr.column)
|
|
495
|
+
if (expr.op === 'isBlank') return `${col} ISBLANK`
|
|
496
|
+
if (expr.op === 'isNotBlank') return `${col} ISNOTBLANK`
|
|
497
|
+
if (expr.op === 'between') return `${col} BETWEEN ${litText(expr.value)} AND ${litText(expr.valueTo)}`
|
|
498
|
+
if (expr.op === 'in' || expr.op === 'notIn') {
|
|
499
|
+
const items = Array.isArray(expr.value) ? expr.value : String(expr.value ?? '').split(/[\n,]/)
|
|
500
|
+
const list = items.map((v) => litText(String(v).trim())).join(', ')
|
|
501
|
+
return `${col} ${expr.op === 'in' ? 'IN' : 'NOT IN'} (${list})`
|
|
502
|
+
}
|
|
503
|
+
const sym = CMP_SYMBOL[expr.op]
|
|
504
|
+
if (sym) return `${col} ${sym} ${litText(expr.value)}`
|
|
505
|
+
const kw = CMP_KEYWORD[expr.op]
|
|
506
|
+
return `${col} ${kw ?? expr.op} ${litText(expr.value)}`
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function wrapPred(expr: PredicateExpr, columns: ReadonlyArray<ExprColumn>): string {
|
|
512
|
+
const s = stringifyPredicate(expr, columns)
|
|
513
|
+
return expr.kind === 'and' || expr.kind === 'or' ? `(${s})` : s
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Validation + reference collection
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
/** Every column id referenced anywhere in a predicate (deduped, in order). */
|
|
521
|
+
export function collectColumnRefs(expr: PredicateExpr): string[] {
|
|
522
|
+
const out = new Set<string>()
|
|
523
|
+
walkPredicate(expr, out)
|
|
524
|
+
return [...out]
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function walkPredicate(expr: PredicateExpr, out: Set<string>): void {
|
|
528
|
+
switch (expr.kind) {
|
|
529
|
+
case 'and':
|
|
530
|
+
case 'or':
|
|
531
|
+
expr.parts.forEach((p) => walkPredicate(p, out))
|
|
532
|
+
return
|
|
533
|
+
case 'not':
|
|
534
|
+
walkPredicate(expr.expr, out)
|
|
535
|
+
return
|
|
536
|
+
case 'cmp':
|
|
537
|
+
out.add(expr.column)
|
|
538
|
+
return
|
|
539
|
+
case 'scalarCmp':
|
|
540
|
+
walkScalar(expr.left, out)
|
|
541
|
+
walkScalar(expr.right, out)
|
|
542
|
+
return
|
|
543
|
+
case 'const':
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function walkScalar(expr: ScalarExpr, out: Set<string>): void {
|
|
549
|
+
switch (expr.kind) {
|
|
550
|
+
case 'col':
|
|
551
|
+
out.add(expr.id)
|
|
552
|
+
return
|
|
553
|
+
case 'agg':
|
|
554
|
+
out.add(expr.column)
|
|
555
|
+
return
|
|
556
|
+
case 'neg':
|
|
557
|
+
walkScalar(expr.expr, out)
|
|
558
|
+
return
|
|
559
|
+
case 'bin':
|
|
560
|
+
walkScalar(expr.left, out)
|
|
561
|
+
walkScalar(expr.right, out)
|
|
562
|
+
return
|
|
563
|
+
case 'func':
|
|
564
|
+
expr.args.forEach((a) => walkScalar(a, out))
|
|
565
|
+
return
|
|
566
|
+
case 'lit':
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Structurally validate an AST against a column list. Returns a list of
|
|
573
|
+
* human-readable problems (empty when the expression is sound): unknown column
|
|
574
|
+
* references and unknown function names.
|
|
575
|
+
*/
|
|
576
|
+
export function validateExpression(
|
|
577
|
+
expr: PredicateExpr,
|
|
578
|
+
columns: ReadonlyArray<ExprColumn>,
|
|
579
|
+
): string[] {
|
|
580
|
+
const known = new Set(columns.map((c) => c.id))
|
|
581
|
+
const errors: string[] = []
|
|
582
|
+
for (const id of collectColumnRefs(expr)) {
|
|
583
|
+
if (!known.has(id)) errors.push(`Unknown column "${id}"`)
|
|
584
|
+
}
|
|
585
|
+
checkFuncs(expr, errors)
|
|
586
|
+
return errors
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function checkFuncs(expr: PredicateExpr, errors: string[]): void {
|
|
590
|
+
const visit = (s: ScalarExpr): void => {
|
|
591
|
+
if (s.kind === 'func' && !isBuiltinFunction(s.name)) {
|
|
592
|
+
errors.push(`Unknown function "${s.name}"`)
|
|
593
|
+
}
|
|
594
|
+
if (s.kind === 'func') s.args.forEach(visit)
|
|
595
|
+
if (s.kind === 'bin') {
|
|
596
|
+
visit(s.left)
|
|
597
|
+
visit(s.right)
|
|
598
|
+
}
|
|
599
|
+
if (s.kind === 'neg') visit(s.expr)
|
|
600
|
+
}
|
|
601
|
+
const walk = (p: PredicateExpr): void => {
|
|
602
|
+
if (p.kind === 'and' || p.kind === 'or') p.parts.forEach(walk)
|
|
603
|
+
else if (p.kind === 'not') walk(p.expr)
|
|
604
|
+
else if (p.kind === 'scalarCmp') {
|
|
605
|
+
visit(p.left)
|
|
606
|
+
visit(p.right)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
walk(expr)
|
|
610
|
+
}
|
package/src/import.ts
CHANGED
|
@@ -199,6 +199,8 @@ async function getJSZip(): Promise<any> {
|
|
|
199
199
|
jszipPromise = (async () => {
|
|
200
200
|
let mod: unknown
|
|
201
201
|
try {
|
|
202
|
+
// Optional peer, loaded on demand (keeps consumer type-checks clean when absent).
|
|
203
|
+
// @ts-ignore - "jszip" is an optional peerDependency
|
|
202
204
|
mod = await import('jszip')
|
|
203
205
|
} catch {
|
|
204
206
|
throw new Error(
|