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:
parent
96e5596653
commit
18ded5e63f
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue