flyql-vue 0.0.37
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/LICENSE +21 -0
- package/package.json +54 -0
- package/src/FlyqlColumns.vue +783 -0
- package/src/FlyqlEditor.vue +928 -0
- package/src/columns-engine.js +846 -0
- package/src/engine.js +891 -0
- package/src/flyql.css +531 -0
- package/src/index.js +5 -0
- package/src/state.js +61 -0
- package/src/suggestions.js +709 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ColumnsEngine — framework-agnostic columns expression editor logic.
|
|
3
|
+
* Pure JS class, no Vue/React/DOM dependencies.
|
|
4
|
+
* Uses the columns parser (flyql/columns) instead of the core query parser.
|
|
5
|
+
* One instance per columns editor component.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Parser,
|
|
10
|
+
parse as parseColumns,
|
|
11
|
+
diagnose,
|
|
12
|
+
CharType,
|
|
13
|
+
State,
|
|
14
|
+
TRANSFORMER_OPERATOR,
|
|
15
|
+
COLUMNS_DELIMITER,
|
|
16
|
+
} from 'flyql/columns'
|
|
17
|
+
import { defaultRegistry } from 'flyql/transformers'
|
|
18
|
+
import { EditorState } from './state.js'
|
|
19
|
+
import { getNestedColumnSuggestions, resolveColumnDef, getKeyDiscoverySuggestions } from './suggestions.js'
|
|
20
|
+
import { Column, ColumnSchema, Diagnostic, Range, CODE_UNKNOWN_COLUMN, CODE_UNKNOWN_TRANSFORMER } from 'flyql/core'
|
|
21
|
+
import { Type } from 'flyql'
|
|
22
|
+
|
|
23
|
+
/** Maps editor-input raw-type strings to canonical flyql.Type. */
|
|
24
|
+
const EDITOR_TYPE_TO_FLYQL = {
|
|
25
|
+
enum: Type.String,
|
|
26
|
+
string: Type.String,
|
|
27
|
+
number: Type.Int,
|
|
28
|
+
int: Type.Int,
|
|
29
|
+
integer: Type.Int,
|
|
30
|
+
float: Type.Float,
|
|
31
|
+
bool: Type.Bool,
|
|
32
|
+
boolean: Type.Bool,
|
|
33
|
+
array: Type.Array,
|
|
34
|
+
map: Type.Map,
|
|
35
|
+
struct: Type.Struct,
|
|
36
|
+
json: Type.JSON,
|
|
37
|
+
date: Type.Date,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const _FLYQL_TYPE_VALUES = new Set(Object.values(Type))
|
|
41
|
+
|
|
42
|
+
function _applyEditorTypeNormalization(col) {
|
|
43
|
+
if (col.type && !_FLYQL_TYPE_VALUES.has(col.type)) {
|
|
44
|
+
const mapped = EDITOR_TYPE_TO_FLYQL[col.type]
|
|
45
|
+
col.type = mapped !== undefined ? mapped : Type.Unknown
|
|
46
|
+
}
|
|
47
|
+
if (col.children) {
|
|
48
|
+
for (const child of Object.values(col.children)) {
|
|
49
|
+
if (child) _applyEditorTypeNormalization(child)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const COL_CHAR_TYPE_CLASS = {
|
|
55
|
+
[CharType.COLUMN]: 'flyql-col-column',
|
|
56
|
+
[CharType.OPERATOR]: 'flyql-col-operator',
|
|
57
|
+
[CharType.TRANSFORMER]: 'flyql-col-transformer',
|
|
58
|
+
[CharType.ARGUMENT]: 'flyql-col-argument',
|
|
59
|
+
[CharType.ALIAS]: 'flyql-col-alias',
|
|
60
|
+
[CharType.ERROR]: 'flyql-col-error',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const STATE_LABELS = {
|
|
64
|
+
column: 'column name',
|
|
65
|
+
transformer: 'transformers',
|
|
66
|
+
delimiter: 'next',
|
|
67
|
+
alias: 'next',
|
|
68
|
+
argument: 'arguments',
|
|
69
|
+
next: 'column name, separator or transformer',
|
|
70
|
+
none: '',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapeHtml(str) {
|
|
74
|
+
return str
|
|
75
|
+
.replace(/&/g, '&')
|
|
76
|
+
.replace(/</g, '<')
|
|
77
|
+
.replace(/>/g, '>')
|
|
78
|
+
.replace(/"/g, '"')
|
|
79
|
+
.replace(/'/g, ''')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function wrapSpan(charType, text) {
|
|
83
|
+
const escaped = escapeHtml(text)
|
|
84
|
+
const cls = COL_CHAR_TYPE_CLASS[charType]
|
|
85
|
+
if (cls) {
|
|
86
|
+
return `<span class="${cls}">${escaped}</span>`
|
|
87
|
+
}
|
|
88
|
+
return escaped
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class ColumnsEngine {
|
|
92
|
+
constructor(schema, options = {}) {
|
|
93
|
+
this.columns = schema || new ColumnSchema({})
|
|
94
|
+
for (const col of Object.values(this.columns.columns)) {
|
|
95
|
+
if (col) _applyEditorTypeNormalization(col)
|
|
96
|
+
}
|
|
97
|
+
const capDefaults = { transformers: true }
|
|
98
|
+
this.capabilities = options.capabilities ? { ...capDefaults, ...options.capabilities } : { ...capDefaults }
|
|
99
|
+
this.onKeyDiscovery = options.onKeyDiscovery || null
|
|
100
|
+
this.onLoadingChange = options.onLoadingChange || null
|
|
101
|
+
this.registry = options.registry || defaultRegistry()
|
|
102
|
+
this.keyCache = {}
|
|
103
|
+
this.state = new EditorState()
|
|
104
|
+
this.context = null
|
|
105
|
+
this.suggestions = []
|
|
106
|
+
this.suggestionType = ''
|
|
107
|
+
this.message = ''
|
|
108
|
+
this.isLoading = false
|
|
109
|
+
this.diagnostics = []
|
|
110
|
+
this._seq = 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setColumns(schema) {
|
|
114
|
+
this.columns = schema || new ColumnSchema({})
|
|
115
|
+
for (const col of Object.values(this.columns.columns)) {
|
|
116
|
+
if (col) _applyEditorTypeNormalization(col)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setRegistry(registry) {
|
|
121
|
+
this.registry = registry || defaultRegistry()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_transformerDetail(name) {
|
|
125
|
+
const t = this.registry.get(name)
|
|
126
|
+
if (!t) return ''
|
|
127
|
+
const schema = t.argSchema
|
|
128
|
+
if (!schema || schema.length === 0) return `${t.inputType} → ${t.outputType}`
|
|
129
|
+
const parts = schema.map((a) => (a.optional ? a.type + '?' : a.type))
|
|
130
|
+
return '(' + parts.join(', ') + ') ' + t.inputType + ' → ' + t.outputType
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setQuery(text) {
|
|
134
|
+
this.state.setQuery(text)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setCursorPosition(pos) {
|
|
138
|
+
this.state.setCursorPosition(pos)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build context from text before cursor — determines what the editor expects next.
|
|
143
|
+
*/
|
|
144
|
+
buildContext(textBeforeCursor, fullText) {
|
|
145
|
+
if (!textBeforeCursor) {
|
|
146
|
+
return {
|
|
147
|
+
expecting: 'column',
|
|
148
|
+
column: '',
|
|
149
|
+
transformer: '',
|
|
150
|
+
state: State.EXPECT_COLUMN,
|
|
151
|
+
textBeforeCursor: '',
|
|
152
|
+
existingColumns: [],
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parser = new Parser(this.capabilities)
|
|
157
|
+
try {
|
|
158
|
+
parser.parse(textBeforeCursor, false, true)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return {
|
|
161
|
+
expecting: 'error',
|
|
162
|
+
column: '',
|
|
163
|
+
transformer: '',
|
|
164
|
+
state: State.ERROR,
|
|
165
|
+
error: e.message || 'Parse error',
|
|
166
|
+
textBeforeCursor,
|
|
167
|
+
existingColumns: parser.columns ? parser.columns.map((c) => c.name) : [],
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (parser.state === State.ERROR) {
|
|
172
|
+
return {
|
|
173
|
+
expecting: 'error',
|
|
174
|
+
column: '',
|
|
175
|
+
transformer: '',
|
|
176
|
+
state: State.ERROR,
|
|
177
|
+
error: parser.errorText || 'Parse error',
|
|
178
|
+
textBeforeCursor,
|
|
179
|
+
existingColumns: parser.columns ? parser.columns.map((c) => c.name) : [],
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const existingColumns = parser.columns ? parser.columns.map((c) => c.name) : []
|
|
184
|
+
|
|
185
|
+
const ctx = {
|
|
186
|
+
state: parser.state,
|
|
187
|
+
column: parser.column || '',
|
|
188
|
+
transformer: parser.transformer || '',
|
|
189
|
+
expecting: 'none',
|
|
190
|
+
textBeforeCursor,
|
|
191
|
+
existingColumns,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (parser.state === State.EXPECT_COLUMN || parser.state === State.COLUMN) {
|
|
195
|
+
ctx.expecting = 'column'
|
|
196
|
+
} else if (parser.state === State.EXPECT_TRANSFORMER || parser.state === State.TRANSFORMER) {
|
|
197
|
+
ctx.expecting = 'transformer'
|
|
198
|
+
} else if (
|
|
199
|
+
parser.state === State.EXPECT_ALIAS ||
|
|
200
|
+
parser.state === State.EXPECT_ALIAS_OPERATOR ||
|
|
201
|
+
parser.state === State.EXPECT_ALIAS_DELIMITER
|
|
202
|
+
) {
|
|
203
|
+
ctx.expecting = 'alias'
|
|
204
|
+
} else if (
|
|
205
|
+
parser.state === State.TRANSFORMER_ARGUMENT ||
|
|
206
|
+
parser.state === State.EXPECT_TRANSFORMER_ARGUMENT ||
|
|
207
|
+
parser.state === State.TRANSFORMER_ARGUMENT_DOUBLE_QUOTED ||
|
|
208
|
+
parser.state === State.TRANSFORMER_ARGUMENT_SINGLE_QUOTED ||
|
|
209
|
+
parser.state === State.EXPECT_TRANSFORMER_ARGUMENT_DELIMITER
|
|
210
|
+
) {
|
|
211
|
+
// Peek at char after cursor: if it's ) and parser is between args
|
|
212
|
+
// (waiting for comma or close), user is done — show next steps.
|
|
213
|
+
// But NOT when actively typing an argument or in empty parens.
|
|
214
|
+
const charAtCursor = fullText ? fullText[textBeforeCursor.length] : undefined
|
|
215
|
+
if (charAtCursor === ')' && parser.state === State.EXPECT_TRANSFORMER_ARGUMENT_DELIMITER) {
|
|
216
|
+
ctx.expecting = 'next'
|
|
217
|
+
} else {
|
|
218
|
+
ctx.expecting = 'argument'
|
|
219
|
+
}
|
|
220
|
+
} else if (parser.state === State.TRANSFORMER_COMPLETE) {
|
|
221
|
+
ctx.expecting = 'next'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return ctx
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Update suggestions based on current cursor position.
|
|
229
|
+
*/
|
|
230
|
+
async updateSuggestions() {
|
|
231
|
+
const seq = ++this._seq
|
|
232
|
+
const textBeforeCursor = this.state.getTextBeforeCursor()
|
|
233
|
+
const ctx = this.buildContext(textBeforeCursor, this.state.query)
|
|
234
|
+
this.context = ctx
|
|
235
|
+
this.message = ''
|
|
236
|
+
this.isLoading = false
|
|
237
|
+
this.suggestions = []
|
|
238
|
+
this.suggestionType = ''
|
|
239
|
+
this.state.selectedIndex = 0
|
|
240
|
+
|
|
241
|
+
if (ctx.expecting === 'column') {
|
|
242
|
+
const prefix = ctx.column.toLowerCase()
|
|
243
|
+
const existing = ctx.existingColumns
|
|
244
|
+
|
|
245
|
+
// Nested column path — delegate to shared helper
|
|
246
|
+
if (prefix.includes('.')) {
|
|
247
|
+
// Check if it's an exact leaf match — show next-step actions
|
|
248
|
+
const resolvedCol = resolveColumnDef(this.columns, ctx.column)
|
|
249
|
+
if (resolvedCol && !resolvedCol.children) {
|
|
250
|
+
const nextSteps = [
|
|
251
|
+
{
|
|
252
|
+
label: COLUMNS_DELIMITER,
|
|
253
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
254
|
+
type: 'delimiter',
|
|
255
|
+
detail: 'next column',
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
if (this.capabilities.transformers) {
|
|
259
|
+
nextSteps.push({
|
|
260
|
+
label: TRANSFORMER_OPERATOR,
|
|
261
|
+
insertText: TRANSFORMER_OPERATOR,
|
|
262
|
+
type: 'delimiter',
|
|
263
|
+
detail: 'add transformer',
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
const nested = getNestedColumnSuggestions(this.columns, ctx.column).filter(
|
|
267
|
+
(s) => !existing.includes(s.label) && s.label.toLowerCase() !== prefix,
|
|
268
|
+
)
|
|
269
|
+
this.suggestions = [...nextSteps, ...nested]
|
|
270
|
+
this.suggestionType = 'column'
|
|
271
|
+
return { ctx, seq }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const nested = getNestedColumnSuggestions(this.columns, ctx.column)
|
|
275
|
+
if (nested.length === 0) {
|
|
276
|
+
// Try remote key discovery for schemaless object columns
|
|
277
|
+
const discovered = await getKeyDiscoverySuggestions(
|
|
278
|
+
this.columns,
|
|
279
|
+
ctx.column,
|
|
280
|
+
this.onKeyDiscovery,
|
|
281
|
+
this.keyCache,
|
|
282
|
+
(loading) => {
|
|
283
|
+
if (this.isStale(seq)) return
|
|
284
|
+
this.isLoading = loading
|
|
285
|
+
if (this.onLoadingChange) this.onLoadingChange(loading)
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
if (this.isStale(seq)) return { ctx, seq }
|
|
289
|
+
this.suggestions = discovered.filter((s) => !existing.includes(s.label))
|
|
290
|
+
this.suggestionType = 'column'
|
|
291
|
+
if (this.suggestions.length === 0 && prefix) {
|
|
292
|
+
this.message = 'No matching columns'
|
|
293
|
+
}
|
|
294
|
+
return { ctx, seq }
|
|
295
|
+
}
|
|
296
|
+
this.suggestions = nested.filter((s) => !existing.includes(s.label))
|
|
297
|
+
this.suggestionType = 'column'
|
|
298
|
+
if (this.suggestions.length === 0 && prefix) {
|
|
299
|
+
this.message = 'No matching columns'
|
|
300
|
+
}
|
|
301
|
+
return { ctx, seq }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const columnSuggestions = []
|
|
305
|
+
let hasExactMatch = false
|
|
306
|
+
for (const [name, def] of Object.entries(this.columns.columns)) {
|
|
307
|
+
if (!def || def.suggest === false) continue
|
|
308
|
+
if (existing.includes(name)) continue
|
|
309
|
+
if (prefix && !name.toLowerCase().startsWith(prefix)) continue
|
|
310
|
+
if (prefix && name.toLowerCase() === prefix) hasExactMatch = true
|
|
311
|
+
const hasChildren = !!def.children
|
|
312
|
+
columnSuggestions.push({
|
|
313
|
+
label: name,
|
|
314
|
+
insertText: hasChildren ? name + '.' : name,
|
|
315
|
+
type: 'column',
|
|
316
|
+
detail: def.type || '',
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (hasExactMatch && prefix) {
|
|
321
|
+
// Exact match — check if it has children (object/map column)
|
|
322
|
+
const matchedDef = resolveColumnDef(this.columns, ctx.column)
|
|
323
|
+
if (matchedDef && matchedDef.children && Object.keys(matchedDef.children).length > 0) {
|
|
324
|
+
// Object column with children — show nested paths
|
|
325
|
+
const nested = getNestedColumnSuggestions(this.columns, ctx.column + '.')
|
|
326
|
+
this.suggestions = nested.filter((s) => !existing.includes(s.label))
|
|
327
|
+
this.suggestionType = 'column'
|
|
328
|
+
return { ctx, seq }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Leaf column — show next-step actions first, then remaining columns
|
|
332
|
+
const nextSteps = [
|
|
333
|
+
{
|
|
334
|
+
label: COLUMNS_DELIMITER,
|
|
335
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
336
|
+
type: 'delimiter',
|
|
337
|
+
detail: 'next column',
|
|
338
|
+
},
|
|
339
|
+
]
|
|
340
|
+
if (this.capabilities.transformers) {
|
|
341
|
+
nextSteps.push({
|
|
342
|
+
label: TRANSFORMER_OPERATOR,
|
|
343
|
+
insertText: TRANSFORMER_OPERATOR,
|
|
344
|
+
type: 'transformer',
|
|
345
|
+
detail: 'transformer (pipe)',
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
const otherColumns = columnSuggestions.filter((s) => s.label.toLowerCase() !== prefix)
|
|
349
|
+
this.suggestions = [...otherColumns, ...nextSteps]
|
|
350
|
+
this.suggestionType = 'next'
|
|
351
|
+
} else {
|
|
352
|
+
this.suggestions = columnSuggestions
|
|
353
|
+
this.suggestionType = 'column'
|
|
354
|
+
if (columnSuggestions.length === 0 && prefix) {
|
|
355
|
+
this.message = 'No matching columns'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} else if (ctx.expecting === 'transformer') {
|
|
359
|
+
if (!this.capabilities.transformers) {
|
|
360
|
+
this.message = 'transformers are not enabled'
|
|
361
|
+
this.suggestionType = ''
|
|
362
|
+
return { ctx, seq }
|
|
363
|
+
}
|
|
364
|
+
const prefix = ctx.transformer.toLowerCase()
|
|
365
|
+
const names = this.registry.names()
|
|
366
|
+
const hasExactMatch = prefix && names.some((m) => m.toLowerCase() === prefix)
|
|
367
|
+
|
|
368
|
+
if (hasExactMatch) {
|
|
369
|
+
const matchedName = names.find((m) => m.toLowerCase() === prefix)
|
|
370
|
+
const t = matchedName ? this.registry.get(matchedName) : null
|
|
371
|
+
const hasArgs = t && t.argSchema && t.argSchema.length > 0
|
|
372
|
+
const nextSteps = [
|
|
373
|
+
{
|
|
374
|
+
label: COLUMNS_DELIMITER,
|
|
375
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
376
|
+
type: 'delimiter',
|
|
377
|
+
detail: 'next column',
|
|
378
|
+
},
|
|
379
|
+
]
|
|
380
|
+
if (hasArgs) {
|
|
381
|
+
nextSteps.push({
|
|
382
|
+
label: '()',
|
|
383
|
+
insertText: '()',
|
|
384
|
+
type: 'delimiter',
|
|
385
|
+
detail: this._transformerDetail(prefix),
|
|
386
|
+
cursorOffset: -1,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
nextSteps.push({
|
|
390
|
+
label: TRANSFORMER_OPERATOR,
|
|
391
|
+
insertText: TRANSFORMER_OPERATOR,
|
|
392
|
+
type: 'transformer',
|
|
393
|
+
detail: 'transformer (pipe)',
|
|
394
|
+
})
|
|
395
|
+
const otherMods = []
|
|
396
|
+
for (const mod of names) {
|
|
397
|
+
if (mod.toLowerCase() === prefix) continue
|
|
398
|
+
if (!mod.toLowerCase().startsWith(prefix)) continue
|
|
399
|
+
otherMods.push({
|
|
400
|
+
label: mod,
|
|
401
|
+
insertText: mod,
|
|
402
|
+
type: 'transformer',
|
|
403
|
+
detail: this._transformerDetail(mod),
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
this.suggestions = [...otherMods, ...nextSteps]
|
|
407
|
+
this.suggestionType = 'next'
|
|
408
|
+
} else {
|
|
409
|
+
const suggestions = []
|
|
410
|
+
for (const mod of names) {
|
|
411
|
+
if (prefix && !mod.toLowerCase().startsWith(prefix)) continue
|
|
412
|
+
suggestions.push({
|
|
413
|
+
label: mod,
|
|
414
|
+
insertText: mod,
|
|
415
|
+
type: 'transformer',
|
|
416
|
+
detail: this._transformerDetail(mod),
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
this.suggestions = suggestions
|
|
420
|
+
this.suggestionType = 'transformer'
|
|
421
|
+
if (suggestions.length === 0 && prefix) {
|
|
422
|
+
this.message = 'No matching transformers'
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} else if (ctx.expecting === 'alias') {
|
|
426
|
+
if (ctx.state === State.EXPECT_ALIAS) {
|
|
427
|
+
// Inside alias value (e.g. "column as RC") — only separator is valid
|
|
428
|
+
this.suggestions = [
|
|
429
|
+
{
|
|
430
|
+
label: COLUMNS_DELIMITER,
|
|
431
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
432
|
+
type: 'delimiter',
|
|
433
|
+
detail: 'next column',
|
|
434
|
+
},
|
|
435
|
+
]
|
|
436
|
+
} else {
|
|
437
|
+
// After column/transformer+space, before alias operator — pipe and comma are valid
|
|
438
|
+
const items = []
|
|
439
|
+
if (this.capabilities.transformers) {
|
|
440
|
+
items.push({
|
|
441
|
+
label: TRANSFORMER_OPERATOR,
|
|
442
|
+
insertText: TRANSFORMER_OPERATOR,
|
|
443
|
+
type: 'transformer',
|
|
444
|
+
detail: 'transformer (pipe)',
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
items.push({
|
|
448
|
+
label: COLUMNS_DELIMITER,
|
|
449
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
450
|
+
type: 'delimiter',
|
|
451
|
+
detail: 'next column',
|
|
452
|
+
})
|
|
453
|
+
this.suggestions = items
|
|
454
|
+
}
|
|
455
|
+
this.suggestionType = 'delimiter'
|
|
456
|
+
} else if (ctx.expecting === 'next') {
|
|
457
|
+
// After transformer with args completes — suggest comma or pipe
|
|
458
|
+
const items = [
|
|
459
|
+
{
|
|
460
|
+
label: COLUMNS_DELIMITER,
|
|
461
|
+
insertText: COLUMNS_DELIMITER + ' ',
|
|
462
|
+
type: 'delimiter',
|
|
463
|
+
detail: 'next column',
|
|
464
|
+
},
|
|
465
|
+
]
|
|
466
|
+
if (this.capabilities.transformers) {
|
|
467
|
+
items.push({
|
|
468
|
+
label: TRANSFORMER_OPERATOR,
|
|
469
|
+
insertText: TRANSFORMER_OPERATOR,
|
|
470
|
+
type: 'transformer',
|
|
471
|
+
detail: 'transformer (pipe)',
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
this.suggestions = items
|
|
475
|
+
this.suggestionType = 'delimiter'
|
|
476
|
+
} else if (ctx.expecting === 'error') {
|
|
477
|
+
this.message = ctx.error
|
|
478
|
+
this.suggestionType = ''
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { ctx, seq }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
isStale(seq) {
|
|
485
|
+
return seq !== this._seq
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Generate highlight tokens as HTML string.
|
|
490
|
+
*/
|
|
491
|
+
getHighlightTokens(query, diagnostics = null, highlightDiagIndex = -1) {
|
|
492
|
+
const value = query !== undefined ? query : this.state.query
|
|
493
|
+
if (!value) return ''
|
|
494
|
+
|
|
495
|
+
const parser = new Parser(this.capabilities)
|
|
496
|
+
try {
|
|
497
|
+
parser.parse(value, false, true)
|
|
498
|
+
} catch {
|
|
499
|
+
return escapeHtml(value)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const typedChars = parser.typedChars
|
|
503
|
+
if (!typedChars || typedChars.length === 0) {
|
|
504
|
+
return escapeHtml(value)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Build per-position diagnostic map
|
|
508
|
+
let diagMap = null
|
|
509
|
+
let highlightSet = null
|
|
510
|
+
if (diagnostics && diagnostics.length > 0) {
|
|
511
|
+
diagMap = {}
|
|
512
|
+
for (let di = 0; di < diagnostics.length; di++) {
|
|
513
|
+
const d = diagnostics[di]
|
|
514
|
+
for (let j = d.range.start; j < d.range.end && j < value.length; j++) {
|
|
515
|
+
if (!diagMap[j]) {
|
|
516
|
+
diagMap[j] = { diag: d, index: di }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (highlightDiagIndex >= 0 && highlightDiagIndex < diagnostics.length) {
|
|
521
|
+
highlightSet = new Set()
|
|
522
|
+
const hd = diagnostics[highlightDiagIndex]
|
|
523
|
+
for (let j = hd.range.start; j < hd.range.end && j < value.length; j++) {
|
|
524
|
+
highlightSet.add(j)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Build highlight using char positions — columns parser skips spaces
|
|
530
|
+
// in some states, so typedChars count != value length. Use pos to align.
|
|
531
|
+
let html = ''
|
|
532
|
+
let currentType = null
|
|
533
|
+
let currentText = ''
|
|
534
|
+
let currentDiag = null
|
|
535
|
+
let currentHighlight = false
|
|
536
|
+
let lastPos = -1
|
|
537
|
+
|
|
538
|
+
const flushSpan = () => {
|
|
539
|
+
if (!currentText) return
|
|
540
|
+
const inner = wrapSpan(currentType, currentText)
|
|
541
|
+
if (currentDiag || currentHighlight) {
|
|
542
|
+
const classes = ['flyql-diagnostic']
|
|
543
|
+
if (currentDiag) {
|
|
544
|
+
classes.push('flyql-diagnostic--' + (currentDiag.diag.severity === 'warning' ? 'warning' : 'error'))
|
|
545
|
+
}
|
|
546
|
+
if (currentHighlight) {
|
|
547
|
+
classes.push('flyql-diagnostic--highlight')
|
|
548
|
+
}
|
|
549
|
+
const title = currentDiag ? ` title="${escapeHtml(currentDiag.diag.message)}"` : ''
|
|
550
|
+
html += `<span class="${classes.join(' ')}"${title}>${inner}</span>`
|
|
551
|
+
} else {
|
|
552
|
+
html += inner
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (let i = 0; i < typedChars.length; i++) {
|
|
557
|
+
const char = typedChars[i][0]
|
|
558
|
+
const charType = typedChars[i][1]
|
|
559
|
+
const pos = char.pos
|
|
560
|
+
|
|
561
|
+
// Fill any gap (untracked chars like spaces) as plain text
|
|
562
|
+
if (pos > lastPos + 1) {
|
|
563
|
+
flushSpan()
|
|
564
|
+
currentText = ''
|
|
565
|
+
currentType = null
|
|
566
|
+
currentDiag = null
|
|
567
|
+
currentHighlight = false
|
|
568
|
+
// Render gap characters with diagnostic overlay
|
|
569
|
+
for (let gapPos = lastPos + 1; gapPos < pos; gapPos++) {
|
|
570
|
+
const gapDiag = diagMap ? diagMap[gapPos] || null : null
|
|
571
|
+
const gapHighlight = highlightSet ? highlightSet.has(gapPos) : false
|
|
572
|
+
const gapCh = value[gapPos]
|
|
573
|
+
if (gapDiag || gapHighlight) {
|
|
574
|
+
const classes = ['flyql-diagnostic']
|
|
575
|
+
if (gapDiag)
|
|
576
|
+
classes.push(
|
|
577
|
+
'flyql-diagnostic--' + (gapDiag.diag.severity === 'warning' ? 'warning' : 'error'),
|
|
578
|
+
)
|
|
579
|
+
if (gapHighlight) classes.push('flyql-diagnostic--highlight')
|
|
580
|
+
const title = gapDiag ? ` title="${escapeHtml(gapDiag.diag.message)}"` : ''
|
|
581
|
+
html += `<span class="${classes.join(' ')}"${title}>${escapeHtml(gapCh)}</span>`
|
|
582
|
+
} else {
|
|
583
|
+
html += escapeHtml(gapCh)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const ch = value[pos] !== undefined ? value[pos] : char.value
|
|
589
|
+
const newDiag = diagMap ? diagMap[pos] || null : null
|
|
590
|
+
const newHighlight = highlightSet ? highlightSet.has(pos) : false
|
|
591
|
+
if (
|
|
592
|
+
charType === currentType &&
|
|
593
|
+
newDiag === currentDiag &&
|
|
594
|
+
newHighlight === currentHighlight &&
|
|
595
|
+
ch !== '\n'
|
|
596
|
+
) {
|
|
597
|
+
currentText += ch
|
|
598
|
+
} else {
|
|
599
|
+
flushSpan()
|
|
600
|
+
currentType = charType
|
|
601
|
+
currentDiag = newDiag
|
|
602
|
+
currentHighlight = newHighlight
|
|
603
|
+
currentText = ch
|
|
604
|
+
}
|
|
605
|
+
lastPos = pos
|
|
606
|
+
}
|
|
607
|
+
flushSpan()
|
|
608
|
+
|
|
609
|
+
// Render any remaining untracked characters after last typed char
|
|
610
|
+
if (lastPos + 1 < value.length && parser.state !== State.ERROR) {
|
|
611
|
+
html += escapeHtml(value.substring(lastPos + 1))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (parser.state === State.ERROR && lastPos + 1 < value.length) {
|
|
615
|
+
const remaining = value.substring(lastPos + 1)
|
|
616
|
+
html += `<span class="flyql-col-error">${escapeHtml(remaining)}</span>`
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return html
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Parse the full expression and return ParsedColumn array.
|
|
624
|
+
*/
|
|
625
|
+
getParsedColumns() {
|
|
626
|
+
const value = this.state.query
|
|
627
|
+
if (!value) return []
|
|
628
|
+
try {
|
|
629
|
+
return parseColumns(value, this.capabilities)
|
|
630
|
+
} catch {
|
|
631
|
+
return []
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Validate the expression and return status.
|
|
637
|
+
*/
|
|
638
|
+
getQueryStatus() {
|
|
639
|
+
const value = this.state.query
|
|
640
|
+
if (!value) return { valid: true, message: 'Empty' }
|
|
641
|
+
const parser = new Parser(this.capabilities)
|
|
642
|
+
try {
|
|
643
|
+
parser.parse(value, false, false)
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return { valid: false, message: e.message || 'Parse error' }
|
|
646
|
+
}
|
|
647
|
+
if (parser.state === State.ERROR) {
|
|
648
|
+
return { valid: false, message: parser.errorText || 'Parse error' }
|
|
649
|
+
}
|
|
650
|
+
if (
|
|
651
|
+
parser.state === State.COLUMN ||
|
|
652
|
+
parser.state === State.EXPECT_COLUMN ||
|
|
653
|
+
parser.state === State.EXPECT_ALIAS_OPERATOR ||
|
|
654
|
+
parser.state === State.EXPECT_ALIAS ||
|
|
655
|
+
parser.state === State.TRANSFORMER ||
|
|
656
|
+
parser.state === State.TRANSFORMER_COMPLETE
|
|
657
|
+
) {
|
|
658
|
+
return { valid: true, message: 'Valid columns expression' }
|
|
659
|
+
}
|
|
660
|
+
return { valid: false, message: 'Incomplete expression' }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
_buildValidatorColumns() {
|
|
664
|
+
return this.columns
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
getDiagnostics() {
|
|
668
|
+
const value = this.state.query
|
|
669
|
+
if (!value) {
|
|
670
|
+
this.diagnostics = []
|
|
671
|
+
return this.diagnostics
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const parser = new Parser(this.capabilities)
|
|
675
|
+
let typedChars
|
|
676
|
+
try {
|
|
677
|
+
parser.parse(value, false, false)
|
|
678
|
+
typedChars = parser.typedChars
|
|
679
|
+
} catch (e) {
|
|
680
|
+
typedChars = parser.typedChars
|
|
681
|
+
const start = typedChars && typedChars.length > 0 ? typedChars[typedChars.length - 1][0].pos + 1 : 0
|
|
682
|
+
// Suppress syntax errors at end of input
|
|
683
|
+
if (start >= value.length - 1) {
|
|
684
|
+
this.diagnostics = []
|
|
685
|
+
return this.diagnostics
|
|
686
|
+
}
|
|
687
|
+
this.diagnostics = [
|
|
688
|
+
new Diagnostic(new Range(start, value.length), e.message || 'Parse error', 'error', 'syntax'),
|
|
689
|
+
]
|
|
690
|
+
return this.diagnostics
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (parser.state === State.ERROR) {
|
|
694
|
+
const start = typedChars && typedChars.length > 0 ? typedChars[typedChars.length - 1][0].pos + 1 : 0
|
|
695
|
+
if (start >= value.length - 1) {
|
|
696
|
+
this.diagnostics = []
|
|
697
|
+
return this.diagnostics
|
|
698
|
+
}
|
|
699
|
+
this.diagnostics = [
|
|
700
|
+
new Diagnostic(new Range(start, value.length), parser.errorText || 'Parse error', 'error', 'syntax'),
|
|
701
|
+
]
|
|
702
|
+
return this.diagnostics
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let parsedColumns
|
|
706
|
+
try {
|
|
707
|
+
parsedColumns = parseColumns(value, this.capabilities)
|
|
708
|
+
} catch {
|
|
709
|
+
this.diagnostics = []
|
|
710
|
+
return this.diagnostics
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!parsedColumns || parsedColumns.length === 0) {
|
|
714
|
+
this.diagnostics = []
|
|
715
|
+
return this.diagnostics
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const validatorColumns = this._buildValidatorColumns()
|
|
719
|
+
const reg = this.registry
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
this.diagnostics = diagnose(parsedColumns, validatorColumns, reg)
|
|
723
|
+
} catch {
|
|
724
|
+
this.diagnostics = []
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Smart suppression at end of input
|
|
728
|
+
if (this.diagnostics.length > 0) {
|
|
729
|
+
const queryLen = value.trimEnd().length
|
|
730
|
+
const transformerNames = reg.names()
|
|
731
|
+
const columnNames = Object.keys(this.columns.columns).map((n) => n.toLowerCase())
|
|
732
|
+
this.diagnostics = this.diagnostics.filter((d) => {
|
|
733
|
+
if (d.range.end < queryLen) return true
|
|
734
|
+
if (d.code === CODE_UNKNOWN_TRANSFORMER) {
|
|
735
|
+
const match = d.message.match(/^unknown transformer: '(.+)'$/)
|
|
736
|
+
if (!match) return true
|
|
737
|
+
const partial = match[1]
|
|
738
|
+
return !transformerNames.some((n) => n.startsWith(partial) && n !== partial)
|
|
739
|
+
}
|
|
740
|
+
if (d.code === CODE_UNKNOWN_COLUMN) {
|
|
741
|
+
const match = d.message.match(/^column '(.+)' is not defined$/)
|
|
742
|
+
if (!match) return true
|
|
743
|
+
const partial = match[1].toLowerCase()
|
|
744
|
+
return !columnNames.some((n) => n.startsWith(partial) && n !== partial)
|
|
745
|
+
}
|
|
746
|
+
return true
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return this.diagnostics
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
getParseError() {
|
|
754
|
+
if (this.context && this.context.expecting === 'error') {
|
|
755
|
+
return this.context.error
|
|
756
|
+
}
|
|
757
|
+
return null
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Get the text range to replace when accepting a suggestion.
|
|
762
|
+
* If suggestion is an operator type, insert at cursor without replacing prefix.
|
|
763
|
+
*/
|
|
764
|
+
getInsertRange(ctx, fullText, suggestion) {
|
|
765
|
+
const context = ctx || this.context
|
|
766
|
+
if (!context) return { start: 0, end: 0 }
|
|
767
|
+
|
|
768
|
+
const cursor = context.textBeforeCursor.length
|
|
769
|
+
|
|
770
|
+
// Calculate end position: include trailing word chars after cursor
|
|
771
|
+
let endPos = cursor
|
|
772
|
+
if (fullText) {
|
|
773
|
+
const afterCursor = fullText.substring(cursor)
|
|
774
|
+
const trailingMatch = afterCursor.match(/^[^\s,|()]+/)
|
|
775
|
+
if (trailingMatch) {
|
|
776
|
+
endPos = cursor + trailingMatch[0].length
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Delimiter and pipe suggestions insert at cursor, don't replace prefix
|
|
781
|
+
if (suggestion && (suggestion.type === 'delimiter' || suggestion.label === '|' || suggestion.label === '()')) {
|
|
782
|
+
return { start: cursor, end: cursor }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (context.expecting === 'column') {
|
|
786
|
+
const prefix = context.column || ''
|
|
787
|
+
return { start: cursor - prefix.length, end: endPos }
|
|
788
|
+
}
|
|
789
|
+
if (context.expecting === 'transformer') {
|
|
790
|
+
const prefix = context.transformer || ''
|
|
791
|
+
return { start: cursor - prefix.length, end: endPos }
|
|
792
|
+
}
|
|
793
|
+
return { start: cursor, end: endPos }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
navigateUp() {
|
|
797
|
+
if (this.suggestions.length === 0) return
|
|
798
|
+
this.state.selectedIndex =
|
|
799
|
+
this.state.selectedIndex <= 0 ? this.suggestions.length - 1 : this.state.selectedIndex - 1
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
navigateDown() {
|
|
803
|
+
if (this.suggestions.length === 0) return
|
|
804
|
+
this.state.selectedIndex =
|
|
805
|
+
this.state.selectedIndex >= this.suggestions.length - 1 ? 0 : this.state.selectedIndex + 1
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
selectSuggestion(index) {
|
|
809
|
+
return this.suggestions[index] || null
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
getStateLabel() {
|
|
813
|
+
return STATE_LABELS[this.suggestionType] || ''
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
getFooterInfo() {
|
|
817
|
+
if (!this.context) return null
|
|
818
|
+
const col = this.context.column || ''
|
|
819
|
+
if (!col) return null
|
|
820
|
+
const def = resolveColumnDef(this.columns, col)
|
|
821
|
+
if (def) {
|
|
822
|
+
return { column: col, type: def.type || '' }
|
|
823
|
+
}
|
|
824
|
+
return { column: col, type: '' }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
clearKeyCache() {
|
|
828
|
+
this.keyCache = {}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
getFilterPrefix() {
|
|
832
|
+
if (!this.context) return ''
|
|
833
|
+
if (this.context.expecting === 'column') return this.context.column || ''
|
|
834
|
+
if (this.context.expecting === 'transformer') return this.context.transformer || ''
|
|
835
|
+
return ''
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
highlightMatch(label) {
|
|
839
|
+
const prefix = this.getFilterPrefix()
|
|
840
|
+
if (!prefix) return escapeHtml(label)
|
|
841
|
+
if (!label.toLowerCase().startsWith(prefix.toLowerCase())) return escapeHtml(label)
|
|
842
|
+
const matched = escapeHtml(label.substring(0, prefix.length))
|
|
843
|
+
const rest = escapeHtml(label.substring(prefix.length))
|
|
844
|
+
return `<span class="flyql-panel__match">${matched}</span>${rest}`
|
|
845
|
+
}
|
|
846
|
+
}
|