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
package/src/engine.js
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorEngine — framework-agnostic editor logic.
|
|
3
|
+
* Pure JS class, no Vue/React/DOM dependencies.
|
|
4
|
+
* One instance per editor component.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Parser,
|
|
9
|
+
CharType,
|
|
10
|
+
State,
|
|
11
|
+
VALID_KEY_VALUE_OPERATORS,
|
|
12
|
+
isNumeric,
|
|
13
|
+
Column,
|
|
14
|
+
ColumnSchema,
|
|
15
|
+
Diagnostic,
|
|
16
|
+
diagnose,
|
|
17
|
+
CODE_UNKNOWN_COLUMN,
|
|
18
|
+
CODE_UNKNOWN_TRANSFORMER,
|
|
19
|
+
Range,
|
|
20
|
+
} from 'flyql/core'
|
|
21
|
+
import { Type } from 'flyql'
|
|
22
|
+
import { defaultRegistry } from 'flyql/transformers'
|
|
23
|
+
import { EditorState } from './state.js'
|
|
24
|
+
import {
|
|
25
|
+
updateSuggestions,
|
|
26
|
+
prepareSuggestionValues,
|
|
27
|
+
resolveColumnDef,
|
|
28
|
+
getColumnSuggestionsForValue,
|
|
29
|
+
getInsertRange,
|
|
30
|
+
STATE_LABELS,
|
|
31
|
+
} from './suggestions.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Maps editor-input raw-type strings (as appearing in user schema definitions)
|
|
35
|
+
* to canonical flyql.Type values. Unknown strings fall through to Type.Unknown
|
|
36
|
+
* so the validator's chain check is skipped cleanly (never leak raw strings).
|
|
37
|
+
*/
|
|
38
|
+
const EDITOR_TYPE_TO_FLYQL = {
|
|
39
|
+
enum: Type.String,
|
|
40
|
+
string: Type.String,
|
|
41
|
+
number: Type.Int,
|
|
42
|
+
int: Type.Int,
|
|
43
|
+
integer: Type.Int,
|
|
44
|
+
float: Type.Float,
|
|
45
|
+
bool: Type.Bool,
|
|
46
|
+
boolean: Type.Bool,
|
|
47
|
+
array: Type.Array,
|
|
48
|
+
map: Type.Map,
|
|
49
|
+
struct: Type.Struct,
|
|
50
|
+
json: Type.JSON,
|
|
51
|
+
date: Type.Date,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const _FLYQL_TYPE_VALUES = new Set(Object.values(Type))
|
|
55
|
+
|
|
56
|
+
function _applyEditorTypeNormalization(col) {
|
|
57
|
+
if (col.type && !_FLYQL_TYPE_VALUES.has(col.type)) {
|
|
58
|
+
const mapped = EDITOR_TYPE_TO_FLYQL[col.type]
|
|
59
|
+
col.type = mapped !== undefined ? mapped : Type.Unknown
|
|
60
|
+
}
|
|
61
|
+
if (col.children) {
|
|
62
|
+
for (const child of Object.values(col.children)) {
|
|
63
|
+
if (child) _applyEditorTypeNormalization(child)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CHAR_TYPE_CLASS = {
|
|
69
|
+
[CharType.KEY]: 'flyql-key',
|
|
70
|
+
[CharType.OPERATOR]: 'flyql-operator',
|
|
71
|
+
[CharType.VALUE]: 'flyql-value',
|
|
72
|
+
[CharType.NUMBER]: 'flyql-number',
|
|
73
|
+
[CharType.STRING]: 'flyql-string',
|
|
74
|
+
[CharType.BOOLEAN]: 'flyql-boolean',
|
|
75
|
+
[CharType.NULL]: 'flyql-null',
|
|
76
|
+
[CharType.SPACE]: 'flyql-space',
|
|
77
|
+
[CharType.PIPE]: 'flyql-transformer',
|
|
78
|
+
[CharType.TRANSFORMER]: 'flyql-transformer',
|
|
79
|
+
[CharType.ARGUMENT]: 'flyql-argument',
|
|
80
|
+
[CharType.ARGUMENT_STRING]: 'flyql-argument-string',
|
|
81
|
+
[CharType.ARGUMENT_NUMBER]: 'flyql-argument-number',
|
|
82
|
+
[CharType.WILDCARD]: 'flyql-wildcard',
|
|
83
|
+
[CharType.COLUMN]: 'flyql-column',
|
|
84
|
+
[CharType.PARAMETER]: 'flyql-parameter',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeHtml(str) {
|
|
88
|
+
return str
|
|
89
|
+
.replace(/&/g, '&')
|
|
90
|
+
.replace(/</g, '<')
|
|
91
|
+
.replace(/>/g, '>')
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
.replace(/'/g, ''')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalize newlines to spaces for parser consumption.
|
|
98
|
+
* The editor supports multiline visually, but the parser only recognizes spaces as delimiters.
|
|
99
|
+
*/
|
|
100
|
+
function normalizeForParser(text) {
|
|
101
|
+
return text.replace(/\r\n/g, ' ').replace(/[\r\n]/g, ' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function wrapSpan(charType, text) {
|
|
105
|
+
const escaped = escapeHtml(text)
|
|
106
|
+
const cls = CHAR_TYPE_CLASS[charType]
|
|
107
|
+
if (cls) {
|
|
108
|
+
return `<span class="${cls}">${escaped}</span>`
|
|
109
|
+
}
|
|
110
|
+
return escaped
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class EditorEngine {
|
|
114
|
+
constructor(schema, options = {}) {
|
|
115
|
+
this.columns = schema || new ColumnSchema({})
|
|
116
|
+
for (const col of Object.values(this.columns.columns)) {
|
|
117
|
+
if (col) _applyEditorTypeNormalization(col)
|
|
118
|
+
}
|
|
119
|
+
this.onAutocomplete = options.onAutocomplete || null
|
|
120
|
+
this.onKeyDiscovery = options.onKeyDiscovery || null
|
|
121
|
+
this.onLoadingChange = options.onLoadingChange || null
|
|
122
|
+
this.registry = options.registry || defaultRegistry()
|
|
123
|
+
this.parameters = options.parameters || []
|
|
124
|
+
this.debounceMs = options.debounceMs ?? 300
|
|
125
|
+
this.state = new EditorState()
|
|
126
|
+
this.context = null
|
|
127
|
+
this.suggestions = []
|
|
128
|
+
this.suggestionType = ''
|
|
129
|
+
this.incomplete = false
|
|
130
|
+
this.message = ''
|
|
131
|
+
|
|
132
|
+
this.activeTab = 'values'
|
|
133
|
+
this.valueSuggestions = []
|
|
134
|
+
this.columnSuggestions = []
|
|
135
|
+
this.valueMessage = ''
|
|
136
|
+
|
|
137
|
+
this.isLoading = false
|
|
138
|
+
this.keyCache = {}
|
|
139
|
+
this._suggestionSeq = 0
|
|
140
|
+
this._debounceTimer = null
|
|
141
|
+
this._valueState = null
|
|
142
|
+
this._lastValueKey = null
|
|
143
|
+
this._validatorColumns = null
|
|
144
|
+
this.diagnostics = []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setTab(tab) {
|
|
148
|
+
if (tab === this.activeTab) return
|
|
149
|
+
this.activeTab = tab
|
|
150
|
+
this.suggestions = tab === 'values' ? this.valueSuggestions : this.columnSuggestions
|
|
151
|
+
this.message = tab === 'values' ? this.valueMessage : ''
|
|
152
|
+
this.state.selectedIndex = 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
cycleTab() {
|
|
156
|
+
this.setTab(this.activeTab === 'values' ? 'columns' : 'values')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_resetTabState() {
|
|
160
|
+
this.activeTab = 'values'
|
|
161
|
+
this.valueSuggestions = []
|
|
162
|
+
this.columnSuggestions = []
|
|
163
|
+
this.valueMessage = ''
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Set columns and invalidate the validator column cache.
|
|
168
|
+
*/
|
|
169
|
+
setColumns(schema) {
|
|
170
|
+
this.columns = schema || new ColumnSchema({})
|
|
171
|
+
for (const col of Object.values(this.columns.columns)) {
|
|
172
|
+
if (col) _applyEditorTypeNormalization(col)
|
|
173
|
+
}
|
|
174
|
+
this._validatorColumns = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Set the list of available parameter names (without `$` prefix).
|
|
179
|
+
*/
|
|
180
|
+
setParameters(params) {
|
|
181
|
+
this.parameters = params || []
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setRegistry(registry) {
|
|
185
|
+
this.registry = registry || defaultRegistry()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_buildValidatorColumns() {
|
|
189
|
+
// ColumnSchema is already typed — return it directly for the validator
|
|
190
|
+
this._validatorColumns = this.columns
|
|
191
|
+
return this._validatorColumns
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run the validator on the current query and return diagnostics.
|
|
196
|
+
* Includes parser syntax errors and semantic validation errors.
|
|
197
|
+
*/
|
|
198
|
+
getDiagnostics() {
|
|
199
|
+
const value = this.state.query
|
|
200
|
+
if (!value) {
|
|
201
|
+
this.diagnostics = []
|
|
202
|
+
return this.diagnostics
|
|
203
|
+
}
|
|
204
|
+
const normalized = normalizeForParser(value)
|
|
205
|
+
const parser = new Parser()
|
|
206
|
+
try {
|
|
207
|
+
parser.parse(normalized, false, false)
|
|
208
|
+
} catch (e) {
|
|
209
|
+
const range = e.range || new Range(parser.typedChars ? parser.typedChars.length : 0, normalized.length)
|
|
210
|
+
// Suppress syntax errors at the end of query — user is still typing
|
|
211
|
+
if (range.end >= normalized.length) {
|
|
212
|
+
this.diagnostics = []
|
|
213
|
+
return this.diagnostics
|
|
214
|
+
}
|
|
215
|
+
this.diagnostics = [new Diagnostic(range, e.message || 'Parse error', 'error', 'syntax')]
|
|
216
|
+
return this.diagnostics
|
|
217
|
+
}
|
|
218
|
+
if (parser.state === State.ERROR) {
|
|
219
|
+
const start = parser.typedChars ? parser.typedChars.length : 0
|
|
220
|
+
// Suppress if error is at the end of query
|
|
221
|
+
if (start >= normalized.length - 1) {
|
|
222
|
+
this.diagnostics = []
|
|
223
|
+
return this.diagnostics
|
|
224
|
+
}
|
|
225
|
+
this.diagnostics = [
|
|
226
|
+
new Diagnostic(
|
|
227
|
+
new Range(start, normalized.length),
|
|
228
|
+
parser.errorText || 'Parse error',
|
|
229
|
+
'error',
|
|
230
|
+
'syntax',
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
return this.diagnostics
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
parser.state !== State.EXPECT_BOOL_OP &&
|
|
237
|
+
parser.state !== State.VALUE &&
|
|
238
|
+
parser.state !== State.KEY &&
|
|
239
|
+
parser.state !== State.KEY_OR_BOOL_OP
|
|
240
|
+
) {
|
|
241
|
+
this.diagnostics = []
|
|
242
|
+
return this.diagnostics
|
|
243
|
+
}
|
|
244
|
+
if (!parser.root) {
|
|
245
|
+
this.diagnostics = []
|
|
246
|
+
return this.diagnostics
|
|
247
|
+
}
|
|
248
|
+
const columns = this._validatorColumns || this._buildValidatorColumns()
|
|
249
|
+
const reg = this.registry
|
|
250
|
+
try {
|
|
251
|
+
this.diagnostics = diagnose(parser.root, columns, reg)
|
|
252
|
+
} catch {
|
|
253
|
+
this.diagnostics = []
|
|
254
|
+
}
|
|
255
|
+
// Suppress unknown_transformer/unknown_column at query end when name is a prefix of a known one
|
|
256
|
+
if (this.diagnostics.length > 0) {
|
|
257
|
+
const queryLen = normalized.length
|
|
258
|
+
const transformerNames = reg.names()
|
|
259
|
+
const columnNames = Object.keys(this.columns.columns).map((n) => n.toLowerCase())
|
|
260
|
+
this.diagnostics = this.diagnostics.filter((d) => {
|
|
261
|
+
if (d.range.end < queryLen) return true
|
|
262
|
+
if (d.code === CODE_UNKNOWN_TRANSFORMER) {
|
|
263
|
+
const match = d.message.match(/^unknown transformer: '(.+)'$/)
|
|
264
|
+
if (!match) return true
|
|
265
|
+
const partial = match[1]
|
|
266
|
+
return !transformerNames.some((n) => n.startsWith(partial) && n !== partial)
|
|
267
|
+
}
|
|
268
|
+
if (d.code === CODE_UNKNOWN_COLUMN) {
|
|
269
|
+
const match = d.message.match(/^column '(.+)' is not defined$/)
|
|
270
|
+
if (!match) return true
|
|
271
|
+
const partial = match[1].toLowerCase()
|
|
272
|
+
return !columnNames.some((n) => n.startsWith(partial) && n !== partial)
|
|
273
|
+
}
|
|
274
|
+
return true
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
return this.diagnostics
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Set the query text and update cursor position.
|
|
282
|
+
*/
|
|
283
|
+
setQuery(text) {
|
|
284
|
+
this.state.setQuery(text)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Set cursor position within the query.
|
|
289
|
+
*/
|
|
290
|
+
setCursorPosition(pos) {
|
|
291
|
+
this.state.setCursorPosition(pos)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build context from text before cursor — determines what the editor expects next.
|
|
296
|
+
*/
|
|
297
|
+
buildContext(textBeforeCursor) {
|
|
298
|
+
if (!textBeforeCursor) {
|
|
299
|
+
return {
|
|
300
|
+
expecting: 'column',
|
|
301
|
+
key: '',
|
|
302
|
+
value: '',
|
|
303
|
+
quoteChar: '',
|
|
304
|
+
keyValueOperator: '',
|
|
305
|
+
state: 'INITIAL',
|
|
306
|
+
textBeforeCursor: '',
|
|
307
|
+
nestingDepth: 0,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const normalized = normalizeForParser(textBeforeCursor)
|
|
312
|
+
const parser = new Parser()
|
|
313
|
+
try {
|
|
314
|
+
parser.parse(normalized, false, true)
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return {
|
|
317
|
+
expecting: '',
|
|
318
|
+
key: '',
|
|
319
|
+
value: '',
|
|
320
|
+
quoteChar: '',
|
|
321
|
+
keyValueOperator: '',
|
|
322
|
+
state: 'ERROR',
|
|
323
|
+
error: e.message || 'Parse error',
|
|
324
|
+
textBeforeCursor,
|
|
325
|
+
nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (parser.state === State.ERROR) {
|
|
330
|
+
return {
|
|
331
|
+
expecting: '',
|
|
332
|
+
key: '',
|
|
333
|
+
value: '',
|
|
334
|
+
quoteChar: '',
|
|
335
|
+
keyValueOperator: '',
|
|
336
|
+
state: 'ERROR',
|
|
337
|
+
error: parser.errorText || 'Parse error',
|
|
338
|
+
textBeforeCursor,
|
|
339
|
+
nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ctx = {
|
|
344
|
+
state: parser.state,
|
|
345
|
+
key: parser.key || '',
|
|
346
|
+
value: parser.value || '',
|
|
347
|
+
keyValueOperator: parser.keyValueOperator || '',
|
|
348
|
+
quoteChar: '',
|
|
349
|
+
expecting: '',
|
|
350
|
+
textBeforeCursor,
|
|
351
|
+
nestingDepth: parser.nodesStack ? parser.nodesStack.length : 0,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Detect transformer context: key contains pipe character
|
|
355
|
+
const keyStr = ctx.key
|
|
356
|
+
const pipeIndex = keyStr.indexOf('|')
|
|
357
|
+
if (pipeIndex >= 0) {
|
|
358
|
+
const lastPipeIndex = keyStr.lastIndexOf('|')
|
|
359
|
+
ctx.transformerBaseKey = keyStr.substring(0, pipeIndex)
|
|
360
|
+
let rawPrefix = keyStr.substring(lastPipeIndex + 1)
|
|
361
|
+
ctx.transformerChain = pipeIndex < lastPipeIndex ? keyStr.substring(pipeIndex + 1, lastPipeIndex) : ''
|
|
362
|
+
// Strip argument portion from prefix: "upper(1,2" → "upper"
|
|
363
|
+
const parenIdx = rawPrefix.indexOf('(')
|
|
364
|
+
if (parenIdx >= 0 && !rawPrefix.endsWith(')')) {
|
|
365
|
+
ctx.transformerPrefix = rawPrefix.substring(0, parenIdx)
|
|
366
|
+
ctx.transformerInArgs = true
|
|
367
|
+
} else if (parenIdx >= 0) {
|
|
368
|
+
ctx.transformerPrefix = rawPrefix.substring(0, parenIdx)
|
|
369
|
+
ctx.transformerInArgs = false
|
|
370
|
+
} else {
|
|
371
|
+
ctx.transformerPrefix = rawPrefix
|
|
372
|
+
ctx.transformerInArgs = false
|
|
373
|
+
}
|
|
374
|
+
// Normalize key to base column for all downstream lookups
|
|
375
|
+
ctx.key = ctx.transformerBaseKey
|
|
376
|
+
if (parser.state === State.KEY) {
|
|
377
|
+
ctx.expecting = 'transformer'
|
|
378
|
+
return ctx
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
parser.state === State.KEY ||
|
|
384
|
+
parser.state === State.INITIAL ||
|
|
385
|
+
parser.state === State.BOOL_OP_DELIMITER ||
|
|
386
|
+
parser.state === State.SINGLE_QUOTED_KEY ||
|
|
387
|
+
parser.state === State.DOUBLE_QUOTED_KEY
|
|
388
|
+
) {
|
|
389
|
+
ctx.expecting = 'column'
|
|
390
|
+
} else if (parser.state === State.KEY_OR_BOOL_OP) {
|
|
391
|
+
ctx.expecting = 'operatorOrBool'
|
|
392
|
+
} else if (parser.state === State.EXPECT_OPERATOR) {
|
|
393
|
+
ctx.expecting = 'operatorOrBool'
|
|
394
|
+
} else if (parser.state === State.EXPECT_LIST_START) {
|
|
395
|
+
ctx.expecting = 'list'
|
|
396
|
+
} else if (
|
|
397
|
+
parser.state === State.EXPECT_LIST_VALUE ||
|
|
398
|
+
parser.state === State.IN_LIST_VALUE ||
|
|
399
|
+
parser.state === State.IN_LIST_SINGLE_QUOTED_VALUE ||
|
|
400
|
+
parser.state === State.IN_LIST_DOUBLE_QUOTED_VALUE ||
|
|
401
|
+
parser.state === State.EXPECT_LIST_COMMA_OR_END
|
|
402
|
+
) {
|
|
403
|
+
ctx.expecting = 'none'
|
|
404
|
+
} else if (parser.state === State.KEY_VALUE_OPERATOR) {
|
|
405
|
+
const op = parser.keyValueOperator
|
|
406
|
+
const isValid = VALID_KEY_VALUE_OPERATORS.includes(op)
|
|
407
|
+
const hasLonger = VALID_KEY_VALUE_OPERATORS.some((o) => o.startsWith(op) && o !== op)
|
|
408
|
+
if (hasLonger) {
|
|
409
|
+
ctx.expecting = 'operatorPrefix'
|
|
410
|
+
} else if (isValid) {
|
|
411
|
+
ctx.expecting = 'value'
|
|
412
|
+
}
|
|
413
|
+
} else if (
|
|
414
|
+
parser.state === State.VALUE ||
|
|
415
|
+
parser.state === State.EXPECT_VALUE ||
|
|
416
|
+
parser.state === State.DOUBLE_QUOTED_VALUE ||
|
|
417
|
+
parser.state === State.SINGLE_QUOTED_VALUE
|
|
418
|
+
) {
|
|
419
|
+
ctx.expecting = 'value'
|
|
420
|
+
if (parser.state === State.DOUBLE_QUOTED_VALUE) ctx.quoteChar = '"'
|
|
421
|
+
else if (parser.state === State.SINGLE_QUOTED_VALUE) ctx.quoteChar = "'"
|
|
422
|
+
} else if (parser.state === State.EXPECT_BOOL_OP) {
|
|
423
|
+
ctx.expecting = 'boolOp'
|
|
424
|
+
} else if (parser.state === State.EXPECT_NOT_TARGET) {
|
|
425
|
+
ctx.expecting = 'column'
|
|
426
|
+
} else if (parser.state === State.EXPECT_IN_KEYWORD) {
|
|
427
|
+
ctx.expecting = 'none'
|
|
428
|
+
} else if (parser.state === State.EXPECT_HAS_KEYWORD) {
|
|
429
|
+
ctx.expecting = 'none'
|
|
430
|
+
} else if (parser.state === State.EXPECT_LIKE_KEYWORD) {
|
|
431
|
+
ctx.expecting = 'none'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return ctx
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Update suggestions based on current cursor position.
|
|
439
|
+
* Debounces async calls (value/key loading) to avoid excessive requests.
|
|
440
|
+
* Returns a promise (may be async for value loading).
|
|
441
|
+
*/
|
|
442
|
+
async updateSuggestions() {
|
|
443
|
+
const seq = ++this._suggestionSeq
|
|
444
|
+
if (this._debounceTimer) {
|
|
445
|
+
clearTimeout(this._debounceTimer)
|
|
446
|
+
this._debounceTimer = null
|
|
447
|
+
}
|
|
448
|
+
const textBeforeCursor = this.state.getTextBeforeCursor()
|
|
449
|
+
const ctx = this.buildContext(textBeforeCursor)
|
|
450
|
+
this.context = ctx
|
|
451
|
+
this.message = ''
|
|
452
|
+
this.suggestionType = ctx ? ctx.expecting || '' : 'column'
|
|
453
|
+
|
|
454
|
+
// Reset value state when leaving value context or changing key
|
|
455
|
+
if (!ctx || ctx.expecting !== 'value' || (this._valueState && this._valueState.key !== ctx.key)) {
|
|
456
|
+
this._valueState = null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Reset tab state when leaving value context or changing key
|
|
460
|
+
if (!ctx || ctx.expecting !== 'value') {
|
|
461
|
+
this._resetTabState()
|
|
462
|
+
} else if (this._lastValueKey && this._lastValueKey !== ctx.key) {
|
|
463
|
+
this._resetTabState()
|
|
464
|
+
}
|
|
465
|
+
if (ctx && ctx.expecting === 'value') {
|
|
466
|
+
this._lastValueKey = ctx.key
|
|
467
|
+
this.columnSuggestions = getColumnSuggestionsForValue(this.columns, ctx.value || '', ctx.key || '')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Parameter autocomplete short-circuit: when value starts with `$`, show
|
|
471
|
+
// parameter suggestions regardless of column async state.
|
|
472
|
+
if (ctx && ctx.expecting === 'value' && ctx.value && ctx.value.startsWith('$')) {
|
|
473
|
+
const prefix = ctx.value.slice(1).toLowerCase()
|
|
474
|
+
const paramSuggestions = (this.parameters || [])
|
|
475
|
+
.filter((n) => !prefix || n.toLowerCase().startsWith(prefix))
|
|
476
|
+
.map((n) => ({
|
|
477
|
+
label: '$' + n,
|
|
478
|
+
insertText: '$' + n,
|
|
479
|
+
type: 'value',
|
|
480
|
+
detail: 'parameter',
|
|
481
|
+
}))
|
|
482
|
+
this.valueSuggestions = paramSuggestions
|
|
483
|
+
this.valueMessage = paramSuggestions.length === 0 ? 'No matching parameters' : ''
|
|
484
|
+
this.suggestionType = 'value'
|
|
485
|
+
this.incomplete = false
|
|
486
|
+
this.isLoading = false
|
|
487
|
+
if (this.onLoadingChange) this.onLoadingChange(false)
|
|
488
|
+
this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
|
|
489
|
+
this.message = this.activeTab === 'values' ? this.valueMessage : ''
|
|
490
|
+
this.state.selectedIndex = 0
|
|
491
|
+
return ctx
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Only enter async _valueState flow when the column actually needs server fetch:
|
|
495
|
+
// - column has autocomplete enabled AND no static values, OR
|
|
496
|
+
// - column is unknown (unresolved dotted key — let onAutocomplete try)
|
|
497
|
+
const _needsAsyncValue =
|
|
498
|
+
ctx &&
|
|
499
|
+
ctx.expecting === 'value' &&
|
|
500
|
+
this.onAutocomplete &&
|
|
501
|
+
(() => {
|
|
502
|
+
const col = resolveColumnDef(this.columns, ctx.key)
|
|
503
|
+
if (!col) return true
|
|
504
|
+
if (!col.autocomplete) return false
|
|
505
|
+
return !col.values || col.values.length === 0
|
|
506
|
+
})()
|
|
507
|
+
|
|
508
|
+
// Complete list: client-side filter, no server call
|
|
509
|
+
if (_needsAsyncValue && this._valueState && !this._valueState.incomplete) {
|
|
510
|
+
this.valueSuggestions = prepareSuggestionValues(this._valueState.items, ctx.quoteChar, ctx.value)
|
|
511
|
+
this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
|
|
512
|
+
this.incomplete = false
|
|
513
|
+
this.isLoading = false
|
|
514
|
+
this.state.selectedIndex = 0
|
|
515
|
+
return ctx
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Incomplete list refinement: keep current suggestions, debounce re-fetch
|
|
519
|
+
if (_needsAsyncValue && this._valueState && this._valueState.incomplete) {
|
|
520
|
+
// Don't clear suggestions — keep showing current values
|
|
521
|
+
this.isLoading = true
|
|
522
|
+
if (this.onLoadingChange) this.onLoadingChange(true)
|
|
523
|
+
if (this.debounceMs > 0) {
|
|
524
|
+
await new Promise((resolve) => {
|
|
525
|
+
this._debounceTimer = setTimeout(resolve, this.debounceMs)
|
|
526
|
+
})
|
|
527
|
+
if (seq !== this._suggestionSeq) return ctx
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const result = await updateSuggestions(
|
|
531
|
+
ctx,
|
|
532
|
+
this.columns,
|
|
533
|
+
this.onAutocomplete,
|
|
534
|
+
this.onKeyDiscovery,
|
|
535
|
+
this.keyCache,
|
|
536
|
+
(loading) => {
|
|
537
|
+
if (seq !== this._suggestionSeq) return
|
|
538
|
+
this.isLoading = loading
|
|
539
|
+
if (this.onLoadingChange) this.onLoadingChange(loading)
|
|
540
|
+
},
|
|
541
|
+
this.registry,
|
|
542
|
+
this.parameters,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
if (seq !== this._suggestionSeq) return ctx
|
|
546
|
+
|
|
547
|
+
this._valueState = {
|
|
548
|
+
key: ctx.key,
|
|
549
|
+
value: ctx.value,
|
|
550
|
+
items: result.rawItems || this._valueState.items,
|
|
551
|
+
incomplete: result.incomplete,
|
|
552
|
+
}
|
|
553
|
+
this.valueSuggestions = result.suggestions
|
|
554
|
+
this.valueMessage = result.suggestions.length === 0 ? 'No matching values' : result.message
|
|
555
|
+
this.suggestionType = result.suggestionType
|
|
556
|
+
this.incomplete = result.incomplete || false
|
|
557
|
+
if (this.activeTab === 'values') {
|
|
558
|
+
this.suggestions = this.valueSuggestions
|
|
559
|
+
this.message = this.valueMessage
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.state.selectedIndex = 0
|
|
563
|
+
return ctx
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Initial value load: no debounce
|
|
567
|
+
if (_needsAsyncValue && !this._valueState) {
|
|
568
|
+
// Mark pending so subsequent keystrokes hit the debounced refinement branch
|
|
569
|
+
this._valueState = { key: ctx.key, value: ctx.value, items: [], incomplete: true }
|
|
570
|
+
this.suggestions = []
|
|
571
|
+
this.incomplete = false
|
|
572
|
+
this.isLoading = true
|
|
573
|
+
this.state.selectedIndex = 0
|
|
574
|
+
|
|
575
|
+
const result = await updateSuggestions(
|
|
576
|
+
ctx,
|
|
577
|
+
this.columns,
|
|
578
|
+
this.onAutocomplete,
|
|
579
|
+
this.onKeyDiscovery,
|
|
580
|
+
this.keyCache,
|
|
581
|
+
(loading) => {
|
|
582
|
+
if (seq !== this._suggestionSeq) return
|
|
583
|
+
this.isLoading = loading
|
|
584
|
+
if (this.onLoadingChange) this.onLoadingChange(loading)
|
|
585
|
+
},
|
|
586
|
+
this.registry,
|
|
587
|
+
this.parameters,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if (seq !== this._suggestionSeq) return ctx
|
|
591
|
+
|
|
592
|
+
this._valueState = {
|
|
593
|
+
key: ctx.key,
|
|
594
|
+
value: ctx.value,
|
|
595
|
+
items: result.rawItems || [],
|
|
596
|
+
incomplete: result.incomplete,
|
|
597
|
+
}
|
|
598
|
+
this.valueSuggestions = result.suggestions
|
|
599
|
+
this.valueMessage = result.suggestions.length === 0 ? 'No matching values' : result.message
|
|
600
|
+
this.suggestionType = result.suggestionType
|
|
601
|
+
this.incomplete = result.incomplete || false
|
|
602
|
+
if (this.activeTab === 'values') {
|
|
603
|
+
this.suggestions = this.valueSuggestions
|
|
604
|
+
this.message = this.valueMessage
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.state.selectedIndex = 0
|
|
608
|
+
return ctx
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Non-value suggestions (columns, operators, boolOps) or value with static values
|
|
612
|
+
this.suggestions = []
|
|
613
|
+
this.incomplete = false
|
|
614
|
+
this.isLoading = false
|
|
615
|
+
this.state.selectedIndex = 0
|
|
616
|
+
|
|
617
|
+
const result = await updateSuggestions(
|
|
618
|
+
ctx,
|
|
619
|
+
this.columns,
|
|
620
|
+
this.onAutocomplete,
|
|
621
|
+
this.onKeyDiscovery,
|
|
622
|
+
this.keyCache,
|
|
623
|
+
(loading) => {
|
|
624
|
+
if (seq !== this._suggestionSeq) return
|
|
625
|
+
this.isLoading = loading
|
|
626
|
+
if (this.onLoadingChange) this.onLoadingChange(loading)
|
|
627
|
+
},
|
|
628
|
+
this.registry,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if (seq !== this._suggestionSeq) return ctx
|
|
632
|
+
|
|
633
|
+
if (ctx && ctx.expecting === 'value') {
|
|
634
|
+
this.valueSuggestions = result.suggestions
|
|
635
|
+
this.valueMessage = result.suggestions.length === 0 && !result.message ? 'No suggestions' : result.message
|
|
636
|
+
this.suggestions = this.activeTab === 'values' ? this.valueSuggestions : this.columnSuggestions
|
|
637
|
+
this.message = this.activeTab === 'values' ? this.valueMessage : ''
|
|
638
|
+
} else {
|
|
639
|
+
this.suggestions = result.suggestions
|
|
640
|
+
this.message = result.message
|
|
641
|
+
}
|
|
642
|
+
this.suggestionType = result.suggestionType
|
|
643
|
+
this.incomplete = result.incomplete || false
|
|
644
|
+
this.messageIsError = result.messageIsError || false
|
|
645
|
+
this.state.selectedIndex = 0
|
|
646
|
+
|
|
647
|
+
return ctx
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Generate highlight tokens as HTML string.
|
|
652
|
+
* Accepts an optional query parameter to avoid mutating engine state.
|
|
653
|
+
* When diagnostics are provided, wraps affected characters with diagnostic spans.
|
|
654
|
+
*/
|
|
655
|
+
getHighlightTokens(query, diagnostics = null, highlightDiagIndex = -1) {
|
|
656
|
+
const value = query !== undefined ? query : this.state.query
|
|
657
|
+
if (!value) return ''
|
|
658
|
+
|
|
659
|
+
const normalized = normalizeForParser(value)
|
|
660
|
+
const parser = new Parser()
|
|
661
|
+
try {
|
|
662
|
+
parser.parse(normalized, false, true)
|
|
663
|
+
} catch {
|
|
664
|
+
return escapeHtml(value)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const typedChars = parser.typedChars
|
|
668
|
+
if (!typedChars || typedChars.length === 0) {
|
|
669
|
+
return escapeHtml(value)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Build per-character diagnostic map
|
|
673
|
+
let diagMap = null
|
|
674
|
+
let highlightSet = null
|
|
675
|
+
if (diagnostics && diagnostics.length > 0) {
|
|
676
|
+
diagMap = new Array(value.length).fill(null)
|
|
677
|
+
for (let di = 0; di < diagnostics.length; di++) {
|
|
678
|
+
const d = diagnostics[di]
|
|
679
|
+
for (let j = d.range.start; j < d.range.end && j < value.length; j++) {
|
|
680
|
+
if (!diagMap[j]) {
|
|
681
|
+
diagMap[j] = { diag: d, index: di }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Build separate highlight set for hovered diagnostic
|
|
686
|
+
if (highlightDiagIndex >= 0 && highlightDiagIndex < diagnostics.length) {
|
|
687
|
+
highlightSet = new Set()
|
|
688
|
+
const hd = diagnostics[highlightDiagIndex]
|
|
689
|
+
for (let j = hd.range.start; j < hd.range.end && j < value.length; j++) {
|
|
690
|
+
highlightSet.add(j)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let html = ''
|
|
696
|
+
let currentType = null
|
|
697
|
+
let currentText = ''
|
|
698
|
+
let currentDiag = null
|
|
699
|
+
let currentHighlight = false
|
|
700
|
+
|
|
701
|
+
const flushSpan = () => {
|
|
702
|
+
if (!currentText) return
|
|
703
|
+
let spanType = currentType
|
|
704
|
+
if (spanType === CharType.VALUE) {
|
|
705
|
+
if (currentText === 'true' || currentText === 'false') {
|
|
706
|
+
spanType = CharType.BOOLEAN
|
|
707
|
+
} else if (currentText === 'null') {
|
|
708
|
+
spanType = CharType.NULL
|
|
709
|
+
} else if (isNumeric(currentText)) {
|
|
710
|
+
spanType = CharType.NUMBER
|
|
711
|
+
} else if (currentText.length > 0 && currentText[0] !== "'" && currentText[0] !== '"') {
|
|
712
|
+
spanType = CharType.COLUMN
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const inner = wrapSpan(spanType, currentText)
|
|
716
|
+
if (currentDiag || currentHighlight) {
|
|
717
|
+
const classes = ['flyql-diagnostic']
|
|
718
|
+
if (currentDiag) {
|
|
719
|
+
classes.push('flyql-diagnostic--' + (currentDiag.diag.severity === 'warning' ? 'warning' : 'error'))
|
|
720
|
+
}
|
|
721
|
+
if (currentHighlight) {
|
|
722
|
+
classes.push('flyql-diagnostic--highlight')
|
|
723
|
+
}
|
|
724
|
+
const title = currentDiag ? ` title="${escapeHtml(currentDiag.diag.message)}"` : ''
|
|
725
|
+
html += `<span class="${classes.join(' ')}"${title}>${inner}</span>`
|
|
726
|
+
} else {
|
|
727
|
+
html += inner
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
for (let i = 0; i < typedChars.length; i++) {
|
|
732
|
+
const charType = typedChars[i][1]
|
|
733
|
+
const ch = value[i] !== undefined ? value[i] : typedChars[i][0].value
|
|
734
|
+
const newDiag = diagMap ? diagMap[i] : null
|
|
735
|
+
const newHighlight = highlightSet ? highlightSet.has(i) : false
|
|
736
|
+
if (
|
|
737
|
+
charType === currentType &&
|
|
738
|
+
newDiag === currentDiag &&
|
|
739
|
+
newHighlight === currentHighlight &&
|
|
740
|
+
ch !== '\n'
|
|
741
|
+
) {
|
|
742
|
+
currentText += ch
|
|
743
|
+
} else {
|
|
744
|
+
flushSpan()
|
|
745
|
+
currentType = charType
|
|
746
|
+
currentDiag = newDiag
|
|
747
|
+
currentHighlight = newHighlight
|
|
748
|
+
currentText = ch
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
flushSpan()
|
|
752
|
+
|
|
753
|
+
if (parser.state === State.ERROR && typedChars.length < value.length) {
|
|
754
|
+
const remaining = value.substring(typedChars.length)
|
|
755
|
+
html += `<span class="flyql-error">${escapeHtml(remaining)}</span>`
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return html
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Get current suggestions list.
|
|
763
|
+
*/
|
|
764
|
+
getSuggestions() {
|
|
765
|
+
return this.suggestions
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Get the current editor state snapshot.
|
|
770
|
+
*/
|
|
771
|
+
getState() {
|
|
772
|
+
return {
|
|
773
|
+
query: this.state.query,
|
|
774
|
+
cursorPosition: this.state.cursorPosition,
|
|
775
|
+
focused: this.state.focused,
|
|
776
|
+
activated: this.state.activated,
|
|
777
|
+
composing: this.state.composing,
|
|
778
|
+
selectedIndex: this.state.selectedIndex,
|
|
779
|
+
context: this.context,
|
|
780
|
+
suggestions: this.suggestions,
|
|
781
|
+
suggestionType: this.suggestionType,
|
|
782
|
+
message: this.message,
|
|
783
|
+
incomplete: this.incomplete,
|
|
784
|
+
isLoading: this.isLoading,
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Get current parse error, if any.
|
|
790
|
+
*/
|
|
791
|
+
getParseError() {
|
|
792
|
+
if (this.context && this.context.state === 'ERROR') {
|
|
793
|
+
return this.context.error
|
|
794
|
+
}
|
|
795
|
+
return null
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Validate the full query and return status.
|
|
800
|
+
*/
|
|
801
|
+
getQueryStatus() {
|
|
802
|
+
const value = this.state.query
|
|
803
|
+
if (!value) return { valid: true, message: 'Empty query' }
|
|
804
|
+
const normalized = normalizeForParser(value)
|
|
805
|
+
const parser = new Parser()
|
|
806
|
+
try {
|
|
807
|
+
parser.parse(normalized, false, false)
|
|
808
|
+
} catch (e) {
|
|
809
|
+
return { valid: false, message: e.message || 'Parse error' }
|
|
810
|
+
}
|
|
811
|
+
if (parser.state === State.ERROR) {
|
|
812
|
+
return { valid: false, message: parser.errorText || 'Parse error' }
|
|
813
|
+
}
|
|
814
|
+
if (
|
|
815
|
+
parser.state === State.EXPECT_BOOL_OP ||
|
|
816
|
+
parser.state === State.VALUE ||
|
|
817
|
+
parser.state === State.KEY ||
|
|
818
|
+
parser.state === State.KEY_OR_BOOL_OP
|
|
819
|
+
) {
|
|
820
|
+
return { valid: true, message: 'Valid query' }
|
|
821
|
+
}
|
|
822
|
+
return { valid: false, message: 'Incomplete query' }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Get the text range to replace when accepting a suggestion.
|
|
827
|
+
*/
|
|
828
|
+
getInsertRange(ctx, fullText) {
|
|
829
|
+
return getInsertRange(ctx || this.context, fullText || this.state.query, this.suggestionType)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Navigate suggestion selection up.
|
|
834
|
+
*/
|
|
835
|
+
navigateUp() {
|
|
836
|
+
if (this.suggestions.length === 0) return
|
|
837
|
+
this.state.selectedIndex =
|
|
838
|
+
this.state.selectedIndex <= 0 ? this.suggestions.length - 1 : this.state.selectedIndex - 1
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Navigate suggestion selection down.
|
|
843
|
+
*/
|
|
844
|
+
navigateDown() {
|
|
845
|
+
if (this.suggestions.length === 0) return
|
|
846
|
+
this.state.selectedIndex =
|
|
847
|
+
this.state.selectedIndex >= this.suggestions.length - 1 ? 0 : this.state.selectedIndex + 1
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Get the selected suggestion item.
|
|
852
|
+
*/
|
|
853
|
+
selectSuggestion(index) {
|
|
854
|
+
const suggestion = this.suggestions[index]
|
|
855
|
+
if (!suggestion) return null
|
|
856
|
+
return suggestion
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Get the state label for the current suggestion type.
|
|
861
|
+
*/
|
|
862
|
+
getStateLabel() {
|
|
863
|
+
return STATE_LABELS[this.suggestionType] || ''
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Get filter prefix for highlighting matched text in suggestions.
|
|
868
|
+
*/
|
|
869
|
+
getFilterPrefix() {
|
|
870
|
+
return this.state.getFilterPrefix(this.context)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Clear the key discovery cache (e.g., when deactivating).
|
|
875
|
+
*/
|
|
876
|
+
clearKeyCache() {
|
|
877
|
+
this.keyCache = {}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Highlight the matching portion of a suggestion label.
|
|
882
|
+
*/
|
|
883
|
+
highlightMatch(label) {
|
|
884
|
+
const prefix = this.getFilterPrefix()
|
|
885
|
+
if (!prefix) return escapeHtml(label)
|
|
886
|
+
if (!label.toLowerCase().startsWith(prefix.toLowerCase())) return escapeHtml(label)
|
|
887
|
+
const matched = escapeHtml(label.substring(0, prefix.length))
|
|
888
|
+
const rest = escapeHtml(label.substring(prefix.length))
|
|
889
|
+
return `<span class="flyql-panel__match">${matched}</span>${rest}`
|
|
890
|
+
}
|
|
891
|
+
}
|