test(editor): failing specs for autocomplete Phase 2 (TDD red)

Covers tmp/code.md Phase 2 items 7, 8 and 10 (item 9, the single catalog,
landed in Phase 1).

B1 — SQL clause keywords. monaco's sql.contribution is tokenizer-only, so
  SELECT/FROM/WHERE/GROUP BY/JOIN/CASE/OVER have never been offered; they only
  appeared to work via word-echo from the buffer. Specs pin a new
  SQL_CLAUSE_KEYWORDS export: content, uppercase labels, CASE and JOIN
  snippets, distinct tab-stop indices, no overlap with the predicate list, and
  delivery through resolveKeywords and the composable. Also pinned: clauses
  must NOT appear in value context, and must sort behind field names.

D1/N5 — column types. Every field object already carries its type (Utf8,
  Int64, ...) and buildFieldKeywords discarded it. Specs pin a buildFieldEntry
  helper putting the type in , absence of a type omitting detail rather
  than rendering 'undefined', and the type surviving updateFieldKeywords and
  updateAllKeywords.

B4 — the function catalog endpoint.
  Frontend: query_functions service (GET /api/{org}/query_functions) and a
  serverFunctions module converting the payload to entries. Specs pin one tab
  stop per argument, NO quoting of server arguments (arity is known, types are
  not — guessing is how sum('field') happened), and a merge where the LOCAL
  catalog wins on insertion detail because only it knows which arguments are
  columns.
  Backend: exec.rs tests for the two registry gaps found in review — JSON
  functions are registered by a separate call the snapshot context never makes,
  and rewriter-provided names (match_all_raw) appear in no registry at all.
  A catalog_function_names() union is pinned over both.

Frontend: 49 failing, 2 suites blocked on unwritten modules.
This commit is contained in:
Prabhat Sharma 2026-08-02 11:45:53 -07:00
parent 9867af28d1
commit be128b48c4
5 changed files with 551 additions and 0 deletions

View File

