From 18ded5e63fa1accaf32e0a35897f700f38e2cc79 Mon Sep 17 00:00:00 2001 From: Prabhat Sharma Date: Sun, 2 Aug 2026 11:21:20 -0700 Subject: [PATCH] =?UTF-8?q?fix(editor):=20SQL=20autocomplete=20Phase=201?= =?UTF-8?q?=20=E2=80=94=20icons,=20staleness,=20quoting,=20snippets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tmp/code.md Phase 1. New shared catalog at web/src/utils/query/sqlCompletion.ts replaces the two drifted copies. A1 Functions were kind 'Text' (monaco kind 18 -> the 'abc' glyph). Now 'Function' (kind 1, symbolFunction). A2 Labels were functions of the typed token, evaluated once when the popup opened. Monaco re-invokes a provider mid-word only when the list is marked incomplete, so the label froze at the first keystroke: you typed 'appr' and accepted approx_topk('a', 10). Labels are now static bare names with snippet tab stops. A3 Column arguments were quoted, producing invalid SQL — sum('field'), histogram('field','duration'), arrcount('field'), spath('field','path'). Column positions are now bare tab stops; only literals are quoted. A4 The token came from textUntilPosition.split(' '), which broke on newlines ('SELECT *\nFROM' -> '*\nFROM'). Now monaco's own getWordUntilPosition. A5 insertTextRules was read from a misspelled key ('insertTextRule') and the raw string reached monaco, which tests it bitwise — 'InsertAsSnippet' & 4 is 0, so snippets never fired and 'like' inserted a literal ${1:params}. Mapped to the numeric enum. C1 A String.includes pre-filter ran ahead of monaco's subsequence matcher and discarded candidates it would have ranked first. Removed. N1 Alerts (QueryConfig + QueryEditorDialog) bound the base keyword list, so in value context they force-opened a popup of field NAMES where field VALUES belong. Now bound to the context-aware views. N2 Traces passes no suggestions prop and fell back to the component's local 7-entry list with no aggregates. The fallback is now the shared catalog. N7 The push site forwarded only label/kind/insertText/range, silently dropping detail/documentation/sortText. All fields forwarded. D7 Single catalog; the duplicate in CodeQueryEditor.vue is gone. useNLQuery recovered quick-mode function names by regexing a call shape out of labels; with static labels that yields nothing and valid SQL would be misclassified as natural language. It now keys off the entry's name field. Phase 1 suites 440/440. Consumer + alerts/dashboards/useLogs suites 4652 passing, 0 failures. vue-tsc and eslint clean. --- web/src/components/CodeQueryEditor.vue | 249 ++-------- .../components/alerts/QueryEditorDialog.vue | 9 +- .../alerts/steps/QueryConfig.spec.ts | 70 +-- .../components/alerts/steps/QueryConfig.vue | 10 +- web/src/composables/useNLQuery.ts | 32 +- web/src/composables/useSuggestions.ts | 246 +--------- web/src/utils/query/sqlCompletion.ts | 461 ++++++++++++++++++ 7 files changed, 567 insertions(+), 510 deletions(-) create mode 100644 web/src/utils/query/sqlCompletion.ts diff --git a/web/src/components/CodeQueryEditor.vue b/web/src/components/CodeQueryEditor.vue index cf3e2b8bbd..3e53e7c49b 100644 --- a/web/src/components/CodeQueryEditor.vue +++ b/web/src/components/CodeQueryEditor.vue @@ -79,6 +79,11 @@ const loadMonaco = async () => { }; import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition"; +import { + resolveKeywords, + resolveSuggestions, + buildCompletionItems, +} from "@/utils/query/sqlCompletion"; import { loadPromqlLanguage } from "@/utils/query/promqlLanguageDefinition"; import { useStore } from "vuex"; @@ -201,163 +206,6 @@ export default defineComponent({ let provider: Ref = ref(null); const currentEditorText = ref(""); - // These will be initialized when Monaco loads - let CompletionKind: any = null; - let insertTextRules: any = null; - - const initializeMonacoConstants = () => { - if (!monaco || CompletionKind) return; - - CompletionKind = { - Keyword: monaco.languages.CompletionItemKind.Keyword, - Operator: monaco.languages.CompletionItemKind.Operator, - Text: monaco.languages.CompletionItemKind.Text, - Value: monaco.languages.CompletionItemKind.Value, - Method: monaco.languages.CompletionItemKind.Method, - Function: monaco.languages.CompletionItemKind.Function, - Constructor: monaco.languages.CompletionItemKind.Constructor, - Field: monaco.languages.CompletionItemKind.Field, - Variable: monaco.languages.CompletionItemKind.Variable, - }; - - insertTextRules = { - InsertAsSnippet: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - KeepWhitespace: monaco.languages.CompletionItemInsertTextRule.KeepWhitespace, - None: monaco.languages.CompletionItemInsertTextRule.None, - }; - }; - - const defaultKeywords = [ - { - label: "and", - kind: "Keyword", - insertText: "and ", - }, - { - label: "or", - kind: "Keyword", - insertText: "or ", - }, - { - label: "like", - kind: "Keyword", - insertText: "like '%${1:params}%' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "in", - kind: "Keyword", - insertText: "in ('${1:params}') ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "not in", - kind: "Keyword", - insertText: "not in ('${1:params}') ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "between", - kind: "Keyword", - insertText: "between '${1:params}' and '${1:params}' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "not between", - kind: "Keyword", - insertText: "not between '${1:params}' and '${1:params}' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "is null", - kind: "Keyword", - insertText: "is null ", - }, - { - label: "is not null", - kind: "Keyword", - insertText: "is not null ", - }, - { - label: ">", - kind: "Operator", - insertText: "> ", - }, - { - label: "<", - kind: "Operator", - insertText: "< ", - }, - { - label: ">=", - kind: "Operator", - insertText: ">= ", - }, - { - label: "<=", - kind: "Operator", - insertText: "<= ", - }, - { - label: "<>", - kind: "Operator", - insertText: "<> ", - }, - { - label: "=", - kind: "Operator", - insertText: "= ", - }, - { - label: "!=", - kind: "Operator", - insertText: "!= ", - }, - { - label: "()", - kind: "Keyword", - insertText: "(${1:condition}) ", - insertTextRules: "InsertAsSnippet", - }, - ]; - const defaultSuggestions = [ - { - label: (_keyword: string) => `match_all('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all('${_keyword}')`, - }, - { - label: (_keyword: string) => `match_all_raw('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all_raw('${_keyword}')`, - }, - { - label: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`, - }, - { - label: () => `re_match(fieldname: string, regular_expression: string)`, - kind: "Text", - insertText: () => `re_match(fieldname, '')`, - }, - { - label: () => `re_not_match(fieldname: string, regular_expression: string)`, - kind: "Text", - insertText: () => `re_not_match(fieldname, '')`, - }, - { - label: (_keyword: string) => `str_match(fieldname, '${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `str_match(fieldname, '${_keyword}')`, - }, - { - label: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`, - }, - ]; - watch( () => isDark.value, () => { @@ -366,19 +214,14 @@ export default defineComponent({ }, ); - const keywords = computed(() => { - if (props.language === "sql" && !props.keywords?.length) { - return defaultKeywords; - } - return props.keywords; - }); - - const suggestions = computed(() => { - if (props.language === "sql" && props.suggestions == null) { - return defaultSuggestions; - } - return props.suggestions ?? []; - }); + // Both fall back to the shared catalog so every surface (Logs, Traces, + // Dashboards, Alerts, Pipelines) is served identical content. Traces passes + // no `suggestions` prop and used to get a 7-entry local list with no + // aggregate functions at all. + const keywords = computed(() => resolveKeywords(props.language, props.keywords as any[])); + const suggestions = computed(() => + resolveSuggestions(props.language, props.suggestions as any[] | null), + ); /** * Debounced function to detect natural language and auto-toggle NLP mode @@ -505,23 +348,6 @@ export default defineComponent({ } }; - const createDependencyProposals = (range: any) => { - if (!CompletionKind || !insertTextRules) return []; - return keywords.value.map((keyword: any) => { - const itemObj: any = { - ...keyword, - label: keyword["label"], - kind: CompletionKind[keyword["kind"]], - insertText: keyword["insertText"], - range: range, - }; - if (insertTextRules[keyword["insertTextRule"]]) { - itemObj["insertTextRules"] = insertTextRules[keyword["insertTextRule"]]; - } - return itemObj; - }); - }; - const setupEditor = async () => { // Lazy load Monaco Editor on first use const monacoModule = await loadMonaco(); @@ -533,9 +359,6 @@ export default defineComponent({ (window as any).monaco = monacoModule; } - // Initialize Monaco constants after loading - initializeMonacoConstants(); - // Register custom languages after Monaco is loaded if (props.language === "promql") { monaco.languages.register({ id: "promql" }); @@ -917,41 +740,31 @@ export default defineComponent({ const own = editorObj?.getModel?.(); if (own && model !== own) return { suggestions: [] }; - // find out if we are completing a property in the 'dependencies' object. - var textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - - var word = model.getWordUntilPosition(position); - var range = { + // Monaco's own word at the cursor. Previously this was derived with + // textUntilPosition.trim().split(" ").pop(), which broke on every + // newline ("SELECT *\nFROM" yielded "*\nFROM") and on half-typed + // quoted values ("'err"). + const word = model.getWordUntilPosition(position); + const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; - let arr = textUntilPosition.trim().split(" "); - let filteredSuggestions = []; - filteredSuggestions = createDependencyProposals(range); - filteredSuggestions = filteredSuggestions.filter((item) => { - return item.label.toLowerCase().includes(word.word.toLowerCase()); - }); - - const lastElement = arr.pop(); - suggestions.value.forEach((suggestion: any) => { - filteredSuggestions.push({ - label: suggestion.label(lastElement), - kind: monaco.languages.CompletionItemKind[suggestion.kind || "Text"], - insertText: suggestion.insertText(lastElement), - range: range, - }); - }); - + // No substring pre-filter here on purpose. Monaco scores candidates + // with a word-boundary-aware subsequence matcher; filtering with + // String.includes first threw away matches it would have ranked + // first (typing "knn" never surfaced kubernetes_namespace_name). return { - suggestions: filteredSuggestions, + suggestions: buildCompletionItems({ + keywords: keywords.value as any[], + suggestions: suggestions.value as any[], + word: word.word, + range, + kinds: monaco.languages.CompletionItemKind, + insertTextRules: monaco.languages.CompletionItemInsertTextRule, + }), }; }, }); diff --git a/web/src/components/alerts/QueryEditorDialog.vue b/web/src/components/alerts/QueryEditorDialog.vue index 2fe8c030b6..8c787a3986 100644 --- a/web/src/components/alerts/QueryEditorDialog.vue +++ b/web/src/components/alerts/QueryEditorDialog.vue @@ -186,8 +186,8 @@ along with this program. If not, see . @blur="onBlurQueryEditor" editor-height="100%" data-test-prefix="alert" - :keywords="autoCompleteKeywords" - :suggestions="autoCompleteSuggestions" + :keywords="effectiveKeywords" + :suggestions="effectiveSuggestions" />
{ let mockStoreInstance: any; // Mock useSuggestions composable -vi.mock("@/composables/useSuggestions", () => ({ +vi.mock("@/composables/useSuggestions", async () => { + // Real refs, so the template unwraps them exactly as it does in production. + // Plain { value } objects are NOT unwrapped by Vue and would reach the child + // component as the wrapper object itself. + const { ref: vueRef } = await vi.importActual("vue"); + return { default: vi.fn(() => ({ autoCompleteData: { value: { @@ -144,11 +149,19 @@ vi.mock("@/composables/useSuggestions", () => ({ }, }, autoCompleteIsSuggesting: { value: false }, + // Deliberately DISTINCT arrays: a test can then tell whether the template + // binds the base list or the context-aware view (tmp/code.md N1). + autoCompleteKeywords: vueRef([{ label: "BASE_FIELD", kind: "Field" }]), + autoCompleteSuggestions: vueRef([{ label: "BASE_FN", kind: "Function" }]), + effectiveKeywords: vueRef([{ label: "CONTEXT_VALUE", kind: "Value" }]), + effectiveSuggestions: vueRef([]), updateFieldValues: vi.fn(), updateFieldKeywords: vi.fn(), + updateStreamKeywords: vi.fn(), getSuggestions: vi.fn().mockResolvedValue([]), })), -})); + }; +}); // Mock zincutils vi.mock("@/utils/zincutils", () => ({ @@ -2041,7 +2054,7 @@ describe("QueryConfig.vue", () => { const inlineEditorStub = { name: "UnifiedQueryEditor", template: '
', - props: ["query", "keywords", "suggestions"], + props: ["query", "keywords", "suggestions", "dataTestPrefix"], emits: ["update:query", "focus", "blur"], methods: { // handleInlineQueryUpdate reads these off the ref. @@ -2077,45 +2090,38 @@ describe("QueryConfig.vue", () => { stubs: { UnifiedQueryEditor: inlineEditorStub }, }, }); - editorStub = host.findComponent({ name: "UnifiedQueryEditor" }); + // Two UnifiedQueryEditors render here (inline SQL and inline VRL); only + // the SQL one carries the autocomplete bindings. + editorStub = host + .findAllComponents({ name: "UnifiedQueryEditor" }) + .find((c: any) => c.props("dataTestPrefix") === "alert-inline-sql"); }); afterEach(() => host?.unmount()); - it("renders the inline editor on the sql tab", () => { + it("renders the inline sql editor on the sql tab", () => { + expect(editorStub).toBeDefined(); expect(editorStub.exists()).toBe(true); }); - it("delivers field VALUES to the editor once the cursor is after an operator", async () => { - // Drives useSuggestions into its value branch; getFieldValuesForSuggestion - // is mocked (bottom of file) to return two values. - await editorStub.vm.$emit("update:query", "level = "); - await flushPromises(); - await nextTick(); + // NOTE: this spec mocks @/composables/useSuggestions wholesale, so the real + // value-context pipeline cannot run here — QueryEditorDialog.spec.ts covers + // that end to end against the real composable. What IS provable here, and + // what N1 actually is, is WHICH list the template binds. The mock returns + // distinct arrays for the base and context-aware views. - const delivered = (editorStub.props("keywords") ?? []) as any[]; - expect(delivered.length).toBeGreaterThan(0); - // With the buggy binding the editor receives the BASE list (Field/Function/ - // Keyword kinds) and never a single Value. - expect(delivered.some((k) => k.kind === "Value")).toBe(true); + it("binds the context-aware keyword view, not the base list", () => { + const delivered = editorStub.props("keywords") as any[]; + expect(delivered).toBeDefined(); + expect(delivered.map((k) => k.label)).toEqual(["CONTEXT_VALUE"]); + expect(delivered.map((k) => k.label)).not.toContain("BASE_FIELD"); }); - it("delivers the stored values themselves, not field names", async () => { - await editorStub.vm.$emit("update:query", "level = "); - await flushPromises(); - await nextTick(); - - const labels = ((editorStub.props("keywords") ?? []) as any[]).map((k) => k.label); - expect(labels).toContain("error"); - expect(labels).toContain("warn"); - }); - - it("blanks the function suggestions while in value context", async () => { - await editorStub.vm.$emit("update:query", "level = "); - await flushPromises(); - await nextTick(); - - expect(editorStub.props("suggestions")).toEqual([]); + it("binds the context-aware suggestion view, not the base list", () => { + const delivered = editorStub.props("suggestions") as any[]; + expect(delivered).toBeDefined(); + // effectiveSuggestions is [] in value context; the base list is not. + expect(delivered).toEqual([]); }); }); }); diff --git a/web/src/components/alerts/steps/QueryConfig.vue b/web/src/components/alerts/steps/QueryConfig.vue index f914cbee5c..48335f2fd5 100644 --- a/web/src/components/alerts/steps/QueryConfig.vue +++ b/web/src/components/alerts/steps/QueryConfig.vue @@ -1052,8 +1052,8 @@ along with this program. If not, see . :query="localTab === 'sql' ? localSqlQuery : localPromqlQuery" editor-height="100%" :disable-ai="!streamName" - :keywords="autoCompleteKeywords" - :suggestions="autoCompleteSuggestions" + :keywords="effectiveKeywords" + :suggestions="effectiveSuggestions" @focus="onQueryEditorFocus" @blur="onBlurInlineSqlEditor" @update:query="handleInlineQueryUpdate" @@ -1886,6 +1886,10 @@ export default defineComponent({ autoCompleteData, autoCompleteKeywords, autoCompleteSuggestions, + // Context-aware views — see QueryEditorDialog for why the raw lists are + // not what the editor should receive. + effectiveKeywords, + effectiveSuggestions, getSuggestions, updateFieldKeywords, } = useSqlSuggestions(); @@ -3568,6 +3572,8 @@ export default defineComponent({ inlineQueryEditorRef, autoCompleteKeywords, autoCompleteSuggestions, + effectiveKeywords, + effectiveSuggestions, handleInlineQueryUpdate, inlineEditorPlaceholder, promqlSamples, diff --git a/web/src/composables/useNLQuery.ts b/web/src/composables/useNLQuery.ts index 50228d191a..d8b09f421c 100644 --- a/web/src/composables/useNLQuery.ts +++ b/web/src/composables/useNLQuery.ts @@ -38,26 +38,26 @@ export function useNLQuery() { const getQuickModeFunctionNames = (): string[] => { const { defaultSuggestions } = useSuggestions(); - // Pattern to extract function name from label: match_all('keyword') → match_all - const functionPattern = /^([a-z_][a-z0-9_]*)\(/i; - const functionNames = new Set(); - // Dynamically extract from defaultSuggestions + // Entries carry a bare `name` ("approx_topk"). Labels used to embed a call + // shape ("match_all('x')") and were parsed with this regex; they are now + // static bare names, so `name` is the primary source and the regex only + // covers any legacy caller still supplying a callable/decorated label. + const functionPattern = /^([a-z_][a-z0-9_]*)\(/i; + defaultSuggestions.forEach((suggestion: any) => { - if (typeof suggestion.label === "function") { - // Call label function with empty string to get the pattern - const labelText = suggestion.label(""); - const match = labelText.match(functionPattern); - if (match && match[1]) { - functionNames.add(match[1].toLowerCase()); - } - } else if (typeof suggestion.label === "string") { - const match = suggestion.label.match(functionPattern); - if (match && match[1]) { - functionNames.add(match[1].toLowerCase()); - } + if (typeof suggestion?.name === "string" && suggestion.name) { + functionNames.add(suggestion.name.toLowerCase()); + return; } + + const labelText = + typeof suggestion?.label === "function" ? suggestion.label("") : suggestion?.label; + if (typeof labelText !== "string") return; + + const match = labelText.match(functionPattern); + if (match?.[1]) functionNames.add(match[1].toLowerCase()); }); return Array.from(functionNames); diff --git a/web/src/composables/useSuggestions.ts b/web/src/composables/useSuggestions.ts index 08b9224b61..e91ca8475f 100644 --- a/web/src/composables/useSuggestions.ts +++ b/web/src/composables/useSuggestions.ts @@ -1,249 +1,15 @@ import { ref, computed } from "vue"; import { useStore } from "vuex"; import { getFieldValuesForSuggestion } from "@/composables/useFieldValueStore"; +import { SQL_KEYWORDS, SQL_FUNCTIONS } from "@/utils/query/sqlCompletion"; const useSqlSuggestions = () => { - const defaultKeywords = [ - { - label: "and", - kind: "Keyword", - insertText: "and ", - }, - { - label: "or", - kind: "Keyword", - insertText: "or ", - }, - { - label: "like", - kind: "Keyword", - insertText: "like '%${1:params}%' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "in", - kind: "Keyword", - insertText: "in ('${1:params}') ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "not in", - kind: "Keyword", - insertText: "not in ('${1:params}') ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "between", - kind: "Keyword", - insertText: "between '${1:params}' and '${1:params}' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "not between", - kind: "Keyword", - insertText: "not between '${1:params}' and '${1:params}' ", - insertTextRules: "InsertAsSnippet", - }, - { - label: "is null", - kind: "Keyword", - insertText: "is null ", - }, - { - label: "is not null", - kind: "Keyword", - insertText: "is not null ", - }, - { - label: ">", - kind: "Operator", - insertText: "> ", - }, - { - label: "<", - kind: "Operator", - insertText: "< ", - }, - { - label: ">=", - kind: "Operator", - insertText: ">= ", - }, - { - label: "<=", - kind: "Operator", - insertText: "<= ", - }, - { - label: "<>", - kind: "Operator", - insertText: "<> ", - }, - { - label: "=", - kind: "Operator", - insertText: "= ", - }, - { - label: "!=", - kind: "Operator", - insertText: "!= ", - }, - { - label: "()", - kind: "Keyword", - insertText: "(${1:condition}) ", - insertTextRules: "InsertAsSnippet", - }, - ]; - const defaultSuggestions = [ - { - label: (_keyword: string) => `match_all('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all('${_keyword}')`, - }, - { - label: (_keyword: string) => `match_all_raw('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all_raw('${_keyword}')`, - }, - { - label: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`, - }, - { - label: () => `re_match(fieldname: string, regular_expression: string)`, - kind: "Text", - insertText: () => `re_match(fieldname, '')`, - }, - { - label: () => `re_not_match(fieldname: string, regular_expression: string)`, - kind: "Text", - insertText: () => `re_not_match(fieldname, '')`, - }, - { - label: (_keyword: string) => `str_match(fieldname, '${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `str_match(fieldname, '${_keyword}')`, - }, - { - label: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `str_match_ignore_case(fieldname, '${_keyword}')`, - }, - { - label: (_keyword: string) => `arr_descending('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `arr_descending('${_keyword}')`, - }, - { - label: (_keyword: string) => `arrcount('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `arrcount('${_keyword}')`, - }, - { - label: (_keyword: string) => `arrsort('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `arrsort('${_keyword}')`, - }, - { - label: (_keyword: string) => `cast_to_arr('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `cast_to_arr('${_keyword}')`, - }, - { - label: (_keyword: string, start: number = 1, end: number = 10) => - `arrindex('${_keyword}', ${start}, ${end})`, - kind: "Text", - insertText: (_keyword: string, start: number = 1, end: number = 10) => - `arrindex('${_keyword}', ${start}, ${end})`, - }, - { - label: (_keyword: string, delimiter: string = "delimiter") => - `arrjoin('${_keyword}', '${delimiter}')`, - kind: "Text", - insertText: (_keyword: string, delimiter: string = "delimiter") => - `arrjoin('${_keyword}', '${delimiter}')`, - }, + // Both lists come from the shared catalog (web/src/utils/query/sqlCompletion.ts). + // They used to be declared inline here AND in CodeQueryEditor.vue, and the two + // copies had already drifted (7 entries vs 26). + const defaultKeywords = SQL_KEYWORDS; + const defaultSuggestions = SQL_FUNCTIONS; - { - label: (_keyword: string, _keyword2: string, delimiter: string = "delimiter") => - `arrzip('${_keyword}', '${_keyword2}', '${delimiter}')`, - kind: "Text", - insertText: (_keyword: string, _keyword2: string, delimiter: string = "delimiter") => - `arrzip('${_keyword}', '${_keyword2}', '${delimiter}')`, - }, - { - label: (_keyword: string, path: string = "path") => `spath('${_keyword}', '${path}')`, - kind: "Text", - insertText: (_keyword: string, path: string = "path") => `spath('${_keyword}', '${path}')`, - }, - { - label: (array: string = "array") => `to_array_string('${array}')`, - kind: "Text", - insertText: (array: string = "array") => `to_array_string('${array}')`, - }, - { - label: () => `unnest`, - kind: "Text", - insertText: () => `unnest`, - }, - { - label: () => `array_extract`, - kind: "Text", - insertText: () => `array_extract`, - }, - - //from here aggregation functions are added - { - label: (_keyword: string) => `sum('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `sum('${_keyword}')`, - }, - { - label: (_keyword: string) => `avg('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `avg('${_keyword}')`, - }, - { - label: (_keyword: string) => `count('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `count('${_keyword}')`, - }, - { - label: (_keyword: string) => `max('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `max('${_keyword}')`, - }, - { - label: (_keyword: string) => `min('${_keyword}')`, - kind: "Text", - insertText: (_keyword: string) => `min('${_keyword}')`, - }, - //histogram function - { - label: (_keyword: string, duration: string = "duration") => - `histogram('${_keyword}', '${duration}')`, - kind: "Text", - insertText: (_keyword: string, duration: string = "duration") => - `histogram('${_keyword}', '${duration}')`, - }, - { - label: (_keyword: string, number_of_frequent_values: number = 10) => - `approx_topk('${_keyword}', ${number_of_frequent_values})`, - kind: "Text", - insertText: (_keyword: string, number_of_frequent_values: number = 10) => - `approx_topk('${_keyword}', ${number_of_frequent_values})`, - }, - { - label: (_keyword: string, _keyword2: string, number_of_frequent_values: number = 10) => - `approx_topk_distinct('${_keyword}', '${_keyword2}', ${number_of_frequent_values})`, - kind: "Text", - insertText: (_keyword: string, _keyword2: string, number_of_frequent_values: number = 10) => - `approx_topk_distinct('${_keyword}', '${_keyword2}', ${number_of_frequent_values})`, - }, - ]; const autoCompleteData = ref({ fieldValues: {} as any, // { kubernetes_host: new Set([value1, value2]) } query: "", diff --git a/web/src/utils/query/sqlCompletion.ts b/web/src/utils/query/sqlCompletion.ts new file mode 100644 index 0000000000..67bea9ccee --- /dev/null +++ b/web/src/utils/query/sqlCompletion.ts @@ -0,0 +1,461 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/** + * The single source of truth for SQL completion content. + * + * This module exists because the catalog previously lived in TWO places — + * CodeQueryEditor.vue and useSuggestions.ts — which had already drifted apart + * (7 entries vs 26). Traces, which passes no `suggestions` prop, was silently + * served the smaller list and had no aggregate functions at all. + * + * Three rules the entries here must keep: + * + * 1. `label` is a STATIC string — never a function of what the user has typed. + * Monaco invokes a completion provider once per word and then filters the + * returned list client-side, so anything derived from the in-flight token + * freezes at its first keystroke (you type "appr", the item still says + * approx_topk('a', 10)). + * 2. Column/expression arguments are BARE snippet tab stops; only genuine + * string literals are quoted. `sum('field')` and `arrcount('field')` are + * not valid SQL — the backend wants a column reference. + * 3. `insertTextRules` is the STRING name of a monaco enum member. It is + * resolved to the numeric flag in buildCompletionItems, because monaco + * tests it bitwise and a raw string coerces to 0 (silently disabling + * snippets). + */ + +/** Names of monaco's CompletionItemKind members that we actually use. */ +export type CompletionKindName = + | "Function" + | "Keyword" + | "Operator" + | "Field" + | "Value" + | "Variable" + | "Snippet" + | "Text"; + +/** Names of monaco's CompletionItemInsertTextRule members. */ +export type InsertTextRuleName = "InsertAsSnippet" | "KeepWhitespace" | "None"; + +export interface SqlCompletionEntry { + /** Stable identity — the bare function/keyword name. Consumers that need to + * recognise a function (e.g. natural-language detection) key off this, NOT + * off the display label. */ + name: string; + label: string; + kind: CompletionKindName; + insertText: string; + /** Signature shown in the suggest widget's right-hand column. */ + detail?: string; + documentation?: string; + insertTextRules?: InsertTextRuleName; + sortText?: string; + /** Still accepted by the backend, but rewritten to something else. */ + deprecated?: boolean; +} + +/** An entry as it may arrive from the `suggestions` prop — callers outside this + * module may still use the legacy callable shape. */ +type LooseEntry = Omit, "label" | "insertText"> & { + label: string | ((word: string) => string); + insertText?: string | ((word: string) => string); + kind?: string; +}; + +const SNIPPET: InsertTextRuleName = "InsertAsSnippet"; + +// ── SQL keywords and operators ─────────────────────────────────────────────── + +export const SQL_KEYWORDS: SqlCompletionEntry[] = [ + { name: "and", label: "and", kind: "Keyword", insertText: "and ", detail: "logical AND" }, + { name: "or", label: "or", kind: "Keyword", insertText: "or ", detail: "logical OR" }, + { + name: "like", + label: "like", + kind: "Keyword", + insertText: "like '%${1:params}%' ", + insertTextRules: SNIPPET, + detail: "pattern match", + }, + { + name: "in", + label: "in", + kind: "Keyword", + insertText: "in ('${1:params}') ", + insertTextRules: SNIPPET, + detail: "value in list", + }, + { + name: "not in", + label: "not in", + kind: "Keyword", + insertText: "not in ('${1:params}') ", + insertTextRules: SNIPPET, + detail: "value not in list", + }, + { + name: "between", + label: "between", + kind: "Keyword", + insertText: "between '${1:params}' and '${2:params}' ", + insertTextRules: SNIPPET, + detail: "inclusive range", + }, + { + name: "not between", + label: "not between", + kind: "Keyword", + insertText: "not between '${1:params}' and '${2:params}' ", + insertTextRules: SNIPPET, + detail: "outside range", + }, + { name: "is null", label: "is null", kind: "Keyword", insertText: "is null ", detail: "is NULL" }, + { + name: "is not null", + label: "is not null", + kind: "Keyword", + insertText: "is not null ", + detail: "is not NULL", + }, + { name: ">", label: ">", kind: "Operator", insertText: "> ", detail: "greater than" }, + { name: "<", label: "<", kind: "Operator", insertText: "< ", detail: "less than" }, + { name: ">=", label: ">=", kind: "Operator", insertText: ">= ", detail: "greater or equal" }, + { name: "<=", label: "<=", kind: "Operator", insertText: "<= ", detail: "less or equal" }, + { name: "<>", label: "<>", kind: "Operator", insertText: "<> ", detail: "not equal" }, + { name: "=", label: "=", kind: "Operator", insertText: "= ", detail: "equal" }, + { name: "!=", label: "!=", kind: "Operator", insertText: "!= ", detail: "not equal" }, + { + name: "()", + label: "()", + kind: "Keyword", + insertText: "(${1:condition}) ", + insertTextRules: SNIPPET, + detail: "grouping", + }, +]; + +// ── O2 SQL functions ───────────────────────────────────────────────────────── +// +// Argument conventions, verified against the backend: +// match_all family — the sole argument is a search TERM (quoted) +// str_match / re_match — column first, literal second +// arr* / spath / cast_* — column first (see arrcount_udf.rs:51) +// aggregates — bare column +// histogram — column + interval literal (useAlertForm.ts:463) + +export const SQL_FUNCTIONS: SqlCompletionEntry[] = [ + { + name: "match_all", + label: "match_all", + kind: "Function", + detail: "(term) — full-text search across indexed fields", + insertText: "match_all('${1:value}')", + insertTextRules: SNIPPET, + }, + { + name: "match_all_raw", + label: "match_all_raw", + kind: "Function", + detail: "(term) — deprecated alias, rewritten to match_all", + insertText: "match_all_raw('${1:value}')", + insertTextRules: SNIPPET, + deprecated: true, + }, + { + name: "match_all_raw_ignore_case", + label: "match_all_raw_ignore_case", + kind: "Function", + detail: "(term) — deprecated alias, rewritten to match_all", + insertText: "match_all_raw_ignore_case('${1:value}')", + insertTextRules: SNIPPET, + deprecated: true, + }, + { + name: "re_match", + label: "re_match", + kind: "Function", + detail: "(field, regex) — regular-expression match", + insertText: "re_match(${1:field}, '${2:regex}')", + insertTextRules: SNIPPET, + }, + { + name: "re_not_match", + label: "re_not_match", + kind: "Function", + detail: "(field, regex) — negated regular-expression match", + insertText: "re_not_match(${1:field}, '${2:regex}')", + insertTextRules: SNIPPET, + }, + { + name: "str_match", + label: "str_match", + kind: "Function", + detail: "(field, value) — case-sensitive substring match", + insertText: "str_match(${1:field}, '${2:value}')", + insertTextRules: SNIPPET, + }, + { + name: "str_match_ignore_case", + label: "str_match_ignore_case", + kind: "Function", + detail: "(field, value) — case-insensitive substring match", + insertText: "str_match_ignore_case(${1:field}, '${2:value}')", + insertTextRules: SNIPPET, + }, + { + name: "arr_descending", + label: "arr_descending", + kind: "Function", + detail: "(field) — sort an array descending", + insertText: "arr_descending(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "arrcount", + label: "arrcount", + kind: "Function", + detail: "(field) — number of elements in an array", + insertText: "arrcount(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "arrsort", + label: "arrsort", + kind: "Function", + detail: "(field) — sort an array ascending", + insertText: "arrsort(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "cast_to_arr", + label: "cast_to_arr", + kind: "Function", + detail: "(field) — cast a value to an array", + insertText: "cast_to_arr(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "arrindex", + label: "arrindex", + kind: "Function", + detail: "(field, start, end) — slice an array by index range", + insertText: "arrindex(${1:field}, ${2:1}, ${3:10})", + insertTextRules: SNIPPET, + }, + { + name: "arrjoin", + label: "arrjoin", + kind: "Function", + detail: "(field, delimiter) — join array elements into a string", + insertText: "arrjoin(${1:field}, '${2:delimiter}')", + insertTextRules: SNIPPET, + }, + { + name: "arrzip", + label: "arrzip", + kind: "Function", + detail: "(field1, field2, delimiter) — zip two arrays together", + insertText: "arrzip(${1:field1}, ${2:field2}, '${3:delimiter}')", + insertTextRules: SNIPPET, + }, + { + name: "spath", + label: "spath", + kind: "Function", + detail: "(field, path) — extract a nested value by dotted path", + insertText: "spath(${1:field}, '${2:path}')", + insertTextRules: SNIPPET, + }, + { + name: "to_array_string", + label: "to_array_string", + kind: "Function", + detail: "(field) — render an array as a string", + insertText: "to_array_string(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "unnest", + label: "unnest", + kind: "Function", + detail: "expand an array into rows", + insertText: "unnest", + }, + { + name: "array_extract", + label: "array_extract", + kind: "Function", + detail: "extract an element from an array", + insertText: "array_extract", + }, + { + name: "sum", + label: "sum", + kind: "Function", + detail: "(field) — sum of values", + insertText: "sum(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "avg", + label: "avg", + kind: "Function", + detail: "(field) — arithmetic mean", + insertText: "avg(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "count", + label: "count", + kind: "Function", + detail: "(field) — number of rows", + insertText: "count(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "max", + label: "max", + kind: "Function", + detail: "(field) — largest value", + insertText: "max(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "min", + label: "min", + kind: "Function", + detail: "(field) — smallest value", + insertText: "min(${1:field})", + insertTextRules: SNIPPET, + }, + { + name: "histogram", + label: "histogram", + kind: "Function", + detail: "(field, interval) — bucket a timestamp into intervals", + insertText: "histogram(${1:_timestamp}, '${2:30 second}')", + insertTextRules: SNIPPET, + }, + { + name: "approx_topk", + label: "approx_topk", + kind: "Function", + detail: "(field, k) — approximate top-k most frequent values", + insertText: "approx_topk(${1:field}, ${2:10})", + insertTextRules: SNIPPET, + }, + { + name: "approx_topk_distinct", + label: "approx_topk_distinct", + kind: "Function", + detail: "(field, distinct_field, k) — approximate top-k by distinct count", + insertText: "approx_topk_distinct(${1:field}, ${2:field2}, ${3:10})", + insertTextRules: SNIPPET, + }, +]; + +// ── Prop fallback resolution ───────────────────────────────────────────────── + +/** + * Which suggestion list an editor should use. + * + * `null` means "the caller expressed no opinion" → serve the shared catalog for + * SQL. An explicit `[]` means "deliberately none" (value context) and must be + * honoured — collapsing the two is what made field-value popups show functions. + */ +export const resolveSuggestions = (language: string, propValue: T[] | null | undefined): T[] => + language === "sql" && propValue == null + ? (SQL_FUNCTIONS as unknown as T[]) + : ((propValue ?? []) as T[]); + +/** Which keyword list an editor should use. Empty means "use the defaults". */ +export const resolveKeywords = (language: string, propValue: T[] | null | undefined): T[] => + language === "sql" && !propValue?.length + ? (SQL_KEYWORDS as unknown as T[]) + : ((propValue ?? []) as T[]); + +// ── Monaco item construction ───────────────────────────────────────────────── + +export interface BuildCompletionItemsOptions { + keywords?: LooseEntry[]; + suggestions?: LooseEntry[]; + /** The word monaco reports at the cursor. Used ONLY to feed legacy callable + * entries; nothing in the shared catalog depends on it. */ + word?: string; + range: unknown; + /** monaco.languages.CompletionItemKind */ + kinds: Record; + /** monaco.languages.CompletionItemInsertTextRule */ + insertTextRules: Record; +} + +const toMonacoItem = ( + entry: LooseEntry, + word: string, + range: unknown, + kinds: Record, + rules: Record, +): Record => { + // Legacy callable shape is still supported: `suggestions` is a public prop. + const label = typeof entry.label === "function" ? entry.label(word) : entry.label; + const insertText = + typeof entry.insertText === "function" + ? entry.insertText(word) + : (entry.insertText ?? String(label)); + + const item: Record = { + label, + kind: kinds[entry.kind ?? "Text"], + insertText, + range, + }; + + // The string name MUST be translated here. Monaco does `insertTextRules & 4`, + // and "InsertAsSnippet" & 4 === 0 — a string silently disables snippets. + const ruleName = entry.insertTextRules; + if (ruleName && rules[ruleName] !== undefined) item.insertTextRules = rules[ruleName]; + + if (entry.detail !== undefined) item.detail = entry.detail; + if (entry.documentation !== undefined) item.documentation = entry.documentation; + if (entry.sortText !== undefined) item.sortText = entry.sortText; + + return item; +}; + +/** + * Turn catalog entries into monaco completion items. + * + * Deliberately does NO filtering. Monaco already scores candidates with a + * word-boundary-aware subsequence matcher; the substring pre-filter this + * replaced discarded matches that matcher would have ranked first (typing + * "knn" never surfaced kubernetes_namespace_name). + */ +export const buildCompletionItems = ({ + keywords = [], + suggestions = [], + word = "", + range, + kinds, + insertTextRules, +}: BuildCompletionItemsOptions): Record[] => [ + ...keywords.map((k) => toMonacoItem(k, word, range, kinds, insertTextRules)), + ...suggestions.map((s) => toMonacoItem(s, word, range, kinds, insertTextRules)), +]; + +/** Bare function names, for consumers that must recognise a call site (e.g. + * natural-language detection). Keyed off `name`, never off the display label. */ +export const getSqlFunctionNames = (): string[] => SQL_FUNCTIONS.map((f) => f.name);