fix(editor): SQL autocomplete Phase 1 — icons, staleness, quoting, snippets

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.
This commit is contained in:
Prabhat Sharma 2026-08-02 11:21:20 -07:00
parent 96e5596653
commit 18ded5e63f
7 changed files with 567 additions and 510 deletions

View File

@ -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<any | null> = 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,
}),
};
},
});

View File

@ -186,8 +186,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@blur="onBlurQueryEditor"
editor-height="100%"
data-test-prefix="alert"
:keywords="autoCompleteKeywords"
:suggestions="autoCompleteSuggestions"
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
/>
<div
v-if="
@ -1099,6 +1099,11 @@ const {
autoCompleteData,
autoCompleteKeywords,
autoCompleteSuggestions,
// Context-aware views: these swap in stream names after FROM and field VALUES
// after an operator. Binding the raw lists above meant the value popup showed
// field names exactly where values belong.
effectiveKeywords,
effectiveSuggestions,
getSuggestions,
updateFieldKeywords,
} = useSqlSuggestions();

View File

@ -131,7 +131,12 @@ vi.mock("vuex", async () => {
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<typeof import("vue")>("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: '<div class="stub-inline-editor" />',
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([]);
});
});
});

View File

@ -1052,8 +1052,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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,

View File

@ -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<string>();
// 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);

View File

@ -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: "",

View File

@ -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 <http://www.gnu.org/licenses/>.
/**
* 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<Partial<SqlCompletionEntry>, "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 = <T>(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 = <T>(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<string, number>;
/** monaco.languages.CompletionItemInsertTextRule */
insertTextRules: Record<string, number>;
}
const toMonacoItem = (
entry: LooseEntry,
word: string,
range: unknown,
kinds: Record<string, number>,
rules: Record<string, number>,
): Record<string, unknown> => {
// 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<string, unknown> = {
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<string, unknown>[] => [
...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);