@ -786,6 +786,97 @@ mod tests {
Ok(())
}
// ── tmp/code.md B4 — the function catalog served to the query editor ─────
//
// registered_function_names() is the authoritative list. Two gaps were
// found reviewing it: JSON functions are registered by a SEPARATE call that
// the snapshot context never made, and functions provided by SQL rewriters
// never appear in any registry at all.
#[test]
fn registered_function_names_includes_o2_udfs() {
let names = registered_function_names();
for expected in [
"str_match",
"match_all",
"histogram",
"spath",
"arrcount",
"re_match",
"cast_to_timestamp",
] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the O2 UDF `{expected}`"
);
}
}
#[test]
fn registered_function_names_includes_datafusion_builtins() {
let names = registered_function_names();
for expected in ["date_trunc", "coalesce", "concat", "regexp_replace", "abs"] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the DataFusion builtin `{expected}`"
);
}
}
#[test]
fn registered_function_names_includes_json_functions() {
// Production contexts call datafusion_functions_json::register_all
// separately (see flight.rs). Without that call here the whole json_*
// family is absent from the catalog the editor is served.
let names = registered_function_names();
for expected in ["json_get", "json_get_str", "json_length"] {
assert!(
names.iter().any(|n| n == expected),
"registry is missing the JSON function `{expected}` — is datafusion_functions_json::register_all wired into the snapshot context?"
);
}
}
#[test]
fn registered_function_names_is_sorted_and_deduped() {
let names = registered_function_names();
assert!(!names.is_empty());
let mut sorted = names.to_vec();
sorted.sort();
sorted.dedup();
assert_eq!(names, sorted.as_slice(), "names must be sorted and deduplicated");
}
#[test]
fn rewriter_aliases_are_exposed_for_the_catalog() {
// match_all_raw / match_all_raw_ignore_case are valid user-facing SQL
// (sql/rewriter/match_all_raw.rs rewrites them to match_all before
// planning) but appear in NO registry. A catalog built only from the
// registry would silently drop two functions users rely on today.
let aliases = crate::sql::rewriter::REWRITER_FUNCTION_ALIASES;
for expected in ["match_all_raw", "match_all_raw_ignore_case"] {
assert!(
aliases.contains(&expected),
"rewriter alias `{expected}` is not exported for the catalog"
);
}
}
#[test]
fn catalog_function_names_unions_registry_and_rewriter_aliases() {
let catalog = catalog_function_names();
assert!(catalog.iter().any(|n| n == "match_all"), "registry entry missing");
assert!(
catalog.iter().any(|n| n == "match_all_raw"),
"rewriter alias missing from the catalog union"
);
assert!(catalog.iter().any(|n| n == "json_get"), "json function missing");
let mut sorted = catalog.clone();
sorted.sort();
sorted.dedup();
assert_eq!(catalog, sorted, "catalog must be sorted and deduplicated");
}
#[test]
fn test_table_builder_new() {
let builder = TableBuilder::new();

View File

@ -410,3 +410,87 @@ describe("catalog wiring — suggestions come from the shared sqlCompletion modu
expect(host.sortText < and.sortText).toBe(true);
});
});
// ─── Phase 2 (tmp/code.md D1/N5 + B1) ────────────────────────────────────────
// The composable is what Logs/Traces/Alerts actually consume, so the column
// type and the clause keywords have to survive the trip through it — not just
// exist in the catalog.
describe("Phase 2 — column types reach the editor as detail (D1/N5)", () => {
beforeEach(() => vi.clearAllMocks());
it("carries the column type into each field keyword's detail", () => {
const c = makeComposable();
c.updateFieldKeywords([
{ name: "code", type: "Int64" },
{ name: "message", type: "Utf8" },
]);
const code = c.autoCompleteKeywords.value.find((k: any) => k.label === "code");
const message = c.autoCompleteKeywords.value.find((k: any) => k.label === "message");
expect(code.detail).toBe("Int64");
expect(message.detail).toBe("Utf8");
});
it("still excludes the timestamp column", () => {
const c = makeComposable();
c.updateFieldKeywords([
{ name: "_timestamp", type: "Int64" },
{ name: "code", type: "Int64" },
]);
const labels = c.autoCompleteKeywords.value.map((k: any) => k.label);
expect(labels).not.toContain("_timestamp");
expect(labels).toContain("code");
});
it("tolerates fields with no type rather than emitting 'undefined'", () => {
const c = makeComposable();
c.updateFieldKeywords([{ name: "code" }]);
const code = c.autoCompleteKeywords.value.find((k: any) => k.label === "code");
expect(code.detail).toBeUndefined();
});
it("carries types through updateAllKeywords too", () => {
const c = makeComposable();
c.updateAllKeywords([{ name: "code", type: "Int64" }], []);
const code = c.autoCompleteKeywords.value.find((k: any) => k.label === "code");
expect(code.detail).toBe("Int64");
});
});
describe("Phase 2 — SQL clause keywords are offered (B1)", () => {
beforeEach(() => vi.clearAllMocks());
it("includes SELECT/FROM/WHERE in the base keyword list", async () => {
const c = makeComposable({ storedValues: [] });
await run(c, "SELECT * FROM stream WHERE ");
const labels = c.effectiveKeywords.value.map((k: any) => k.label);
for (const kw of ["SELECT", "FROM", "WHERE", "GROUP BY", "ORDER BY", "LIMIT"]) {
expect(labels, `missing ${kw}`).toContain(kw);
}
});
it("keeps predicates alongside the clauses", async () => {
const c = makeComposable({ storedValues: [] });
await run(c, "SELECT * FROM stream WHERE ");
const labels = c.effectiveKeywords.value.map((k: any) => k.label);
expect(labels).toContain("and");
expect(labels).toContain("=");
});
it("suppresses clause keywords in value context", async () => {
const c = makeComposable({ storedValues: ["error"] });
await run(c, "level = ");
const labels = c.effectiveKeywords.value.map((k: any) => k.label);
expect(labels).not.toContain("SELECT");
expect(labels).toEqual(["error"]);
});
it("sorts fields ahead of clause keywords", async () => {
const c = makeComposable({ storedValues: [] });
c.updateFieldKeywords([{ name: "host", type: "Utf8" }]);
await run(c, "SELECT * FROM stream WHERE ");
const host = c.effectiveKeywords.value.find((k: any) => k.label === "host");
const select = c.effectiveKeywords.value.find((k: any) => k.label === "SELECT");
expect(host.sortText < select.sortText).toBe(true);
});
});

View File

