@kernhq/module-tracker 0.1.1 → 0.1.2
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/package.json +4 -2
- package/src/client/api.ts +1 -1
- package/src/client/format.ts +1 -1
- package/src/client/group.ts +1 -1
- package/src/client/kql.ts +4 -4
- package/src/client/types.ts +1 -1
- package/src/contract/events.ts +94 -0
- package/src/contract/index.ts +5 -0
- package/src/contract/models.ts +1422 -0
- package/src/contract/notifications.ts +47 -0
- package/src/contract/permissions.ts +197 -0
- package/src/contract/router.ts +857 -0
- package/src/kql/ast.ts +136 -0
- package/src/kql/dates.ts +62 -0
- package/src/kql/fields.ts +150 -0
- package/src/kql/index.ts +15 -0
- package/src/kql/kql.test.ts +371 -0
- package/src/kql/lexer.ts +172 -0
- package/src/kql/parser.ts +300 -0
- package/src/kql/suggest.ts +102 -0
- package/src/kql/validate.ts +89 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { KqlComparison, KqlExpr, KqlOp, KqlOrder, KqlQuery, KqlValue, Span } from './ast.js'
|
|
2
|
+
import { type Token, tokenize } from './lexer.js'
|
|
3
|
+
|
|
4
|
+
export interface ParseError {
|
|
5
|
+
message: string
|
|
6
|
+
start: number
|
|
7
|
+
end: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParseResult {
|
|
11
|
+
ok: boolean
|
|
12
|
+
query: KqlQuery | null
|
|
13
|
+
errors: ParseError[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const KEYWORDS = new Set([
|
|
17
|
+
'and',
|
|
18
|
+
'or',
|
|
19
|
+
'not',
|
|
20
|
+
'in',
|
|
21
|
+
'is',
|
|
22
|
+
'empty',
|
|
23
|
+
'order',
|
|
24
|
+
'by',
|
|
25
|
+
'asc',
|
|
26
|
+
'desc',
|
|
27
|
+
'true',
|
|
28
|
+
'false',
|
|
29
|
+
'null',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const COMPARE_OPS: Record<string, KqlOp> = {
|
|
33
|
+
'=': '=',
|
|
34
|
+
'!=': '!=',
|
|
35
|
+
'<': '<',
|
|
36
|
+
'<=': '<=',
|
|
37
|
+
'>': '>',
|
|
38
|
+
'>=': '>=',
|
|
39
|
+
'~': '~',
|
|
40
|
+
'!~': '!~',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class ParseFailure extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
readonly span: Span,
|
|
47
|
+
) {
|
|
48
|
+
super(message)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursive-descent parser for KQL.
|
|
54
|
+
*
|
|
55
|
+
* ```ebnf
|
|
56
|
+
* query = [ expr ] [ order-clause ] ;
|
|
57
|
+
* expr = or-expr ;
|
|
58
|
+
* or-expr = and-expr { "or" and-expr } ;
|
|
59
|
+
* and-expr = unary { "and" unary } ;
|
|
60
|
+
* unary = "not" unary | "(" expr ")" | comparison ;
|
|
61
|
+
* comparison = field ( op value
|
|
62
|
+
* | "in" "(" value { "," value } ")"
|
|
63
|
+
* | "not" "in" "(" value { "," value } ")"
|
|
64
|
+
* | "is" [ "not" ] "empty" ) ;
|
|
65
|
+
* op = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "!~" ;
|
|
66
|
+
* value = string | number | date | reldate | "true" | "false" | "null" | ident | function ;
|
|
67
|
+
* function = ident "(" [ value { "," value } ] ")" ;
|
|
68
|
+
* order-clause = "order" "by" order-item { "," order-item } ;
|
|
69
|
+
* order-item = field [ "asc" | "desc" ] ;
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
class Parser {
|
|
73
|
+
private pos = 0
|
|
74
|
+
constructor(private readonly tokens: Token[]) {}
|
|
75
|
+
|
|
76
|
+
private peek(offset = 0): Token {
|
|
77
|
+
return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)]!
|
|
78
|
+
}
|
|
79
|
+
private next(): Token {
|
|
80
|
+
const t = this.peek()
|
|
81
|
+
if (t.kind !== 'eof') this.pos++
|
|
82
|
+
return t
|
|
83
|
+
}
|
|
84
|
+
private atKeyword(word: string, offset = 0): boolean {
|
|
85
|
+
const t = this.peek(offset)
|
|
86
|
+
return t.kind === 'ident' && t.text.toLowerCase() === word
|
|
87
|
+
}
|
|
88
|
+
private eatKeyword(word: string): boolean {
|
|
89
|
+
if (!this.atKeyword(word)) return false
|
|
90
|
+
this.pos++
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
private expectKeyword(word: string): Token {
|
|
94
|
+
const t = this.peek()
|
|
95
|
+
if (!this.eatKeyword(word)) throw new ParseFailure(`Expected "${word}"`, span(t))
|
|
96
|
+
return t
|
|
97
|
+
}
|
|
98
|
+
private expect(kind: Token['kind'], label: string): Token {
|
|
99
|
+
const t = this.peek()
|
|
100
|
+
if (t.kind !== kind) throw new ParseFailure(`Expected ${label}`, span(t))
|
|
101
|
+
return this.next()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
parse(): KqlQuery {
|
|
105
|
+
let where: KqlExpr | null = null
|
|
106
|
+
if (this.peek().kind !== 'eof' && !this.atKeyword('order')) where = this.parseOr()
|
|
107
|
+
const orderBy = this.parseOrderClause()
|
|
108
|
+
const trailing = this.peek()
|
|
109
|
+
if (trailing.kind !== 'eof') throw new ParseFailure(`Unexpected "${trailing.text}"`, span(trailing))
|
|
110
|
+
return { where, orderBy }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private parseOrderClause(): KqlOrder[] {
|
|
114
|
+
if (!this.atKeyword('order')) return []
|
|
115
|
+
this.next()
|
|
116
|
+
this.expectKeyword('by')
|
|
117
|
+
const out: KqlOrder[] = []
|
|
118
|
+
for (;;) {
|
|
119
|
+
const field = this.peek()
|
|
120
|
+
if (field.kind !== 'ident' || KEYWORDS.has(field.text.toLowerCase()))
|
|
121
|
+
throw new ParseFailure('Expected a field name', span(field))
|
|
122
|
+
this.next()
|
|
123
|
+
let dir: 'asc' | 'desc' = 'asc'
|
|
124
|
+
let end = field.end
|
|
125
|
+
if (this.atKeyword('asc') || this.atKeyword('desc')) {
|
|
126
|
+
const d = this.next()
|
|
127
|
+
dir = d.text.toLowerCase() as 'asc' | 'desc'
|
|
128
|
+
end = d.end
|
|
129
|
+
}
|
|
130
|
+
out.push({ field: field.text, dir, span: { start: field.start, end } })
|
|
131
|
+
if (this.peek().kind === 'comma') {
|
|
132
|
+
this.next()
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private parseOr(): KqlExpr {
|
|
141
|
+
const first = this.parseAnd()
|
|
142
|
+
if (!this.atKeyword('or')) return first
|
|
143
|
+
const children = [first]
|
|
144
|
+
while (this.eatKeyword('or')) children.push(this.parseAnd())
|
|
145
|
+
return { kind: 'or', children, span: joinSpans(children) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private parseAnd(): KqlExpr {
|
|
149
|
+
const first = this.parseUnary()
|
|
150
|
+
if (!this.atKeyword('and')) return first
|
|
151
|
+
const children = [first]
|
|
152
|
+
while (this.eatKeyword('and')) children.push(this.parseUnary())
|
|
153
|
+
return { kind: 'and', children, span: joinSpans(children) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private parseUnary(): KqlExpr {
|
|
157
|
+
// `not` starts a negation only when it is not the `not in` of a comparison
|
|
158
|
+
if (this.atKeyword('not') && !this.atKeyword('in', 1)) {
|
|
159
|
+
const start = this.next().start
|
|
160
|
+
const child = this.parseUnary()
|
|
161
|
+
return { kind: 'not', child, span: { start, end: child.span.end } }
|
|
162
|
+
}
|
|
163
|
+
if (this.peek().kind === 'lparen') {
|
|
164
|
+
this.next()
|
|
165
|
+
const inner = this.parseOr()
|
|
166
|
+
this.expect('rparen', '")"')
|
|
167
|
+
return inner
|
|
168
|
+
}
|
|
169
|
+
return this.parseComparison()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private parseComparison(): KqlComparison {
|
|
173
|
+
const field = this.peek()
|
|
174
|
+
if (field.kind !== 'ident' || KEYWORDS.has(field.text.toLowerCase()))
|
|
175
|
+
throw new ParseFailure('Expected a field name', span(field))
|
|
176
|
+
this.next()
|
|
177
|
+
|
|
178
|
+
const t = this.peek()
|
|
179
|
+
if (t.kind === 'op') {
|
|
180
|
+
const op = COMPARE_OPS[t.text]
|
|
181
|
+
if (!op) throw new ParseFailure(`Unknown operator "${t.text}"`, span(t))
|
|
182
|
+
this.next()
|
|
183
|
+
const value = this.parseValue()
|
|
184
|
+
return {
|
|
185
|
+
kind: 'cmp',
|
|
186
|
+
field: field.text,
|
|
187
|
+
op,
|
|
188
|
+
value,
|
|
189
|
+
values: null,
|
|
190
|
+
span: { start: field.start, end: value.span.end },
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (this.atKeyword('in') || (this.atKeyword('not') && this.atKeyword('in', 1))) {
|
|
194
|
+
const negated = this.eatKeyword('not')
|
|
195
|
+
this.expectKeyword('in')
|
|
196
|
+
this.expect('lparen', '"("')
|
|
197
|
+
const values: KqlValue[] = []
|
|
198
|
+
if (this.peek().kind !== 'rparen') {
|
|
199
|
+
values.push(this.parseValue())
|
|
200
|
+
while (this.peek().kind === 'comma') {
|
|
201
|
+
this.next()
|
|
202
|
+
values.push(this.parseValue())
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const close = this.expect('rparen', '")"')
|
|
206
|
+
return {
|
|
207
|
+
kind: 'cmp',
|
|
208
|
+
field: field.text,
|
|
209
|
+
op: negated ? 'not-in' : 'in',
|
|
210
|
+
value: null,
|
|
211
|
+
values,
|
|
212
|
+
span: { start: field.start, end: close.end },
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (this.atKeyword('is')) {
|
|
216
|
+
this.next()
|
|
217
|
+
const negated = this.eatKeyword('not')
|
|
218
|
+
const empty = this.expectKeyword('empty')
|
|
219
|
+
return {
|
|
220
|
+
kind: 'cmp',
|
|
221
|
+
field: field.text,
|
|
222
|
+
op: negated ? 'is-not-empty' : 'is-empty',
|
|
223
|
+
value: null,
|
|
224
|
+
values: null,
|
|
225
|
+
span: { start: field.start, end: empty.end },
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new ParseFailure(`Expected an operator after "${field.text}"`, span(t))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private parseValue(): KqlValue {
|
|
232
|
+
const t = this.peek()
|
|
233
|
+
switch (t.kind) {
|
|
234
|
+
case 'string':
|
|
235
|
+
this.next()
|
|
236
|
+
return { kind: 'string', value: String(t.value ?? ''), span: span(t) }
|
|
237
|
+
case 'number':
|
|
238
|
+
this.next()
|
|
239
|
+
return { kind: 'number', value: Number(t.value), span: span(t) }
|
|
240
|
+
case 'date':
|
|
241
|
+
this.next()
|
|
242
|
+
return { kind: 'date', value: String(t.value), span: span(t) }
|
|
243
|
+
case 'reldate':
|
|
244
|
+
this.next()
|
|
245
|
+
return { kind: 'reldate', amount: t.amount!, unit: t.unit!, span: span(t) }
|
|
246
|
+
case 'ident': {
|
|
247
|
+
const lower = t.text.toLowerCase()
|
|
248
|
+
if (lower === 'true' || lower === 'false') {
|
|
249
|
+
this.next()
|
|
250
|
+
return { kind: 'bool', value: lower === 'true', span: span(t) }
|
|
251
|
+
}
|
|
252
|
+
if (lower === 'null') {
|
|
253
|
+
this.next()
|
|
254
|
+
return { kind: 'null', span: span(t) }
|
|
255
|
+
}
|
|
256
|
+
if (KEYWORDS.has(lower)) throw new ParseFailure(`Expected a value, got "${t.text}"`, span(t))
|
|
257
|
+
this.next()
|
|
258
|
+
if (this.peek().kind === 'lparen') {
|
|
259
|
+
this.next()
|
|
260
|
+
const args: KqlValue[] = []
|
|
261
|
+
if (this.peek().kind !== 'rparen') {
|
|
262
|
+
args.push(this.parseValue())
|
|
263
|
+
while (this.peek().kind === 'comma') {
|
|
264
|
+
this.next()
|
|
265
|
+
args.push(this.parseValue())
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const close = this.expect('rparen', '")"')
|
|
269
|
+
return { kind: 'func', name: t.text, args, span: { start: t.start, end: close.end } }
|
|
270
|
+
}
|
|
271
|
+
return { kind: 'ident', value: t.text, span: span(t) }
|
|
272
|
+
}
|
|
273
|
+
default:
|
|
274
|
+
throw new ParseFailure('Expected a value', span(t))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const span = (t: Token): Span => ({ start: t.start, end: t.end })
|
|
280
|
+
const joinSpans = (nodes: Array<{ span: Span }>): Span => ({
|
|
281
|
+
start: nodes[0]?.span.start ?? 0,
|
|
282
|
+
end: nodes.at(-1)?.span.end ?? 0,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
/** Parse a KQL string. Never throws: syntax problems come back as spans in `errors`. */
|
|
286
|
+
export function parseKql(input: string): ParseResult {
|
|
287
|
+
const { tokens, errors: lexErrors } = tokenize(input)
|
|
288
|
+
const errors: ParseError[] = [...lexErrors]
|
|
289
|
+
if (!input.trim()) return { ok: errors.length === 0, query: { where: null, orderBy: [] }, errors }
|
|
290
|
+
try {
|
|
291
|
+
const query = new Parser(tokens).parse()
|
|
292
|
+
return { ok: errors.length === 0, query, errors }
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err instanceof ParseFailure) {
|
|
295
|
+
errors.push({ message: err.message, start: err.span.start, end: err.span.end })
|
|
296
|
+
return { ok: false, query: null, errors }
|
|
297
|
+
}
|
|
298
|
+
throw err
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { KqlSuggestion } from '../contract/models.js'
|
|
2
|
+
import { findField, KQL_FUNCTIONS, type KqlField, operatorsFor } from './fields.js'
|
|
3
|
+
import { tokenize } from './lexer.js'
|
|
4
|
+
|
|
5
|
+
const KEYWORD_TOKENS = new Set(['and', 'or', 'not', 'in', 'is', 'empty', 'order', 'by', 'asc', 'desc'])
|
|
6
|
+
|
|
7
|
+
const fieldSuggestion = (f: KqlField): KqlSuggestion => ({
|
|
8
|
+
kind: 'field',
|
|
9
|
+
label: f.name,
|
|
10
|
+
insertText: f.name,
|
|
11
|
+
detail: f.label,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const opSuggestion = (op: string): KqlSuggestion => ({ kind: 'operator', label: op, insertText: op })
|
|
15
|
+
|
|
16
|
+
const valueSuggestions = (field: KqlField): KqlSuggestion[] => {
|
|
17
|
+
const out: KqlSuggestion[] = []
|
|
18
|
+
for (const v of field.enumValues ?? []) out.push({ kind: 'value', label: v, insertText: v })
|
|
19
|
+
if (field.kind === 'user')
|
|
20
|
+
out.push({ kind: 'function', label: 'currentUser()', insertText: 'currentUser()', detail: 'You' })
|
|
21
|
+
if (field.kind === 'date' || field.kind === 'datetime')
|
|
22
|
+
for (const f of KQL_FUNCTIONS)
|
|
23
|
+
if (['now', 'startOfDay', 'startOfWeek', 'startOfMonth'].includes(f.name))
|
|
24
|
+
out.push({ kind: 'function', label: `${f.name}()`, insertText: `${f.name}()`, detail: f.detail })
|
|
25
|
+
if (field.refType === 'cycle')
|
|
26
|
+
for (const name of ['activeCycle', 'openCycles'])
|
|
27
|
+
out.push({ kind: 'function', label: `${name}()`, insertText: `${name}()` })
|
|
28
|
+
if (field.kind === 'boolean')
|
|
29
|
+
for (const v of ['true', 'false']) out.push({ kind: 'value', label: v, insertText: v })
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Autocomplete for a KQL editor. Looks only at the tokens before `cursor`, so it works on
|
|
35
|
+
* half-written queries the parser would reject.
|
|
36
|
+
*/
|
|
37
|
+
export function suggest(input: string, fields: readonly KqlField[], cursor?: number): KqlSuggestion[] {
|
|
38
|
+
const at = cursor ?? input.length
|
|
39
|
+
const head = input.slice(0, at)
|
|
40
|
+
const { tokens } = tokenize(head)
|
|
41
|
+
const real = tokens.filter((t) => t.kind !== 'eof')
|
|
42
|
+
const last = real.at(-1)
|
|
43
|
+
const prev = real.at(-2)
|
|
44
|
+
|
|
45
|
+
const matchFields = (prefix: string) =>
|
|
46
|
+
fields
|
|
47
|
+
.filter((f) => f.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
48
|
+
.slice(0, 40)
|
|
49
|
+
.map(fieldSuggestion)
|
|
50
|
+
|
|
51
|
+
// nothing typed yet, or the query continues after a connective / open paren
|
|
52
|
+
const startsNewTerm =
|
|
53
|
+
!last ||
|
|
54
|
+
last.kind === 'lparen' ||
|
|
55
|
+
(last.kind === 'ident' && ['and', 'or', 'not'].includes(last.text.toLowerCase()))
|
|
56
|
+
if (startsNewTerm) return matchFields('')
|
|
57
|
+
|
|
58
|
+
// the caret sits inside a bareword
|
|
59
|
+
if (last.kind === 'ident' && last.end === at) {
|
|
60
|
+
const word = last.text.toLowerCase()
|
|
61
|
+
// after `field <op>` a bareword is a value, not a field
|
|
62
|
+
if (prev?.kind === 'op' || (prev?.kind === 'ident' && ['in', 'is'].includes(prev.text.toLowerCase()))) {
|
|
63
|
+
const fieldTok = fieldTokenBefore(real, real.length - 2)
|
|
64
|
+
const field = fieldTok ? findField(fields, fieldTok) : undefined
|
|
65
|
+
return field ? valueSuggestions(field).filter((s) => s.label.toLowerCase().startsWith(word)) : []
|
|
66
|
+
}
|
|
67
|
+
if (KEYWORD_TOKENS.has(word)) return matchFields('')
|
|
68
|
+
const exact = findField(fields, last.text)
|
|
69
|
+
if (exact) return operatorsFor(exact).map(opSuggestion)
|
|
70
|
+
return matchFields(last.text)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// right after an operator → values of the field on its left
|
|
74
|
+
if (last.kind === 'op' || (last.kind === 'ident' && ['in', 'is'].includes(last.text.toLowerCase()))) {
|
|
75
|
+
const fieldTok = fieldTokenBefore(real, real.length - 2)
|
|
76
|
+
const field = fieldTok ? findField(fields, fieldTok) : undefined
|
|
77
|
+
return field ? valueSuggestions(field) : []
|
|
78
|
+
}
|
|
79
|
+
if (last.kind === 'lparen' || last.kind === 'comma') {
|
|
80
|
+
const fieldTok = fieldTokenBefore(real, real.length - 1)
|
|
81
|
+
const field = fieldTok ? findField(fields, fieldTok) : undefined
|
|
82
|
+
return field ? valueSuggestions(field) : matchFields('')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// a complete term: offer connectives
|
|
86
|
+
return [
|
|
87
|
+
{ kind: 'keyword', label: 'and', insertText: ' and ' },
|
|
88
|
+
{ kind: 'keyword', label: 'or', insertText: ' or ' },
|
|
89
|
+
{ kind: 'keyword', label: 'order by', insertText: ' order by ' },
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Walk left from `from` to the nearest bareword that is not a keyword — the field of this comparison. */
|
|
94
|
+
function fieldTokenBefore(tokens: ReturnType<typeof tokenize>['tokens'], from: number): string | null {
|
|
95
|
+
for (let i = from; i >= 0; i--) {
|
|
96
|
+
const t = tokens[i]
|
|
97
|
+
if (t?.kind !== 'ident') continue
|
|
98
|
+
if (KEYWORD_TOKENS.has(t.text.toLowerCase())) continue
|
|
99
|
+
return t.text
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { KqlComparison, KqlQuery, KqlValue } from './ast.js'
|
|
2
|
+
import { walkComparisons } from './ast.js'
|
|
3
|
+
import { findField, KQL_FUNCTIONS, type KqlField, operatorsFor } from './fields.js'
|
|
4
|
+
|
|
5
|
+
export interface KqlIssue {
|
|
6
|
+
message: string
|
|
7
|
+
start: number
|
|
8
|
+
end: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FUNCTIONS = new Map(KQL_FUNCTIONS.map((f) => [f.name.toLowerCase(), f]))
|
|
12
|
+
|
|
13
|
+
const argCountOk = (spec: number | string, got: number): boolean =>
|
|
14
|
+
typeof spec === 'number' ? got === spec : spec === '0-1' ? got <= 1 : true
|
|
15
|
+
|
|
16
|
+
/** Semantic checks: known fields, operators the field supports, plausible values, known functions. */
|
|
17
|
+
export function validateQuery(query: KqlQuery, fields: readonly KqlField[]): KqlIssue[] {
|
|
18
|
+
const issues: KqlIssue[] = []
|
|
19
|
+
walkComparisons(query.where, (cmp) => issues.push(...checkComparison(cmp, fields)))
|
|
20
|
+
for (const order of query.orderBy) {
|
|
21
|
+
const field = findField(fields, order.field)
|
|
22
|
+
if (!field) {
|
|
23
|
+
issues.push({ message: `Unknown field "${order.field}"`, ...order.span })
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
// only fields explicitly marked sortable have a deterministic column to order by
|
|
27
|
+
if (!field.sortable) issues.push({ message: `Field "${order.field}" cannot be sorted on`, ...order.span })
|
|
28
|
+
}
|
|
29
|
+
return issues
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checkComparison(cmp: KqlComparison, fields: readonly KqlField[]): KqlIssue[] {
|
|
33
|
+
const issues: KqlIssue[] = []
|
|
34
|
+
const field = findField(fields, cmp.field)
|
|
35
|
+
if (!field) {
|
|
36
|
+
return [{ message: `Unknown field "${cmp.field}"`, start: cmp.span.start, end: cmp.span.end }]
|
|
37
|
+
}
|
|
38
|
+
const allowed = operatorsFor(field)
|
|
39
|
+
if (!allowed.includes(cmp.op))
|
|
40
|
+
issues.push({
|
|
41
|
+
message: `Operator "${cmp.op}" is not supported for "${field.name}" (try ${allowed.join(', ')})`,
|
|
42
|
+
start: cmp.span.start,
|
|
43
|
+
end: cmp.span.end,
|
|
44
|
+
})
|
|
45
|
+
for (const v of [cmp.value, ...(cmp.values ?? [])]) if (v) issues.push(...checkValue(v, field))
|
|
46
|
+
return issues
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checkValue(value: KqlValue, field: KqlField): KqlIssue[] {
|
|
50
|
+
if (value.kind === 'func') {
|
|
51
|
+
const spec = FUNCTIONS.get(value.name.toLowerCase())
|
|
52
|
+
if (!spec) return [{ message: `Unknown function "${value.name}()"`, ...value.span }]
|
|
53
|
+
if (!argCountOk(spec.args, value.args.length))
|
|
54
|
+
return [{ message: `${value.name}() expects ${spec.args} argument(s)`, ...value.span }]
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
if (value.kind === 'null') return []
|
|
58
|
+
switch (field.kind) {
|
|
59
|
+
case 'number':
|
|
60
|
+
if (value.kind !== 'number') return [{ message: `"${field.name}" expects a number`, ...value.span }]
|
|
61
|
+
return []
|
|
62
|
+
case 'boolean':
|
|
63
|
+
if (value.kind !== 'bool') return [{ message: `"${field.name}" expects true or false`, ...value.span }]
|
|
64
|
+
return []
|
|
65
|
+
case 'date':
|
|
66
|
+
case 'datetime':
|
|
67
|
+
if (value.kind !== 'date' && value.kind !== 'reldate')
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
message: `"${field.name}" expects a date (2026-08-22), a relative date (-7d) or a function`,
|
|
71
|
+
...value.span,
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
return []
|
|
75
|
+
case 'enum': {
|
|
76
|
+
const text = value.kind === 'string' || value.kind === 'ident' ? String(value.value) : null
|
|
77
|
+
if (text && field.enumValues && !field.enumValues.includes(text.toLowerCase()))
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
message: `"${text}" is not a valid ${field.name} (${field.enumValues.join(', ')})`,
|
|
81
|
+
...value.span,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|