1004 lines
32 KiB
TypeScript
1004 lines
32 KiB
TypeScript
// 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";
|
|
|
|
/**
|
|
* Sort lanes. Monaco orders by sortText, so a single coherent scheme keeps the
|
|
* groups from interleaving. Fields first (what you are most likely to want),
|
|
* then functions, then structural SQL.
|
|
*/
|
|
export const SORT_LANE = {
|
|
field: "\u0000",
|
|
function: "\u0001",
|
|
predicate: "\u0002",
|
|
clause: "\u0003",
|
|
} as const;
|
|
|
|
// ── SQL keywords and operators ───────────────────────────────────────────────
|
|
|
|
export const SQL_KEYWORDS: SqlCompletionEntry[] = [
|
|
{
|
|
name: "and",
|
|
label: "and",
|
|
kind: "Keyword",
|
|
insertText: "and ",
|
|
detail: "logical AND",
|
|
sortText: SORT_LANE.predicate + "and",
|
|
documentation: "Both conditions must hold.",
|
|
},
|
|
{
|
|
name: "or",
|
|
label: "or",
|
|
kind: "Keyword",
|
|
insertText: "or ",
|
|
detail: "logical OR",
|
|
sortText: SORT_LANE.predicate + "or",
|
|
documentation: "Either condition may hold.",
|
|
},
|
|
{
|
|
name: "like",
|
|
label: "like",
|
|
kind: "Keyword",
|
|
insertText: "like '%${1:params}%' ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "pattern match",
|
|
sortText: SORT_LANE.predicate + "like",
|
|
documentation: "SQL pattern match; % matches any run of characters and _ matches one.",
|
|
},
|
|
{
|
|
name: "in",
|
|
label: "in",
|
|
kind: "Keyword",
|
|
insertText: "in ('${1:params}') ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "value in list",
|
|
sortText: SORT_LANE.predicate + "in",
|
|
documentation: "True when the value appears in the supplied list.",
|
|
},
|
|
{
|
|
name: "not in",
|
|
label: "not in",
|
|
kind: "Keyword",
|
|
insertText: "not in ('${1:params}') ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "value not in list",
|
|
sortText: SORT_LANE.predicate + "not in",
|
|
documentation: "True when the value does NOT appear in the supplied list.",
|
|
},
|
|
{
|
|
name: "between",
|
|
label: "between",
|
|
kind: "Keyword",
|
|
insertText: "between '${1:params}' and '${2:params}' ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "inclusive range",
|
|
sortText: SORT_LANE.predicate + "between",
|
|
documentation: "Inclusive range test — equivalent to `>= low AND <= high`.",
|
|
},
|
|
{
|
|
name: "not between",
|
|
label: "not between",
|
|
kind: "Keyword",
|
|
insertText: "not between '${1:params}' and '${2:params}' ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "outside range",
|
|
sortText: SORT_LANE.predicate + "not between",
|
|
documentation: "True when the value falls outside the inclusive range.",
|
|
},
|
|
{
|
|
name: "is null",
|
|
label: "is null",
|
|
kind: "Keyword",
|
|
insertText: "is null ",
|
|
detail: "is NULL",
|
|
sortText: SORT_LANE.predicate + "is null",
|
|
documentation: "Matches rows where the field has no value. `= NULL` never matches.",
|
|
},
|
|
{
|
|
name: "is not null",
|
|
label: "is not null",
|
|
kind: "Keyword",
|
|
insertText: "is not null ",
|
|
detail: "is not NULL",
|
|
sortText: SORT_LANE.predicate + "is not null",
|
|
documentation: "Matches rows where the field has any value.",
|
|
},
|
|
{
|
|
name: ">",
|
|
label: ">",
|
|
kind: "Operator",
|
|
insertText: "> ",
|
|
detail: "greater than",
|
|
sortText: SORT_LANE.predicate + ">",
|
|
},
|
|
{
|
|
name: "<",
|
|
label: "<",
|
|
kind: "Operator",
|
|
insertText: "< ",
|
|
detail: "less than",
|
|
sortText: SORT_LANE.predicate + "<",
|
|
},
|
|
{
|
|
name: ">=",
|
|
label: ">=",
|
|
kind: "Operator",
|
|
insertText: ">= ",
|
|
detail: "greater or equal",
|
|
sortText: SORT_LANE.predicate + ">=",
|
|
},
|
|
{
|
|
name: "<=",
|
|
label: "<=",
|
|
kind: "Operator",
|
|
insertText: "<= ",
|
|
detail: "less or equal",
|
|
sortText: SORT_LANE.predicate + "<=",
|
|
},
|
|
{
|
|
name: "<>",
|
|
label: "<>",
|
|
kind: "Operator",
|
|
insertText: "<> ",
|
|
detail: "not equal",
|
|
sortText: SORT_LANE.predicate + "<>",
|
|
},
|
|
{
|
|
name: "=",
|
|
label: "=",
|
|
kind: "Operator",
|
|
insertText: "= ",
|
|
detail: "equal",
|
|
sortText: SORT_LANE.predicate + "=",
|
|
},
|
|
{
|
|
name: "!=",
|
|
label: "!=",
|
|
kind: "Operator",
|
|
insertText: "!= ",
|
|
detail: "not equal",
|
|
sortText: SORT_LANE.predicate + "!=",
|
|
},
|
|
{
|
|
name: "()",
|
|
label: "()",
|
|
kind: "Keyword",
|
|
insertText: "(${1:condition}) ",
|
|
insertTextRules: SNIPPET,
|
|
detail: "grouping",
|
|
sortText: SORT_LANE.predicate + "()",
|
|
documentation: "Group conditions to control precedence.",
|
|
},
|
|
];
|
|
|
|
// ── SQL clause keywords ──────────────────────────────────────────────────────
|
|
//
|
|
// monaco's sql.contribution.js is tokenizer-only — it registers no completion
|
|
// provider — so SELECT/FROM/WHERE were never offered by anything. They only
|
|
// appeared to work because the word-based provider echoed text already in the
|
|
// buffer. Uppercase by SQL convention; the predicate list above stays lowercase
|
|
// because that is how it has always been inserted.
|
|
|
|
export const SQL_CLAUSE_KEYWORDS: SqlCompletionEntry[] = [
|
|
{
|
|
name: "SELECT",
|
|
label: "SELECT",
|
|
kind: "Keyword",
|
|
detail: "choose columns",
|
|
documentation: "Choose which columns the query returns. `SELECT *` returns every column.",
|
|
insertText: "SELECT ${1:*}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "SELECT",
|
|
},
|
|
{
|
|
name: "FROM",
|
|
label: "FROM",
|
|
kind: "Keyword",
|
|
detail: "source stream",
|
|
documentation: "Names the stream the query reads from.",
|
|
insertText: "FROM ${1:stream}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "FROM",
|
|
},
|
|
{
|
|
name: "WHERE",
|
|
label: "WHERE",
|
|
kind: "Keyword",
|
|
detail: "filter rows",
|
|
documentation: "Filters rows before grouping. Applied to individual rows.",
|
|
insertText: "WHERE ${1:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "WHERE",
|
|
},
|
|
{
|
|
name: "GROUP BY",
|
|
label: "GROUP BY",
|
|
kind: "Keyword",
|
|
detail: "aggregate by",
|
|
documentation: "Collapses rows sharing the listed values into one row per group.",
|
|
insertText: "GROUP BY ${1:field}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "GROUP BY",
|
|
},
|
|
{
|
|
name: "ORDER BY",
|
|
label: "ORDER BY",
|
|
kind: "Keyword",
|
|
detail: "sort results",
|
|
documentation: "Sorts the result set. Combine with ASC or DESC.",
|
|
insertText: "ORDER BY ${1:field} ${2:DESC}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "ORDER BY",
|
|
},
|
|
{
|
|
name: "HAVING",
|
|
label: "HAVING",
|
|
kind: "Keyword",
|
|
detail: "filter groups",
|
|
documentation: "Filters AFTER grouping — use it on aggregates, where WHERE cannot reach.",
|
|
insertText: "HAVING ${1:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "HAVING",
|
|
},
|
|
{
|
|
name: "LIMIT",
|
|
label: "LIMIT",
|
|
kind: "Keyword",
|
|
detail: "cap row count",
|
|
documentation: "Caps how many rows are returned.",
|
|
insertText: "LIMIT ${1:100}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "LIMIT",
|
|
},
|
|
{
|
|
name: "OFFSET",
|
|
label: "OFFSET",
|
|
kind: "Keyword",
|
|
detail: "skip rows",
|
|
documentation: "Skips the first N rows, usually paired with LIMIT for paging.",
|
|
insertText: "OFFSET ${1:0}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "OFFSET",
|
|
},
|
|
{
|
|
name: "DISTINCT",
|
|
label: "DISTINCT",
|
|
kind: "Keyword",
|
|
detail: "unique values",
|
|
documentation: "Removes duplicate rows from the result.",
|
|
insertText: "DISTINCT ${1:field}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "DISTINCT",
|
|
},
|
|
{
|
|
name: "AS",
|
|
label: "AS",
|
|
kind: "Keyword",
|
|
detail: "alias",
|
|
documentation: "Renames a column or stream for the rest of the query.",
|
|
insertText: "AS ${1:alias}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "AS",
|
|
},
|
|
{
|
|
name: "WITH",
|
|
label: "WITH",
|
|
kind: "Keyword",
|
|
detail: "common table expression",
|
|
documentation: "Defines a named subquery you can reference below, for readability.",
|
|
insertText: "WITH ${1:name} AS (${2:SELECT ...})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "WITH",
|
|
},
|
|
{
|
|
name: "UNION",
|
|
label: "UNION",
|
|
kind: "Keyword",
|
|
detail: "combine, dedup",
|
|
documentation: "Combines two result sets and removes duplicate rows.",
|
|
insertText: "UNION",
|
|
sortText: SORT_LANE.clause + "UNION",
|
|
},
|
|
{
|
|
name: "UNION ALL",
|
|
label: "UNION ALL",
|
|
kind: "Keyword",
|
|
detail: "combine, keep dups",
|
|
documentation: "Combines two result sets keeping every row. Cheaper than UNION.",
|
|
insertText: "UNION ALL",
|
|
sortText: SORT_LANE.clause + "UNION ALL",
|
|
},
|
|
{
|
|
name: "INNER JOIN",
|
|
label: "INNER JOIN",
|
|
kind: "Keyword",
|
|
detail: "matching rows only",
|
|
documentation: "Returns only rows with a match on both sides of the join.",
|
|
insertText: "INNER JOIN ${1:stream} ON ${2:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "INNER JOIN",
|
|
},
|
|
{
|
|
name: "LEFT JOIN",
|
|
label: "LEFT JOIN",
|
|
kind: "Keyword",
|
|
detail: "all left rows",
|
|
documentation: "Returns every left-hand row, filling unmatched right columns with NULL.",
|
|
insertText: "LEFT JOIN ${1:stream} ON ${2:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "LEFT JOIN",
|
|
},
|
|
{
|
|
name: "RIGHT JOIN",
|
|
label: "RIGHT JOIN",
|
|
kind: "Keyword",
|
|
detail: "all right rows",
|
|
documentation: "Returns every right-hand row, filling unmatched left columns with NULL.",
|
|
insertText: "RIGHT JOIN ${1:stream} ON ${2:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "RIGHT JOIN",
|
|
},
|
|
{
|
|
name: "FULL OUTER JOIN",
|
|
label: "FULL OUTER JOIN",
|
|
kind: "Keyword",
|
|
detail: "all rows both sides",
|
|
documentation: "Returns rows from both sides, filling unmatched columns with NULL.",
|
|
insertText: "FULL OUTER JOIN ${1:stream} ON ${2:condition}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "FULL OUTER JOIN",
|
|
},
|
|
{
|
|
name: "ON",
|
|
label: "ON",
|
|
kind: "Keyword",
|
|
detail: "join condition",
|
|
documentation: "States which columns a join matches on.",
|
|
insertText: "ON ${1:left} = ${2:right}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "ON",
|
|
},
|
|
{
|
|
name: "CASE",
|
|
label: "CASE",
|
|
kind: "Keyword",
|
|
detail: "conditional expression",
|
|
documentation: "Inline conditional. Each WHEN is tested in order; ELSE supplies the fallback.",
|
|
insertText: "CASE WHEN ${1:condition} THEN ${2:result} ELSE ${3:fallback} END",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "CASE",
|
|
},
|
|
{
|
|
name: "WHEN",
|
|
label: "WHEN",
|
|
kind: "Keyword",
|
|
detail: "case branch",
|
|
documentation: "One branch of a CASE expression, tested in order.",
|
|
insertText: "WHEN ${1:condition} THEN ${2:result}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "WHEN",
|
|
},
|
|
{
|
|
name: "THEN",
|
|
label: "THEN",
|
|
kind: "Keyword",
|
|
detail: "case result",
|
|
documentation: "The value a CASE branch produces when its condition holds.",
|
|
insertText: "THEN ${1:result}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "THEN",
|
|
},
|
|
{
|
|
name: "ELSE",
|
|
label: "ELSE",
|
|
kind: "Keyword",
|
|
detail: "case fallback",
|
|
documentation: "The value a CASE produces when no branch matched.",
|
|
insertText: "ELSE ${1:fallback}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "ELSE",
|
|
},
|
|
{
|
|
name: "END",
|
|
label: "END",
|
|
kind: "Keyword",
|
|
detail: "close case",
|
|
documentation: "Closes a CASE expression.",
|
|
insertText: "END",
|
|
sortText: SORT_LANE.clause + "END",
|
|
},
|
|
{
|
|
name: "CAST",
|
|
label: "CAST",
|
|
kind: "Keyword",
|
|
detail: "convert type",
|
|
documentation: "Converts a value to another type, e.g. `CAST(code AS VARCHAR)`.",
|
|
insertText: "CAST(${1:expr} AS ${2:type})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "CAST",
|
|
},
|
|
{
|
|
name: "OVER",
|
|
label: "OVER",
|
|
kind: "Keyword",
|
|
detail: "window frame",
|
|
documentation: "Turns an aggregate into a window function computed across related rows.",
|
|
insertText: "OVER (PARTITION BY ${1:field} ORDER BY ${2:field})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "OVER",
|
|
},
|
|
{
|
|
name: "PARTITION BY",
|
|
label: "PARTITION BY",
|
|
kind: "Keyword",
|
|
detail: "window grouping",
|
|
documentation: "Splits a window function's input into independent groups.",
|
|
insertText: "PARTITION BY ${1:field}",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "PARTITION BY",
|
|
},
|
|
{
|
|
name: "ASC",
|
|
label: "ASC",
|
|
kind: "Keyword",
|
|
detail: "ascending",
|
|
documentation: "Sorts smallest first. The default for ORDER BY.",
|
|
insertText: "ASC",
|
|
sortText: SORT_LANE.clause + "ASC",
|
|
},
|
|
{
|
|
name: "DESC",
|
|
label: "DESC",
|
|
kind: "Keyword",
|
|
detail: "descending",
|
|
documentation: "Sorts largest first — usually what you want for timestamps.",
|
|
insertText: "DESC",
|
|
sortText: SORT_LANE.clause + "DESC",
|
|
},
|
|
{
|
|
name: "NULLS FIRST",
|
|
label: "NULLS FIRST",
|
|
kind: "Keyword",
|
|
detail: "nulls before values",
|
|
documentation: "Places NULLs at the start of the sort order.",
|
|
insertText: "NULLS FIRST",
|
|
sortText: SORT_LANE.clause + "NULLS FIRST",
|
|
},
|
|
{
|
|
name: "NULLS LAST",
|
|
label: "NULLS LAST",
|
|
kind: "Keyword",
|
|
detail: "nulls after values",
|
|
documentation: "Places NULLs at the end of the sort order.",
|
|
insertText: "NULLS LAST",
|
|
sortText: SORT_LANE.clause + "NULLS LAST",
|
|
},
|
|
{
|
|
name: "EXISTS",
|
|
label: "EXISTS",
|
|
kind: "Keyword",
|
|
detail: "subquery has rows",
|
|
documentation: "True when the subquery returns at least one row.",
|
|
insertText: "EXISTS (${1:SELECT ...})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "EXISTS",
|
|
},
|
|
{
|
|
name: "ANY",
|
|
label: "ANY",
|
|
kind: "Keyword",
|
|
detail: "matches any",
|
|
documentation: "True when the comparison holds for at least one value returned.",
|
|
insertText: "ANY (${1:SELECT ...})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "ANY",
|
|
},
|
|
{
|
|
name: "ALL",
|
|
label: "ALL",
|
|
kind: "Keyword",
|
|
detail: "matches all",
|
|
documentation: "True when the comparison holds for every value returned.",
|
|
insertText: "ALL (${1:SELECT ...})",
|
|
insertTextRules: SNIPPET,
|
|
sortText: SORT_LANE.clause + "ALL",
|
|
},
|
|
];
|
|
|
|
// ── 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)",
|
|
documentation: "Full-text search for a term across all indexed fields of the stream.",
|
|
insertText: "match_all('${1:value}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "match_all_raw",
|
|
label: "match_all_raw",
|
|
kind: "Function",
|
|
detail: "(term)",
|
|
documentation:
|
|
"Deprecated alias for `match_all` — the query planner rewrites it before execution. Prefer `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)",
|
|
documentation:
|
|
"Deprecated alias for `match_all` — the query planner rewrites it before execution. Prefer `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)",
|
|
documentation: "Return rows where the field matches the regular expression.",
|
|
insertText: "re_match(${1:field}, '${2:regex}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "re_not_match",
|
|
label: "re_not_match",
|
|
kind: "Function",
|
|
detail: "(field, regex)",
|
|
documentation: "Return rows where the field does NOT match the regular expression.",
|
|
insertText: "re_not_match(${1:field}, '${2:regex}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "str_match",
|
|
label: "str_match",
|
|
kind: "Function",
|
|
detail: "(field, value)",
|
|
documentation: "Case-sensitive substring match against a field.",
|
|
insertText: "str_match(${1:field}, '${2:value}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "str_match_ignore_case",
|
|
label: "str_match_ignore_case",
|
|
kind: "Function",
|
|
detail: "(field, value)",
|
|
documentation: "Case-insensitive substring match against a field.",
|
|
insertText: "str_match_ignore_case(${1:field}, '${2:value}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arr_descending",
|
|
label: "arr_descending",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Sort the elements of an array field in descending order.",
|
|
insertText: "arr_descending(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arrcount",
|
|
label: "arrcount",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Number of elements in an array field.",
|
|
insertText: "arrcount(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arrsort",
|
|
label: "arrsort",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Sort the elements of an array field in ascending order.",
|
|
insertText: "arrsort(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "cast_to_arr",
|
|
label: "cast_to_arr",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Cast a field value to an array so the arr* functions can operate on it.",
|
|
insertText: "cast_to_arr(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arrindex",
|
|
label: "arrindex",
|
|
kind: "Function",
|
|
detail: "(field, start, end)",
|
|
documentation: "Slice an array field by an inclusive index range.",
|
|
insertText: "arrindex(${1:field}, ${2:1}, ${3:10})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arrjoin",
|
|
label: "arrjoin",
|
|
kind: "Function",
|
|
detail: "(field, delimiter)",
|
|
documentation: "Join the elements of an array field into a single string.",
|
|
insertText: "arrjoin(${1:field}, '${2:delimiter}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "arrzip",
|
|
label: "arrzip",
|
|
kind: "Function",
|
|
detail: "(field1, field2, delimiter)",
|
|
documentation: "Pair up two array fields element by element, joined by the delimiter.",
|
|
insertText: "arrzip(${1:field1}, ${2:field2}, '${3:delimiter}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "spath",
|
|
label: "spath",
|
|
kind: "Function",
|
|
detail: "(field, path)",
|
|
documentation:
|
|
"Extract a nested value from a structured field using a dotted path, e.g. `spath(body, 'user.id')`.",
|
|
insertText: "spath(${1:field}, '${2:path}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "to_array_string",
|
|
label: "to_array_string",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Render an array field as its string representation.",
|
|
insertText: "to_array_string(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "unnest",
|
|
label: "unnest",
|
|
kind: "Function",
|
|
detail: "(array)",
|
|
documentation:
|
|
"Expand an array into one row per element, e.g. `unnest(flatten(cast_to_arr(field)))`.",
|
|
insertText: "unnest(${1:array})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "array_extract",
|
|
label: "array_extract",
|
|
kind: "Function",
|
|
detail: "(array, index)",
|
|
documentation:
|
|
"Extract a single element from an array by 1-based index, e.g. " +
|
|
"`array_extract(regexp_match(log, '...'), 1)`.",
|
|
insertText: "array_extract(${1:array}, ${2:1})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "sum",
|
|
label: "sum",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Sum of all values in the field. Aggregate — pair with GROUP BY.",
|
|
insertText: "sum(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "avg",
|
|
label: "avg",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Arithmetic mean of the field. Aggregate — pair with GROUP BY.",
|
|
insertText: "avg(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "count",
|
|
label: "count",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Number of rows. Aggregate — pair with GROUP BY.",
|
|
insertText: "count(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "max",
|
|
label: "max",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Largest value in the field. Aggregate — pair with GROUP BY.",
|
|
insertText: "max(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "min",
|
|
label: "min",
|
|
kind: "Function",
|
|
detail: "(field)",
|
|
documentation: "Smallest value in the field. Aggregate — pair with GROUP BY.",
|
|
insertText: "min(${1:field})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "histogram",
|
|
label: "histogram",
|
|
kind: "Function",
|
|
detail: "(field, interval)",
|
|
documentation:
|
|
"Bucket a timestamp column into fixed intervals, e.g. `histogram(_timestamp, '30 second')`. Use as the x-axis of a time series.",
|
|
insertText: "histogram(${1:_timestamp}, '${2:30 second}')",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "approx_topk",
|
|
label: "approx_topk",
|
|
kind: "Function",
|
|
detail: "(field, k)",
|
|
documentation:
|
|
"Approximate the k most frequent values of a field. Much cheaper than an exact GROUP BY ... ORDER BY count DESC on high-cardinality fields.",
|
|
insertText: "approx_topk(${1:field}, ${2:10})",
|
|
insertTextRules: SNIPPET,
|
|
},
|
|
{
|
|
name: "approx_topk_distinct",
|
|
label: "approx_topk_distinct",
|
|
kind: "Function",
|
|
detail: "(field, distinct_field, k)",
|
|
documentation:
|
|
"Approximate the k values of `field` with the highest distinct count of `distinct_field`.",
|
|
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, ...SQL_CLAUSE_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>;
|
|
/** monaco.languages.CompletionItemTag. Optional — omit and no item is tagged. */
|
|
tags?: Record<string, number>;
|
|
}
|
|
|
|
const toMonacoItem = (
|
|
entry: LooseEntry,
|
|
word: string,
|
|
range: unknown,
|
|
kinds: Record<string, number>,
|
|
rules: Record<string, number>,
|
|
tags?: 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;
|
|
// Wrapped as an IMarkdownString: monaco renders markdown only for the object
|
|
// form, so a plain string would show its backticks literally in the docs panel.
|
|
if (entry.documentation !== undefined) {
|
|
item.documentation =
|
|
typeof entry.documentation === "string"
|
|
? { value: entry.documentation }
|
|
: entry.documentation;
|
|
}
|
|
if (entry.sortText !== undefined) item.sortText = entry.sortText;
|
|
|
|
// Renders the label with a strikethrough. match_all_raw and friends are still
|
|
// accepted (sql/rewriter rewrites them) but should not be reached for.
|
|
if (entry.deprecated && tags?.Deprecated !== undefined) item.tags = [tags.Deprecated];
|
|
|
|
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,
|
|
tags,
|
|
}: BuildCompletionItemsOptions): Record<string, unknown>[] => [
|
|
...keywords.map((k) => toMonacoItem(k, word, range, kinds, insertTextRules, tags)),
|
|
...suggestions.map((s) => toMonacoItem(s, word, range, kinds, insertTextRules, tags)),
|
|
];
|
|
|
|
/**
|
|
* True when any entry derives its content from the typed word.
|
|
*
|
|
* Monaco calls a provider once per word and then filters the returned list
|
|
* client-side; it only re-queries mid-word when the list is marked
|
|
* `incomplete`. Nothing in the shared catalog is dynamic, but the `suggestions`
|
|
* prop is public and legacy callers may still pass callables — for those the
|
|
* caller must report the list as incomplete or the content freezes at the first
|
|
* keystroke (the original approx_topk('a', 10) bug).
|
|
*/
|
|
export const hasDynamicEntries = (entries: LooseEntry[] = []): boolean =>
|
|
entries.some((e) => typeof e.label === "function" || typeof e.insertText === "function");
|
|
|
|
/** 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);
|
|
|
|
/**
|
|
* Snippet argument list for a custom (VRL) function of `numArgs` arguments.
|
|
*
|
|
* Each argument gets its OWN tab stop. Monaco LINKS placeholders that share an
|
|
* index, so the previous `'${1:value}'` repeated per argument meant typing into
|
|
* one mirrored into all of them and a multi-argument function could not be
|
|
* filled in. That was invisible while insertTextRules was broken and the text
|
|
* was inserted literally; it became reachable the moment snippets started working.
|
|
*/
|
|
export const buildFunctionArgs = (numArgs: number | string): string => {
|
|
const count = Number.parseInt(String(numArgs), 10);
|
|
if (!Number.isFinite(count) || count <= 0) return "()";
|
|
const args = Array.from({ length: count }, (_, i) => `'\${${i + 1}:value}'`);
|
|
return `(${args.join(",")})`;
|
|
};
|
|
|
|
/**
|
|
* Build a completion entry for a schema field.
|
|
*
|
|
* The column type is already carried on every field object and used to be
|
|
* discarded; it is exactly what belongs in the suggest widget's inline column,
|
|
* and it is what type-aware operator suggestions will key off later.
|
|
*/
|
|
export const buildFieldEntry = (field: {
|
|
name: string;
|
|
/** Dynamic fields and Alerts columns carry the type here. */
|
|
type?: string;
|
|
/** Logs SCHEMA fields carry it here instead (useStreamFields.ts:452). */
|
|
dataType?: string;
|
|
}): SqlCompletionEntry => {
|
|
const entry: SqlCompletionEntry = {
|
|
name: field.name,
|
|
label: field.name,
|
|
kind: "Field",
|
|
insertText: field.name,
|
|
sortText: SORT_LANE.field + field.name,
|
|
};
|
|
// Two field shapes exist in the app: schema fields use `dataType`, dynamic
|
|
// fields and Alerts columns use `type`. Omit rather than render "undefined".
|
|
const columnType = field.type || field.dataType;
|
|
if (columnType) entry.detail = columnType;
|
|
return entry;
|
|
};
|