@ -0,0 +1,52 @@
// 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/>.
// TDD spec for tmp/code.md B4 — the query-function catalog endpoint.
//
// The frontend currently hand-maintains its list of SQL functions, which has
// no way of staying in step with what the backend actually registers. The
// server already computes the authoritative set; this service exposes it.
import { describe, it, expect, vi, beforeEach } from "vitest";
import http from "./http";
import queryFunctions from "./query_functions";
vi.mock("./http", () => {
const mockClient = { get: vi.fn() };
return { default: vi.fn(() => mockClient) };
});
describe("query_functions service", () => {
const mockClient = (http as unknown as ReturnType<typeof vi.fn>)();
beforeEach(() => vi.clearAllMocks());
it("list() calls GET /api/{org}/query_functions", () => {
queryFunctions.list("myorg");
expect(mockClient.get).toHaveBeenCalledWith("/api/myorg/query_functions");
});
it("encodes an organisation identifier that needs it", () => {
queryFunctions.list("my org/1");
expect(mockClient.get).toHaveBeenCalledWith(
`/api/${encodeURIComponent("my org/1")}/query_functions`,
);
});
it("returns the client promise so callers can await it", async () => {
mockClient.get.mockResolvedValue({ data: { list: [] } });
await expect(queryFunctions.list("myorg")).resolves.toEqual({ data: { list: [] } });
});
});

View File

@ -0,0 +1,143 @@
// 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/>.
// TDD spec for tmp/code.md B4 — turning the server's function registry into
// completion entries and merging it with the hand-written O2 catalog.
//
// The server list is authoritative for WHAT exists (it is derived from the
// DataFusion registry, so it tracks the pinned fork, build features and
// per-org VRL transforms). The local catalog is authoritative for HOW an entry
// should be inserted, because only it knows which arguments are columns and
// which are literals — the distinction A3 was about.
import { describe, it, expect } from "vitest";
import { SQL_FUNCTIONS } from "./sqlCompletion";
import { toCompletionEntries, mergeServerFunctions } from "./serverFunctions";
const serverFn = (over: Record<string, unknown> = {}) => ({
name: "date_trunc",
signature: "(precision, timestamp)",
doc: "Truncate a timestamp to a given precision.",
kind: "scalar",
...over,
});
describe("toCompletionEntries — server payload to catalog entries", () => {
it("maps name, signature and doc onto the entry shape", () => {
const [e] = toCompletionEntries([serverFn()]);
expect(e.name).toBe("date_trunc");
expect(e.label).toBe("date_trunc");
expect(e.kind).toBe("Function");
expect(e.detail).toBe("(precision, timestamp)");
expect(e.documentation).toBe("Truncate a timestamp to a given precision.");
});
it("builds a snippet with one tab stop per declared argument", () => {
const [e] = toCompletionEntries([serverFn()]);
expect(e.insertText).toBe("date_trunc(${1:precision}, ${2:timestamp})");
expect(e.insertTextRules).toBe("InsertAsSnippet");
});
it("never reuses a tab stop index", () => {
const [e] = toCompletionEntries([
serverFn({ name: "f", signature: "(a, b, c, d)" }),
]);
const indices = [...e.insertText.matchAll(/\$\{(\d+):/g)].map((m) => m[1]);
expect(indices).toEqual(["1", "2", "3", "4"]);
});
it("emits empty parens for a zero-argument function", () => {
const [e] = toCompletionEntries([serverFn({ name: "now", signature: "()" })]);
expect(e.insertText).toBe("now()");
});
it("falls back to a single tab stop when the signature is unparseable", () => {
const [e] = toCompletionEntries([serverFn({ name: "weird", signature: undefined })]);
expect(e.insertText).toBe("weird(${1:arg})");
});
it("does not quote any server argument — arity is known, types are not", () => {
// Quoting is a per-function decision the server list cannot make. Guessing
// wrong is exactly the A3 bug (sum('field')), so never guess.
for (const e of toCompletionEntries([serverFn(), serverFn({ name: "concat" })])) {
expect(e.insertText, `${e.name} must not quote an argument`).not.toContain("'");
}
});
it("flags deprecated entries from the server", () => {
const [e] = toCompletionEntries([serverFn({ deprecated: true })]);
expect(e.deprecated).toBe(true);
});
it("tolerates an empty or missing payload", () => {
expect(toCompletionEntries([])).toEqual([]);
expect(toCompletionEntries(undefined as any)).toEqual([]);
});
it("skips entries with no usable name", () => {
expect(toCompletionEntries([{ name: "" } as any, {} as any])).toEqual([]);
});
});
describe("mergeServerFunctions — local catalog wins on insertion detail", () => {
it("keeps the local entry when the server reports the same function", () => {
// The local sum() knows its argument is a COLUMN; the server only knows
// the arity. Preferring the server entry would reintroduce sum('field').
const merged = mergeServerFunctions(SQL_FUNCTIONS, [serverFn({ name: "sum" })]);
const sum = merged.filter((f) => f.name === "sum");
expect(sum).toHaveLength(1);
expect(sum[0].insertText).toBe("sum(${1:field})");
});
it("adds server functions the local catalog does not have", () => {
const merged = mergeServerFunctions(SQL_FUNCTIONS, [serverFn()]);
expect(merged.some((f) => f.name === "date_trunc")).toBe(true);
});
it("keeps every local entry even when the server list is empty", () => {
const merged = mergeServerFunctions(SQL_FUNCTIONS, []);
expect(merged).toHaveLength(SQL_FUNCTIONS.length);
});
it("returns the local catalog unchanged when the server call failed", () => {
expect(mergeServerFunctions(SQL_FUNCTIONS, undefined as any)).toEqual(SQL_FUNCTIONS);
});
it("produces no duplicate names", () => {
const merged = mergeServerFunctions(SQL_FUNCTIONS, [
serverFn({ name: "sum" }),
serverFn(),
serverFn(),
]);
const names = merged.map((f) => f.name);
expect(new Set(names).size).toBe(names.length);
});
it("matches case-insensitively so SUM does not duplicate sum", () => {
const merged = mergeServerFunctions(SQL_FUNCTIONS, [serverFn({ name: "SUM" })]);
expect(merged.filter((f) => f.name.toLowerCase() === "sum")).toHaveLength(1);
});
it("marks server-only entries kind Function so they get the right icon", () => {
const merged = mergeServerFunctions(SQL_FUNCTIONS, [serverFn()]);
expect(merged.find((f) => f.name === "date_trunc")!.kind).toBe("Function");
});
it("sorts deterministically so the dropdown order is stable", () => {
const a = mergeServerFunctions(SQL_FUNCTIONS, [serverFn(), serverFn({ name: "coalesce" })]);
const b = mergeServerFunctions(SQL_FUNCTIONS, [serverFn({ name: "coalesce" }), serverFn()]);
expect(a.map((f) => f.name)).toEqual(b.map((f) => f.name));
});
});

View File

@ -22,7 +22,9 @@ import { describe, it, expect } from "vitest";
import {
SQL_FUNCTIONS,
SQL_KEYWORDS,
SQL_CLAUSE_KEYWORDS,
buildCompletionItems,
buildFieldEntry,
type SqlCompletionEntry,
} from "./sqlCompletion";
@ -614,3 +616,182 @@ describe("N2/D7 — resolveSuggestions / resolveKeywords", () => {
expect(resolveKeywords("sql", custom)).toBe(custom);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 2
// ═══════════════════════════════════════════════════════════════════════════
// ───────────────────────────────────────────────────────────────────────────
// B1 — SQL clause keywords
//
// monaco's sql.contribution.js is TOKENIZER-ONLY (zero matches for
// registerCompletionItemProvider). Nothing ever offered SELECT/FROM/WHERE; the
// only reason they appeared to work was the word-based provider echoing text
// already in the buffer.
// ───────────────────────────────────────────────────────────────────────────
describe("B1 — the clause keyword catalog", () => {
const clause = (label: string) => SQL_CLAUSE_KEYWORDS.find((k) => k.label === label);
const REQUIRED = [
"SELECT",
"FROM",
"WHERE",
"GROUP BY",
"ORDER BY",
"HAVING",
"LIMIT",
"OFFSET",
"DISTINCT",
"AS",
"WITH",
"UNION",
"UNION ALL",
"INNER JOIN",
"LEFT JOIN",
"RIGHT JOIN",
"FULL OUTER JOIN",
"ON",
"CASE",
"CAST",
"OVER",
"PARTITION BY",
"ASC",
"DESC",
"NULLS FIRST",
"NULLS LAST",
"EXISTS",
];
it.each(REQUIRED)("offers %s", (label) => {
expect(clause(label), `SQL_CLAUSE_KEYWORDS is missing ${label}`).toBeDefined();
});
it("labels clause keywords in uppercase — the SQL convention", () => {
for (const k of SQL_CLAUSE_KEYWORDS) {
expect(k.label, `${k.name} should be uppercase`).toBe(k.label.toUpperCase());
}
});
it("marks every clause keyword as kind Keyword", () => {
for (const k of SQL_CLAUSE_KEYWORDS) expect(k.kind, k.name).toBe("Keyword");
});
it("gives every clause keyword a detail so the widget explains it", () => {
for (const k of SQL_CLAUSE_KEYWORDS) expect(k.detail, `${k.name} needs detail`).toBeTruthy();
});
it("does not duplicate anything already in SQL_KEYWORDS", () => {
const predicates = new Set(SQL_KEYWORDS.map((k) => k.name.toLowerCase()));
for (const k of SQL_CLAUSE_KEYWORDS) {
expect(predicates.has(k.name.toLowerCase()), `${k.name} duplicates a predicate`).toBe(false);
}
});
it("has no duplicates within itself", () => {
const names = SQL_CLAUSE_KEYWORDS.map((k) => k.name);
expect(new Set(names).size).toBe(names.length);
});
it("expands CASE into a full snippet, not a bare word", () => {
const c = clause("CASE")!;
expect(c.insertText).toContain("WHEN");
expect(c.insertText).toContain("END");
expect(c.insertTextRules).toBe("InsertAsSnippet");
});
it("expands JOINs with an ON clause tab stop", () => {
const j = clause("INNER JOIN")!;
expect(j.insertText).toContain("ON");
expect(j.insertTextRules).toBe("InsertAsSnippet");
});
it("gives multi-tab-stop clause snippets distinct indices", () => {
for (const k of SQL_CLAUSE_KEYWORDS) {
const indices = [...k.insertText.matchAll(/\$\{(\d+)[:}]/g)].map((m) => m[1]);
expect(new Set(indices).size, `${k.name} reuses a tab stop index`).toBe(indices.length);
}
});
it("declares InsertAsSnippet for every entry carrying a tab stop", () => {
for (const k of SQL_CLAUSE_KEYWORDS) {
if (k.insertText.includes("${")) {
expect(k.insertTextRules, `${k.name}`).toBe("InsertAsSnippet");
}
}
});
it("sorts clause keywords after fields but they remain reachable", () => {
// Clause keywords are structural: useful, but never ahead of a column name.
for (const k of SQL_CLAUSE_KEYWORDS) {
expect(k.sortText, `${k.name} needs an explicit sortText`).toBeTruthy();
}
});
});
describe("B1 — clause keywords reach a SQL editor through the fallback", () => {
it("resolveKeywords serves predicates AND clauses for SQL", async () => {
const { resolveKeywords } = await import("./sqlCompletion");
const resolved = resolveKeywords("sql", []) as SqlCompletionEntry[];
const names = resolved.map((k) => k.name);
expect(names).toContain("SELECT");
expect(names).toContain("GROUP BY");
expect(names).toContain("and"); // predicates still there
});
it("does not leak SQL clauses into non-SQL editors", async () => {
const { resolveKeywords } = await import("./sqlCompletion");
expect(resolveKeywords("json", [])).toEqual([]);
expect(resolveKeywords("promql", [])).toEqual([]);
});
it("still prefers caller-supplied keywords over the defaults", async () => {
const { resolveKeywords } = await import("./sqlCompletion");
const custom = [{ name: "host", label: "host", kind: "Field", insertText: "host" }] as any;
expect(resolveKeywords("sql", custom)).toBe(custom);
});
});
// ───────────────────────────────────────────────────────────────────────────
// D1 / N5 — column type in `detail`
//
// The type is already carried on every field object (Utf8, Int64, …) and was
// discarded when building keywords. It is exactly what belongs in the suggest
// widget's inline column.
// ───────────────────────────────────────────────────────────────────────────
describe("D1/N5 — field entries surface the column type", () => {
it("puts the column type in detail", () => {
expect(buildFieldEntry({ name: "code", type: "Int64" }).detail).toBe("Int64");
});
it("keeps the field name as label and insertText", () => {
const e = buildFieldEntry({ name: "k8s_pod_name", type: "Utf8" });
expect(e.label).toBe("k8s_pod_name");
expect(e.insertText).toBe("k8s_pod_name");
});
it("marks it kind Field", () => {
expect(buildFieldEntry({ name: "code", type: "Int64" }).kind).toBe("Field");
});
it("sorts fields ahead of functions and keywords", () => {
const field = buildFieldEntry({ name: "code", type: "Int64" });
const clause = SQL_CLAUSE_KEYWORDS[0];
expect(field.sortText! < clause.sortText!).toBe(true);
});
it("omits detail rather than inventing one when the type is unknown", () => {
expect(buildFieldEntry({ name: "code" } as any).detail).toBeUndefined();
});
it("does not mark a plain field name as a snippet", () => {
expect(buildFieldEntry({ name: "code", type: "Utf8" }).insertTextRules).toBeUndefined();
});
it("forwards the type through to monaco as detail", () => {
const [item] = build({ keywords: [buildFieldEntry({ name: "code", type: "Int64" })] });
expect(item.detail).toBe("Int64");
expect(item.kind).toBe(KINDS.Field);
});
});