refactor(desktop): organize lib modules by domain

This commit is contained in:
t8y2 committed 2026-07-04 13:53:52 +08:00
1 parent 90f0594e93
commit 7e7c4944e9
700 files changed
+1292 -1264

No files matched your search

@@ -0,0 +1,52 @@
import { Text } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache";
describe("executableStatementRangeCacheForDoc", () => {
it("reuses parsed executable statement ranges for the same document and database type", () => {
const doc = Text.of(["SELECT 1;", "SELECT 2;"]);
const parse = vi.fn<ExecutableStatementRangeParser>(() => [
{ from: 0, to: 8, sql: "SELECT 1" },
{ from: 10, to: 18, sql: "SELECT 2" },
]);
const first = executableStatementRangeCacheForDoc(null, doc, "mysql", parse);
const second = executableStatementRangeCacheForDoc(first, doc, "mysql", parse);
expect(second).toBe(first);
expect(parse).toHaveBeenCalledTimes(1);
expect(executableStatementRangeStartingAt(second, 10)?.sql).toBe("SELECT 2");
});
it("resolves the exact multi-line statement for a gutter run button", () => {
const doc = Text.of(["SELECT *", "FROM apis AS ap", "LIMIT 100;", "", "SELECT *", "FROM menus AS mn", "LIMIT 100;"]);
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
const secondStatementLine = doc.line(5);
expect(executableStatementRangeStartingAt(cache, secondStatementLine.from)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100");
});
it("rebuilds the cache when the document instance changes", () => {
const firstDoc = Text.of(["SELECT 1;"]);
const secondDoc = Text.of(["SELECT 1;"]);
const parse = vi.fn<ExecutableStatementRangeParser>(() => [{ from: 0, to: 8, sql: "SELECT 1" }]);
const first = executableStatementRangeCacheForDoc(null, firstDoc, "mysql", parse);
const second = executableStatementRangeCacheForDoc(first, secondDoc, "mysql", parse);
expect(second).not.toBe(first);
expect(parse).toHaveBeenCalledTimes(2);
});
it("rebuilds the cache when the database type changes", () => {
const doc = Text.of(["SELECT 1;"]);
const parse = vi.fn<ExecutableStatementRangeParser>(() => [{ from: 0, to: 8, sql: "SELECT 1" }]);
const mysql = executableStatementRangeCacheForDoc(null, doc, "mysql", parse);
const postgres = executableStatementRangeCacheForDoc(mysql, doc, "postgres", parse);
expect(postgres).not.toBe(mysql);
expect(parse).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext, type SqlCompletionColumn, type SqlCompletionProviderInput } from "@/lib/sql/sqlCompletion";
import { sqlCompletionContextFromSemantic, sqlSemanticLocalColumnsByTable } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { sqlFixtureCursor } from "@/lib/sql/semantic/fixtures";
import type { DatabaseType } from "@/types/database";
function mergeColumns(...maps: Array<Map<string, SqlCompletionColumn[]> | undefined>): Map<string, SqlCompletionColumn[]> {
const merged = new Map<string, SqlCompletionColumn[]>();
for (const map of maps) {
for (const [key, columns] of map ?? []) merged.set(key, columns);
}
return merged;
}
function semanticCompletion(markedSql: string, input: Partial<SqlCompletionProviderInput> = {}, options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" } = {}) {
const { sql, cursor } = sqlFixtureCursor(markedSql);
const model = buildSqlSemanticModel(sql, cursor, options);
const context = sqlCompletionContextFromSemantic(model, getSqlCompletionContext(sql, cursor));
const columnsByTable = mergeColumns(sqlSemanticLocalColumnsByTable(model), input.columnsByTable);
const items = buildSqlCompletionItemsFromContext(context, {
tables: input.tables ?? [],
objects: input.objects ?? [],
columnsByTable,
foreignKeysByTable: input.foreignKeysByTable,
schemas: input.schemas,
translations: input.translations,
snippets: input.snippets,
dialect: options.dialect,
databaseType: options.databaseType,
keywordCase: input.keywordCase,
autoAliasTables: input.autoAliasTables,
});
return { sql, cursor, model, context, items };
}
describe("semantic SQL completion candidates", () => {
it("keeps alias-qualified column completion scoped to one row source", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["users", ["id", "name", "email"].map((name) => ({ name, table: "users" }))],
["orders", ["id", "total"].map((name) => ({ name, table: "orders" }))],
]);
const { items } = semanticCompletion("SELECT * FROM users u JOIN orders o ON o.user_id = u.id WHERE u.|", { columnsByTable });
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id", "name", "email"]);
});
it("uses CTE projected columns without remote metadata", () => {
const { items, context } = semanticCompletion("WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT * FROM recent_orders ro WHERE ro.|");
expect(context.exclusiveColumnSuggestions).toBe(true);
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id", "total"]);
});
it("uses subquery projected columns without remote metadata", () => {
const { items } = semanticCompletion("SELECT * FROM (SELECT id, name AS user_name FROM users) sq WHERE sq.|");
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id", "user_name"]);
});
it("expands alias star from only the qualified row source", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
["users", ["id", "name"].map((name) => ({ name, table: "users" }))],
["orders", ["id", "total"].map((name) => ({ name, table: "orders" }))],
]);
const { context, items } = semanticCompletion("SELECT u.*| FROM users u JOIN orders o ON o.user_id = u.id", { columnsByTable });
const star = items.find((item) => item.label === "* \u2192 columns");
expect(context.qualifier).toBe("u");
expect(star?.apply).toBe("id, u.name");
});
it("generates collision-free table aliases from semantic row sources", () => {
const { items } = semanticCompletion("SELECT * FROM order_items oi JOIN ord|", {
tables: [{ name: "order_items", type: "table" }],
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi2");
});
it("preserves dialect-aware identifier quoting in apply text", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([["Order Details", [{ name: "User Name", table: "Order Details" }]]]);
const { items } = semanticCompletion('SELECT od."User| FROM "Order Details" od', { columnsByTable }, { databaseType: "postgres", dialect: "postgres" });
expect(items.find((item) => item.label === "User Name")?.apply).toBe('"User Name"');
});
});
@@ -0,0 +1,28 @@
# SQL Semantic Completion DBeaver References
This change uses the local DBeaver checkout at `/Users/skyler/VsCodeProjects/dbeaver` as the behavioral reference for semantic SQL completion.
Key files reviewed:
- `plugins/org.jkiss.dbeaver.ui.editors.sql/src/org/jkiss/dbeaver/ui/editors/sql/syntax/SQLCompletionProcessor.java`
- Selects semantic, legacy, or combined completion paths and handles asynchronous proposal jobs.
- `plugins/org.jkiss.dbeaver.model.sql/src/org/jkiss/dbeaver/model/sql/semantics/completion/SQLQueryCompletionContext.java`
- Builds completion proposals from syntax inspection, lexical scope, row-source context, and cursor offset.
- `plugins/org.jkiss.dbeaver.model.sql/src/org/jkiss/dbeaver/model/sql/semantics/context/SQLQueryRowsSourceContext.java`
- Tracks table sources, aliases, dynamic CTE sources, unresolved sources, and known-source collections.
- `plugins/org.jkiss.dbeaver.model.sql/src/org/jkiss/dbeaver/model/sql/semantics/completion/SQLQueryCompletionAnalyzer.java`
- Converts semantic completion items into editor proposals with replacement ranges, descriptions, images, and scoring.
DBX intentionally implements a smaller frontend semantic model first. The immediate goal is not DBeaver parser parity; it is to move cursor intent, row-source resolution, CTE/subquery handling, and fallback confidence into one reusable layer before routing completion, diagnostics, and navigation through it.
## Completion Assistant Field Audit
The semantic completion scopes added in this change fit the existing DBX completion assistant and item-builder fields:
- Table/schema/catalog lookup maps to `suggestTables`, `qualifier`, `qualifierParts`, `schemas`, and existing table metadata lookup methods.
- Routine/package lookup maps to `suggestRoutines`, `exclusiveRoutineSuggestions`, `qualifier`, and existing completion object lookup methods.
- Alias, CTE, subquery, INSERT target, UPDATE target, join, and star column lookup maps to `referencedTables`, `columns`, `insertTable`, `updateTarget`, `deleteTarget`, `onStar`, and `columnsByTable`.
- Projection aliases map to `prioritizeSelectAliases` and `selectAliases`.
- Fallback confidence maps to the existing legacy `getSqlCompletionContext()` path without requiring broad all-column scans.
No backend/API field gap was found for the current semantic scopes. The implementation therefore keeps the Java/Rust assistant protocol unchanged and limits new work to frontend semantic context, conversion, and tests.
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns } from "@/lib/sql/semantic/completion";
import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sql/semantic/fixtures";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
describe("sqlSemanticModel baseline fixtures", () => {
for (const fixture of SQL_SEMANTIC_BASELINE_FIXTURES) {
it(fixture.name, () => {
const { sql, cursor } = sqlFixtureCursor(fixture.sql);
const model = buildSqlSemanticModel(sql, cursor, { databaseType: fixture.databaseType });
const scope = sqlSemanticCompletionScope(model);
expect(model.statement.kind).toBe(fixture.expected.statementKind);
expect(model.cursorIntent.kind).toBe(fixture.expected.cursorKind);
expect(scope.kind).toBe(fixture.expected.completionScope);
expect(model.cursorIntent.prefix).toBe(fixture.expected.prefix);
expect(model.cursorIntent.qualifierParts).toEqual(fixture.expected.qualifierParts ?? []);
expect(model.cursorIntent.confidence).toBe(fixture.expected.confidence);
for (const expectedSource of fixture.expected.rowSources ?? []) {
const expectedObject = Object.fromEntries(Object.entries(expectedSource).filter(([, value]) => value !== undefined));
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining(expectedObject)]));
}
if (fixture.expected.completionLabels) {
const labels = [...sqlSemanticLocalColumnsByTable(model).values()].flat().map((column) => column.name);
expect(labels).toEqual(expect.arrayContaining(fixture.expected.completionLabels));
}
});
}
it("does not mix row sources from inactive statements", () => {
const { sql, cursor } = sqlFixtureCursor("select * from users u; select * from orders o where o.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources.some((source) => source.name === "orders")).toBe(true);
expect(model.rowSources.some((source) => source.name === "users")).toBe(false);
});
it("does not expose CTE body tables as outer query row sources", () => {
const { sql, cursor } = sqlFixtureCursor("WITH recent_orders AS (SELECT id FROM orders) SELECT * FROM recent_orders ro WHERE ro.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources.some((source) => source.name === "recent_orders")).toBe(true);
expect(model.rowSources.some((source) => source.name === "orders")).toBe(false);
});
it("does not expose subquery body tables as outer query row sources", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM (SELECT id FROM users) sq WHERE sq.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "sq", kind: "subquery" })]));
expect(model.rowSources.some((source) => source.name === "users")).toBe(false);
});
it("suppresses completion inside string literals without metadata scope", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT 'u.|' FROM users");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("suppressed");
expect(scope.useRemoteMetadata).toBe(false);
});
it("classifies table references after comma-separated table lists", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM users u, ord|");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("table");
expect(model.cursorIntent.prefix).toBe("ord");
expect(scope.kind).toBe("table");
});
it("classifies alias-qualified star with replacement range", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT u.*| FROM users u");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.cursorIntent.kind).toBe("star");
expect(model.cursorIntent.prefix).toBe("*");
expect(model.cursorIntent.qualifierParts).toEqual(["u"]);
expect(sql.slice(model.cursorIntent.replacementRange.start, model.cursorIntent.replacementRange.end)).toBe("*");
});
it("returns low-confidence keyword fallback for unknown SQL", () => {
const { sql, cursor } = sqlFixtureCursor("explain analyze |");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("keyword");
expect(model.cursorIntent.confidence).toBe("low");
expect(scope.useRemoteMetadata).toBe(false);
});
it("exposes PostgreSQL projection aliases in ORDER BY but not WHERE", () => {
const orderBy = sqlFixtureCursor("select total_amount as total from orders order by to|");
const where = sqlFixtureCursor("select total_amount as total from orders where to|");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(orderBy.sql, orderBy.cursor, { databaseType: "postgres" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(where.sql, where.cursor, { databaseType: "postgres" })).map((column) => column.name)).not.toContain("total");
});
it("exposes MySQL projection aliases in GROUP BY and HAVING", () => {
const groupBy = sqlFixtureCursor("select total_amount as total from orders group by to|");
const having = sqlFixtureCursor("select total_amount as total from orders having to|");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(having.sql, having.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
});
it("keeps dialect-specific identifier normalization and qualifier scopes", () => {
const sqlServer = sqlFixtureCursor("SELECT * FROM [dbo].[Users] u WHERE u.|");
const postgres = sqlFixtureCursor('SELECT total AS "Order Total" FROM "Sales"."Orders" o ORDER BY "Order|');
const mysql = sqlFixtureCursor("SELECT * FROM `analytics`.`events` e WHERE e.|");
const sqlite = sqlFixtureCursor("SELECT * FROM main.users u WHERE u.|");
expect(buildSqlSemanticModel(sqlServer.sql, sqlServer.cursor, { databaseType: "sqlserver" }).rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["dbo"], alias: "u" }));
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(postgres.sql, postgres.cursor, { databaseType: "postgres" })).map((column) => column.name)).toContain("Order Total");
expect(buildSqlSemanticModel(mysql.sql, mysql.cursor, { databaseType: "mysql" }).rowSources[0]).toEqual(expect.objectContaining({ name: "events", qualifierParts: ["analytics"], alias: "e" }));
expect(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" }).rowSources[0]).toEqual(expect.objectContaining({ name: "users", qualifierParts: ["main"], alias: "u" }));
});
it("covers SQL Server case-insensitive bracket and multi-part qualifier contexts", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM [ServerOne].[AppDb].[dbo].[Users] U WHERE u.na|");
const model = buildSqlSemanticModel(sql, cursor, { databaseType: "sqlserver" });
expect(model.rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["ServerOne", "AppDb", "dbo"], alias: "U" }));
expect(model.cursorIntent.kind).toBe("alias_column");
expect(model.cursorIntent.qualifierParts).toEqual(["u"]);
});
it("covers PostgreSQL lower-case folding with CTEs and ORDER BY projection aliases", () => {
const { sql, cursor } = sqlFixtureCursor("WITH RecentOrders AS (SELECT id, total FROM orders) SELECT total AS total_alias FROM RecentOrders ro ORDER BY total_|");
const model = buildSqlSemanticModel(sql, cursor, { databaseType: "postgres" });
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recentorders", alias: "ro", columns: ["id", "total"] })]));
expect(sqlSemanticProjectionAliasColumns(model).map((column) => column.name)).toContain("total_alias");
});
it("covers MySQL database-qualified backticks and projection alias visibility", () => {
const groupBy = sqlFixtureCursor("SELECT amount AS total FROM `analytics`.`events` e GROUP BY to|");
const where = sqlFixtureCursor("SELECT amount AS total FROM `analytics`.`events` e WHERE to|");
expect(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" }).rowSources[0]).toEqual(expect.objectContaining({ name: "events", qualifierParts: ["analytics"], alias: "e" }));
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(where.sql, where.cursor, { databaseType: "mysql" })).map((column) => column.name)).not.toContain("total");
});
it("covers SQLite and DuckDB schema-light local row-source behavior", () => {
const sqlite = sqlFixtureCursor("SELECT * FROM main.users u WHERE u.|");
const duckdb = sqlFixtureCursor("SELECT * FROM read_csv('users.csv') csv WHERE csv.|");
expect(sqlSemanticCompletionScope(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" })).useRemoteMetadata).toBe(true);
expect(buildSqlSemanticModel(duckdb.sql, duckdb.cursor, { databaseType: "duckdb" }).rowSources[0]).toEqual(expect.objectContaining({ kind: "table_function", name: "csv", alias: "csv" }));
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildSqlSemanticDiagnostics } from "@/lib/sql/semantic/diagnostics";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { sqlFixtureCursor } from "@/lib/sql/semantic/fixtures";
import { resolveSqlSemanticNavigationTarget, mergeSqlSemanticReferenceAnalysis, sqlSemanticCompletionReferenceTables, sqlSemanticTableReferences } from "@/lib/sql/semantic/references";
import { splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation";
import type { SqlReferenceAnalysis } from "@/types/database";
const span = (startColumn: number, endColumn: number) => ({
start_line: 1,
start_column: startColumn,
end_line: 1,
end_column: endColumn,
});
describe("sqlSemanticReferences shared consumers", () => {
it("feeds CTE projected columns to diagnostics without physical table metadata", () => {
const { sql, cursor } = sqlFixtureCursor("WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT ro.missing FROM recent_orders ro WHERE ro.|");
const model = buildSqlSemanticModel(sql, cursor);
const analysis: SqlReferenceAnalysis = {
tables: [],
columns: [{ name: "missing", qualifier: "ro", span: span(sql.indexOf("missing") + 1, sql.indexOf("missing") + "missing".length) }],
};
const diagnostics = buildSqlSemanticDiagnostics(mergeSqlSemanticReferenceAnalysis(analysis, model), {
tables: [],
columnsByTable: new Map(),
sql,
});
expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(["Unknown column ro.missing"]);
});
it("resolves navigation targets from subquery aliases and projected columns", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT sq.user_| FROM (SELECT id, name AS user_name FROM users) sq");
const model = buildSqlSemanticModel(sql, cursor);
const target = resolveSqlSemanticNavigationTarget(model, splitQualifiedIdentifier("sq.user_name"));
expect(target).toEqual(expect.objectContaining({ name: "sq", alias: "sq", columns: ["id", "user_name"] }));
expect(target?.source.kind).toBe("subquery");
});
it("keeps completion diagnostics and navigation row-source resolution consistent", () => {
const { sql, cursor } = sqlFixtureCursor("WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT * FROM recent_orders ro WHERE ro.|");
const model = buildSqlSemanticModel(sql, cursor);
const completionRefs = sqlSemanticCompletionReferenceTables(model);
const diagnosticRefs = sqlSemanticTableReferences(model);
const navigationTarget = resolveSqlSemanticNavigationTarget(model, ["ro", "total"]);
expect(completionRefs).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recent_orders", alias: "ro", columns: ["id", "total"] })]));
expect(diagnosticRefs).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recent_orders", alias: "ro", columns: ["id", "total"] })]));
expect(navigationTarget).toEqual(expect.objectContaining({ name: "recent_orders", alias: "ro", columns: ["id", "total"] }));
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { findActiveSqlStatementSpan, isSuppressedSqlSemanticContext, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
describe("sqlSemanticTokens", () => {
it("tokenizes comments, strings, quoted identifiers, brackets, and backticks", () => {
const sql = "select [User Name], `order`, \"Mixed\" from users -- comment\nwhere name = 'it''s ok'";
const tokens = tokenizeSqlSemantic(sql);
expect(tokens.some((token) => token.kind === "quoted_identifier" && unquoteSqlSemanticIdentifier(token) === "User Name")).toBe(true);
expect(tokens.some((token) => token.kind === "quoted_identifier" && unquoteSqlSemanticIdentifier(token) === "order")).toBe(true);
expect(tokens.some((token) => token.kind === "quoted_identifier" && unquoteSqlSemanticIdentifier(token) === "Mixed")).toBe(true);
expect(tokens.some((token) => token.kind === "comment" && token.text.includes("comment"))).toBe(true);
expect(tokens.some((token) => token.kind === "string" && token.text === "'it''s ok'")).toBe(true);
});
it("marks comments and string literals as suppressed contexts", () => {
const sql = "select * from users -- user.";
const tokens = tokenizeSqlSemantic(sql);
expect(isSuppressedSqlSemanticContext(tokens, sql.length)).toBe(true);
expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false);
});
it("finds active statement spans across semicolon-separated scripts", () => {
const sql = "select * from users;\nselect * from orders where id = 1;";
const cursor = sql.indexOf("orders");
const span = findActiveSqlStatementSpan(sql, tokenizeSqlSemantic(sql), cursor);
expect(sql.slice(span.start, span.end)).toBe("select * from orders where id = 1");
});
});
@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import { buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
describe("sqlCompletion quoted schema qualifiers", () => {
it("parses quoted PostgreSQL schema names before a dot", () => {
const sql = 'SELECT *\nFROM "order-management".';
const context = getSqlCompletionContext(sql, sql.length);
expect(context.qualifier).toBe("order-management");
expect(context.prefix).toBe("");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveColumnSuggestions).toBe(false);
});
it("suggests tables after a quoted schema qualifier", () => {
const sql = 'SELECT *\nFROM "order-management".';
const items = buildSqlCompletionItems(sql, sql.length, {
dialect: "postgres",
tables: [
{ name: "orders", schema: "order-management", type: "table" },
{ name: "shipments", schema: "order-management", type: "table" },
],
columnsByTable: new Map(),
});
expect(items.some((item) => item.label === "orders" && item.type === "table")).toBe(true);
expect(items.some((item) => item.label === "shipments" && item.type === "table")).toBe(true);
});
});
describe("sqlCompletion table aliases", () => {
it("applies generated aliases to table completions when enabled", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
});
it("omits AS from Oracle table alias completions", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
databaseType: "oracle",
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items oi");
});
it("keeps plain table completions when generated aliases are disabled", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: false,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items");
});
it("omits AS from Oracle alias suggestions", () => {
const sql = "SELECT * FROM order_items ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
databaseType: "oracle",
});
const alias = items.find((item) => item.type === "snippet" && item.detail === "alias for order_items");
expect(alias?.apply).toBe("oi ");
});
it("uses a numbered alias when the generated table alias already exists", () => {
const sql = "SELECT * FROM order_items oi JOIN ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi2");
});
it("applies generated aliases in comma-separated FROM table lists", () => {
const sql = "SELECT * FROM users u, ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
});
it("does not apply generated aliases to non-query table completions", () => {
const sql = "INSERT INTO ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items");
});
});
describe("sqlCompletion scoped context classification", () => {
it("classifies JOIN table contexts", () => {
const sql = "SELECT * FROM users u JOIN ";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("join");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
});
it("classifies alias-qualified column contexts", () => {
const sql = "SELECT * FROM users u WHERE u.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("alias_column");
expect(context.qualifier).toBe("u");
expect(context.suggestColumns).toBe(true);
});
it("classifies unqualified WHERE field input as column context", () => {
const sql = "SELECT * FROM A1User WHERE userc";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.prefix).toBe("userc");
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "A1User" })]));
expect(context.suggestColumns).toBe(true);
expect(context.suggestRoutines).toBe(false);
});
it("classifies CALL routine contexts", () => {
const sql = "CALL usp_";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("exec");
expect(context.suggestRoutines).toBe(true);
expect(context.exclusiveRoutineSuggestions).toBe(true);
});
it("classifies INSERT column-list contexts", () => {
const sql = "INSERT INTO dbo.Users (";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.insertSchema).toBe("dbo");
expect(context.insertTable).toBe("users");
expect(context.exclusiveColumnSuggestions).toBe(true);
});
it("classifies UPDATE SET column contexts", () => {
const sql = "UPDATE dbo.Users SET ";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.updateTarget).toEqual({ schema: "dbo", table: "Users" });
expect(context.suggestColumns).toBe(true);
});
it("extracts statement-local table aliases", () => {
const sql = "SELECT * FROM dbo.Users u JOIN Orders AS o ON o.user_id = u.id WHERE u.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "dbo", name: "Users", alias: "u" }), expect.objectContaining({ name: "Orders", alias: "o" })]));
});
it("treats schema-qualified table prefixes in FROM as table completion input", () => {
const sql = "SELECT * FROM dws_game_sdk_base.di";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.qualifier).toBe("dws_game_sdk_base");
expect(context.prefix).toBe("di");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
expect(context.suggestColumns).toBe(true);
});
it("exposes CTEs as table-like referenced tables", () => {
const sql = "WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT * FROM recent_orders ro WHERE ro.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recent_orders", columns: ["id", "total"] }), expect.objectContaining({ name: "recent_orders", alias: "ro" })]));
});
it("extracts subquery aliases and projected columns", () => {
const sql = "SELECT * FROM (SELECT id, name AS user_name FROM users) sq WHERE sq.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "sq", alias: "sq", columns: ["id", "user_name"] })]));
});
});
describe("sqlCompletion scoped metadata ranking", () => {
it("ranks exact and prefix table matches ahead of contains/fuzzy matches", () => {
const sql = "SELECT * FROM Temp";
const items = buildSqlCompletionItems(sql, sql.length, {
dialect: "sqlserver",
tables: [
{ name: "ArchiveTempTable", schema: "dbo", type: "table" },
{ name: "TempAudit", schema: "dbo", type: "table" },
{ name: "Temp", schema: "dbo", type: "table" },
{ name: "Template", schema: "dbo", type: "table" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(items.map((item) => item.label).slice(0, 3)).toEqual(["Temp", "Template", "TempAudit"]);
expect(items.some((item) => item.label === "ArchiveTempTable")).toBe(true);
});
it("keeps large table catalogs bounded", () => {
const tables = Array.from({ length: 500 }, (_, index) => ({ name: `TempTable_${String(index).padStart(3, "0")}`, schema: "dbo", type: "table" as const }));
const sql = "SELECT * FROM Temp";
const items = buildSqlCompletionItems(sql, sql.length, { dialect: "sqlserver", tables, columnsByTable: new Map() }).filter((item) => item.type === "table");
expect(items.length).toBeLessThanOrEqual(200);
expect(items[0]?.label).toBe("TempTable_000");
});
});
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { buildSnippetItemsForTest } from "@/lib/sql/sqlCompletion";
import type { SqlSnippet } from "@/types/database";
const TEST_SNIPPETS: SqlSnippet[] = [
{ id: "1", label: "select all", prefix: "sel", body: "SELECT *\nFROM my_table;" },
{ id: "2", label: "insert row", prefix: "ins", body: "INSERT INTO my_table VALUES (1);" },
];
const BUILTIN_SELECT: SqlSnippet = { id: "builtin-sel", label: "select *", prefix: "sel", body: "SELECT *\nFROM table\nLIMIT 100;" };
const BUILTIN_CREATE_TABLE: SqlSnippet = { id: "builtin-ct", label: "create table", prefix: "ct", body: "CREATE TABLE table (\n column type\n);" };
const BUILTIN_UPDATE: SqlSnippet = { id: "builtin-upd", label: "update set", prefix: "upd", body: "UPDATE table\nSET column = value\nWHERE condition;" };
const BUILTIN_CTE: SqlSnippet = { id: "builtin-cte", label: "common table expression", prefix: "cte", body: "WITH name AS (\n SELECT columns\n FROM table\n)\nSELECT *\nFROM name;" };
const BUILTIN_INSERT: SqlSnippet = { id: "builtin-ins", label: "insert into", prefix: "ins", body: "INSERT INTO table (columns)\nVALUES (values);" };
const BUILTIN_JOIN: SqlSnippet = { id: "builtin-join", label: "join", prefix: "join", body: "JOIN table ON left_column = right_column" };
const BUILTIN_CASE: SqlSnippet = { id: "builtin-case", label: "case when", prefix: "case", body: "CASE\n WHEN condition THEN value\n ELSE default\nEND" };
const BUILTIN_CREATE_INDEX: SqlSnippet = { id: "builtin-ci", label: "create index", prefix: "ci", body: "CREATE INDEX idx_name\nON table (column);" };
describe("buildSnippetItems", () => {
it("returns matching snippet by prefix", () => {
const items = buildSnippetItemsForTest("sel", TEST_SNIPPETS);
expect(items).toHaveLength(1);
expect(items[0].label).toBe("select all");
expect(items[0].detail).toBe("SELECT *\nFROM my_table;");
expect(items[0].apply).toBe("SELECT *\nFROM my_table;");
});
it("uses CodeMirror placeholders for the built-in select snippet", () => {
const items = buildSnippetItemsForTest("sel", [BUILTIN_SELECT]);
expect(items[0].detail).toBe("SELECT *\nFROM table\nLIMIT 100;");
expect(items[0].apply).toBe("SELECT *\nFROM ${table}\nLIMIT 100;");
});
it("replaces placeholder words but preserves uppercase SQL keywords", () => {
const items = buildSnippetItemsForTest("ct", [BUILTIN_CREATE_TABLE]);
expect(items[0].apply).toBe("CREATE TABLE ${table} (\n ${column} ${type}\n);");
});
it("adds placeholders across built-in update snippets", () => {
const items = buildSnippetItemsForTest("upd", [BUILTIN_UPDATE]);
expect(items[0].apply).toBe("UPDATE ${table}\nSET ${column} = ${value}\nWHERE ${condition};");
});
it("keeps keyword casing separate from editable placeholders", () => {
const items = buildSnippetItemsForTest("sel", [BUILTIN_SELECT], "lower");
expect(items[0].detail).toBe("select *\nfrom table\nlimit 100;");
expect(items[0].apply).toBe("select *\nfrom ${table}\nlimit 100;");
});
it("determines placeholders from original casing even when keywords are lowercased", () => {
const items = buildSnippetItemsForTest("ct", [BUILTIN_CREATE_TABLE], "lower");
expect(items[0].detail).toBe("create table table (\n column type\n);");
expect(items[0].apply).toBe("create table ${table} (\n ${column} ${type}\n);");
});
it("wraps repeated placeholder words in CTE snippets", () => {
const items = buildSnippetItemsForTest("cte", [BUILTIN_CTE]);
expect(items[0].apply).toBe("WITH ${name} AS (\n SELECT ${columns}\n FROM ${table}\n)\nSELECT *\nFROM ${name};");
});
it.each([
[BUILTIN_INSERT, "INSERT INTO ${table} (${columns})\nVALUES (${values});"],
[BUILTIN_JOIN, "JOIN ${table} ON ${left_column} = ${right_column}"],
[BUILTIN_CASE, "CASE\n WHEN ${condition} THEN ${value}\n ELSE ${default}\nEND"],
[BUILTIN_CREATE_INDEX, "CREATE INDEX ${idx_name}\nON ${table} (${column});"],
])("adds placeholders for built-in %s snippet", (snippet, expectedApply) => {
const items = buildSnippetItemsForTest(snippet.prefix, [snippet]);
expect(items[0].apply).toBe(expectedApply);
});
it("returns matching snippet by label substring", () => {
const items = buildSnippetItemsForTest("select", TEST_SNIPPETS);
expect(items).toHaveLength(1);
expect(items[0].label).toBe("select all");
});
it("does not keep matching a renamed snippet by its old short label prefix", () => {
const items = buildSnippetItemsForTest("sel", [{ id: "1", label: "select all", prefix: "fff", body: "SELECT *\nFROM my_table;" }]);
expect(items).toEqual([]);
});
it("still matches a renamed snippet by label when typing a longer descriptive query", () => {
const items = buildSnippetItemsForTest("select", [{ id: "1", label: "select all", prefix: "fff", body: "SELECT *\nFROM my_table;" }]);
expect(items).toHaveLength(1);
expect(items[0].label).toBe("select all");
});
it("returns empty for no match", () => {
const items = buildSnippetItemsForTest("zzz", TEST_SNIPPETS);
expect(items).toEqual([]);
});
it("returns empty for empty prefix", () => {
const items = buildSnippetItemsForTest("", TEST_SNIPPETS);
expect(items).toEqual([]);
});
});
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
describe("resolveExecutableSql", () => {
it("uses selected SQL before cursor-mode resolution", () => {
const sql = "select 1;\n\nselect 2;";
const selectedSql = " select 2; ";
const cursorAfterFirstSemicolon = sql.indexOf(";") + 1;
expect(resolveExecutableSql(sql, selectedSql, { mode: "current", cursorPos: cursorAfterFirstSemicolon })).toBe("select 2;");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { formatSqlForDisplay, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
describe("sqlFormatter", () => {
it("maps PostgreSQL-compatible database types to the postgres formatter dialect", () => {
for (const dbType of ["postgres", "kwdb", "gaussdb", "opengauss", "questdb", "kingbase", "highgo", "vastbase", "redshift"]) {
expect(sqlFormatDialectForDbType(dbType)).toBe("postgres");
}
});
it("maps SQLite-compatible database types to the sqlite formatter dialect", () => {
for (const dbType of ["sqlite", "rqlite", "turso"]) {
expect(sqlFormatDialectForDbType(dbType)).toBe("sqlite");
}
});
it("falls back to the postgres formatter when the generic dialect cannot parse SQL", async () => {
const formatted = await formatSqlText("SELECT 1::int AS id;", "generic");
expect(formatted).toContain("1::int");
expect(formatted).toContain("AS id");
});
it("returns the original SQL for display when formatting fails", async () => {
const oversizedSql = "x".repeat(MAX_SQL_FORMAT_CHARS + 1);
await expect(formatSqlText(oversizedSql, "postgres")).rejects.toThrow("SQL is too large to format safely.");
await expect(formatSqlForDisplay(oversizedSql, "postgres")).resolves.toBe(oversizedSql);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SQL_FORMATTER_SETTINGS, parseSqlFormatterConfig, serializeSqlFormatterConfig } from "@/lib/sql/sqlFormatterConfig";
describe("sqlFormatterConfig shortcut storage", () => {
it("does not serialize JSON editor shortcut settings", () => {
const config = JSON.parse(serializeSqlFormatterConfig(DEFAULT_SQL_FORMATTER_SETTINGS));
expect(config.editor).toBeUndefined();
});
it("ignores legacy JSON editor shortcut settings on import", () => {
const result = parseSqlFormatterConfig(
JSON.stringify({
version: 1,
formatter: "sql-formatter",
options: {},
editor: {
scope: "legacyJsonEditorScope",
shortcuts: [{ id: "unknownLegacyShortcut", enabled: "yes" }],
},
}),
);
expect(result.ok).toBe(true);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { createSafeSqlHighlighter, escapeHtml } from "@/lib/sql/sqlHighlighter";
describe("escapeHtml", () => {
it("escapes SQL text for v-html fallback rendering", () => {
expect(escapeHtml(`SELECT '<tag>' AS "name" & col`)).toBe("SELECT &#39;&lt;tag&gt;&#39; AS &quot;name&quot; &amp; col");
});
});
describe("createSafeSqlHighlighter", () => {
it("falls back to escaped SQL when Shiki highlighting fails", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const highlighter = createSafeSqlHighlighter(
{
codeToHtml: () => {
throw new SyntaxError("Invalid regular expression: invalid group specifier name");
},
},
{ appearance: () => "light" },
);
expect(highlighter(`ALTER TABLE t ADD "<x>" INT;`)).toBe("ALTER TABLE t ADD &quot;&lt;x&gt;&quot; INT;");
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { extractIdentifierAt, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation";
describe("extractIdentifierAt", () => {
it("extracts unquoted qualified identifiers", () => {
const sql = "select * from MAAC00.Accounts";
expect(extractIdentifierAt(sql, sql.indexOf("Accounts"))).toBe("MAAC00.Accounts");
});
it("extracts backtick-quoted qualified identifiers", () => {
const sql = "select * from `MAAC00`.Accounts";
expect(extractIdentifierAt(sql, sql.indexOf("Accounts"))).toBe("MAAC00.Accounts");
expect(extractIdentifierAt(sql, sql.indexOf("MAAC00"))).toBe("MAAC00.Accounts");
});
it("extracts double-quoted qualified identifiers", () => {
const sql = 'select * from "MAAC00"."Accounts"';
expect(extractIdentifierAt(sql, sql.indexOf("Accounts"))).toBe("MAAC00.Accounts");
});
});
describe("splitQualifiedIdentifier", () => {
it("splits quoted and multi-part identifiers", () => {
expect(splitQualifiedIdentifier('catalog."MAAC00".Accounts')).toEqual(["catalog", "MAAC00", "Accounts"]);
expect(splitQualifiedIdentifier("`MAAC00`.Accounts")).toEqual(["MAAC00", "Accounts"]);
});
});
describe("matchTable", () => {
it("matches schema-qualified table identifiers", () => {
const table = { schema: "MAAC00", name: "Accounts" };
expect(matchTable("maac00.accounts", [table])).toBe(table);
});
it("matches catalog.schema.table identifiers against schema-scoped tables", () => {
const table = { schema: "MAAC00", name: "Accounts" };
expect(matchTable("catalog.maac00.accounts", [table])).toBe(table);
});
it("matches quoted schema-qualified table identifiers", () => {
const table = { schema: "MAAC00", name: "Accounts" };
expect(matchTable("`MAAC00`.Accounts", [table])).toBe(table);
});
it("does not treat non-schema qualifiers as table matches", () => {
expect(matchTable("u.users", [{ schema: "public", name: "users" }])).toBeNull();
});
});
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadSqlParameterHistory, MAX_SQL_PARAMETER_HISTORY, rememberSqlParameterValue } from "@/lib/sql/sqlParameterHistory";
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
});
});
describe("sqlParameterHistory", () => {
it("stores recent values by parameter name", () => {
rememberSqlParameterValue("start_date", { kind: "string", value: "2026-01-01" });
rememberSqlParameterValue("start_date", { kind: "string", value: "2026-01-02" });
expect(loadSqlParameterHistory("start_date")).toEqual([
{ kind: "string", value: "2026-01-02" },
{ kind: "string", value: "2026-01-01" },
]);
});
it("deduplicates by type and value and moves the latest value to the front", () => {
rememberSqlParameterValue("min_id", { kind: "number", value: "1" });
rememberSqlParameterValue("min_id", { kind: "number", value: "2" });
rememberSqlParameterValue("min_id", { kind: "number", value: "1" });
expect(loadSqlParameterHistory("min_id")).toEqual([
{ kind: "number", value: "1" },
{ kind: "number", value: "2" },
]);
});
it("matches parameter names case-insensitively and limits history size", () => {
for (let i = 0; i < MAX_SQL_PARAMETER_HISTORY + 2; i += 1) {
rememberSqlParameterValue("UserId", { kind: "number", value: String(i) });
}
const history = loadSqlParameterHistory("userId");
expect(history).toHaveLength(MAX_SQL_PARAMETER_HISTORY);
expect(history[0]).toEqual({ kind: "number", value: String(MAX_SQL_PARAMETER_HISTORY + 1) });
expect(history.at(-1)).toEqual({ kind: "number", value: "2" });
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { extractSqlParameters, sqlParameterLiteral, substituteSqlParameters } from "@/lib/sql/sqlParameters";
describe("extractSqlParameters", () => {
it("extracts unique template parameters in order", () => {
const sql = "select * from t where pt_dt between ${start_date} and ${end_date} or pt_dt = ${start_date}";
expect(extractSqlParameters(sql)).toEqual(["start_date", "end_date"]);
});
it("ignores placeholders inside strings, quoted identifiers, and comments", () => {
const sql = `
select '\${quoted}' as a, "\${identifier}" as b, \`\${mysql_identifier}\`
-- \${line_comment}
# \${hash_comment}
/* \${block_comment} */
from t
where id = \${id}
`;
expect(extractSqlParameters(sql)).toEqual(["id"]);
});
it("ignores placeholders inside Postgres dollar-quoted strings", () => {
const sql = "select $$ ${body_param} $$, $tag$ ${tag_param} $tag$, ${real_param}";
expect(extractSqlParameters(sql)).toEqual(["real_param"]);
});
});
describe("substituteSqlParameters", () => {
it("replaces placeholders with SQL literals", () => {
const sql = "select * from t where dt >= ${start_date} and amount > ${amount} and enabled = ${enabled}";
expect(
substituteSqlParameters(sql, {
start_date: { kind: "string", value: "2026-06-26" },
amount: { kind: "number", value: "100.50" },
enabled: { kind: "boolean", value: "true" },
}),
).toBe("select * from t where dt >= '2026-06-26' and amount > 100.50 and enabled = TRUE");
});
it("escapes string values and supports null and raw SQL", () => {
const sql = "select ${name}, ${empty_value}, ${expression}";
expect(
substituteSqlParameters(sql, {
name: { kind: "string", value: "O'Reilly" },
empty_value: { kind: "null", value: "" },
expression: { kind: "raw", value: "current_date" },
}),
).toBe("select 'O''Reilly', NULL, current_date");
});
});
describe("sqlParameterLiteral", () => {
it("falls back to quoted strings for invalid boolean input", () => {
expect(sqlParameterLiteral({ kind: "boolean", value: "maybe" })).toBe("'maybe'");
});
});
@@ -0,0 +1,527 @@
import { describe, expect, it } from "vitest";
import { buildExecutionCandidates, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges";
function indexOf(sql: string, needle: string, occurrence = 1): number {
let from = 0;
let idx = -1;
for (let i = 0; i < occurrence; i += 1) {
idx = sql.indexOf(needle, from);
if (idx === -1) return -1;
from = idx + needle.length;
}
return idx;
}
function rangeSqlTexts(ranges: Array<{ sql: string }>): string[] {
return ranges.map((range) => range.sql.trim());
}
function candidateKinds(candidates: Array<{ kind: string }>): string[] {
return candidates.map((candidate) => candidate.kind);
}
function candidateLabels(candidates: Array<{ label: string }>): string[] {
return candidates.map((candidate) => candidate.label);
}
function candidateSummaries(candidates: Array<{ kind: string; sql: string }>): string[] {
return candidates.map((candidate) => `${candidate.kind}:${candidate.sql.trim()}`);
}
const oraclePlSqlFixture = `DECLARE
v_order_count NUMBER;
BEGIN
SELECT COUNT(*) INTO v_order_count
FROM "DBX_TEST"."ORDERS_10K";
IF v_order_count = 0 THEN
INSERT INTO "DBX_TEST"."STORES"
("ID", "STORE_CODE", "STORE_NAME", "CITY", "OPENED_AT")
SELECT 10001, 'TEST_STORE_001', '测试门店', '上海', SYSDATE
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM "DBX_TEST"."STORES" WHERE "ID" = 10001
);
INSERT INTO "DBX_TEST"."PRODUCTS"
("ID", "SKU", "PRODUCT_NAME", "CATEGORY", "PRICE")
SELECT 10001, 'TEST_SKU_001', '测试商品', '测试分类', 99.90
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM "DBX_TEST"."PRODUCTS" WHERE "ID" = 10001
);
INSERT INTO "DBX_TEST"."ORDERS_10K"
("ID", "ORDER_NO", "STORE_ID", "PRODUCT_ID", "CUSTOMER_NAME", "QUANTITY", "AMOUNT", "ORDER_STATUS", "CREATED_AT")
SELECT 10001, 'TEST_ORDER_001', 10001, 10001, '测试客户', 2, 199.80, 'PAID', SYSDATE
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM "DBX_TEST"."ORDERS_10K" WHERE "ORDER_NO" = 'TEST_ORDER_001'
);
COMMIT;
END IF;
END;
/
SELECT 1;`;
describe("splitSqlStatementRanges", () => {
it("splits multiple top-level statements", () => {
const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"]);
});
it("keeps a trailing statement without a semicolon", () => {
const sql = "SELECT 1;\nSELECT 2";
const ranges = splitSqlStatementRanges(sql);
expect(rangeSqlTexts(ranges)).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons inside single-quoted strings", () => {
const sql = "INSERT INTO t VALUES ('a;b;c');\nSELECT 1";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["INSERT INTO t VALUES ('a;b;c')", "SELECT 1"]);
});
it("handles doubled single quotes as escaped quotes", () => {
const sql = "SELECT 'it''s; ok';\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 'it''s; ok'", "SELECT 2"]);
});
it("ignores semicolons inside double-quoted identifiers", () => {
const sql = 'SELECT "a;b";\nSELECT 2';
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(['SELECT "a;b"', "SELECT 2"]);
});
it("ignores semicolons inside backtick identifiers (MySQL)", () => {
const sql = "SELECT `a;b`;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT `a;b`", "SELECT 2"]);
});
it("ignores semicolons inside bracket identifiers (SQL Server)", () => {
const sql = "SELECT [a;b];\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT [a;b]", "SELECT 2"]);
});
it("ignores semicolons in line comments", () => {
const sql = "SELECT 1 -- a; b\n;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons in hash line comments", () => {
const sql = "SELECT 1 # a; b\n;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons in block comments", () => {
const sql = "SELECT /* a; b */ 1;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT /* a; b */ 1", "SELECT 2"]);
});
it("handles Postgres dollar quoting", () => {
const sql = "SELECT $$ a; b $$;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT $$ a; b $$", "SELECT 2"]);
});
it("skips MySQL delimiter commands and empty custom delimiter statements", () => {
const sql = "select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["select COUNT(1) FROM your_table", "select COUNT(1) FROM your_table;"]);
});
it("keeps Oracle PL/SQL blocks together and treats slash lines as delimiters", () => {
const ranges = splitSqlStatementRanges(oraclePlSqlFixture, "oracle");
expect(rangeSqlTexts(ranges)).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]);
expect(ranges[0].sql).toContain("v_order_count NUMBER;");
expect(ranges[0].sql).toContain("END;");
expect(ranges[0].sql).not.toContain("\n/");
});
});
describe("statementRangeAtCursor", () => {
it("returns the first statement when the cursor is inside it", () => {
const sql = "SELECT 1;\nSELECT 2;";
const pos = indexOf(sql, "1");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("returns the second statement when the cursor is inside it", () => {
const sql = "SELECT 1;\nSELECT 2;";
const pos = indexOf(sql, "2");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns the statement when the cursor is in indentation before it", () => {
const sql = "SELECT 1;\n SELECT 2;";
const indentationPos = sql.indexOf(" SELECT 2") + 2;
const range = statementRangeAtCursor(sql, indentationPos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns the previous statement when the cursor is in same-line whitespace after its semicolon", () => {
const sql = "SELECT 1; SELECT 2;";
const gapPos = sql.indexOf(";") + 2;
const range = statementRangeAtCursor(sql, gapPos);
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("returns the previous statement when the cursor is just after its semicolon before a later statement", () => {
const sql = "SELECT *\nFROM system_dept;\n\nSELECT *\nFROM sys;";
const gapPos = sql.indexOf(";") + 1;
const range = statementRangeAtCursor(sql, gapPos);
expect(range?.sql.trim()).toBe("SELECT *\nFROM system_dept");
});
it("keeps a semicolon-line-end cursor on the current multi-line statement", () => {
const sql = "SELECT *\nFROM system_dept;";
const gapPos = sql.indexOf(";") + 1;
const range = statementRangeAtCursor(sql, gapPos);
expect(range?.sql.trim()).toBe("SELECT *\nFROM system_dept");
});
it("returns the next same-line statement when the cursor is inside it", () => {
const sql = "SELECT 1; SELECT 2;";
const pos = indexOf(sql, "SELECT 2") + 1;
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns a statement even without a trailing semicolon", () => {
const sql = "SELECT 1";
const pos = indexOf(sql, "1");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("stops at the next top-level statement start when the cursor statement has no semicolon", () => {
const sql = "SELECT 1\nSELECT 2;\nSELECT 3;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("returns the later top-level statement when earlier statements are missing semicolons", () => {
const sql = "SELECT 1\nSELECT 2;\nSELECT 3;";
const range = statementRangeAtCursor(sql, indexOf(sql, "2"));
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("keeps a multi-line select together when continuation lines do not start statements", () => {
const sql = "SELECT id,\n name\nFROM users\nWHERE active = 1\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "name"));
expect(range?.sql.trim()).toBe("SELECT id,\n name\nFROM users\nWHERE active = 1");
});
it("keeps a CTE main query with its WITH statement", () => {
const sql = "WITH active_users AS (\n SELECT * FROM users\n)\nSELECT * FROM active_users\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "active_users", 2));
expect(range?.sql.trim()).toBe("WITH active_users AS (\n SELECT * FROM users\n)\nSELECT * FROM active_users");
});
it("keeps update assignments with the UPDATE statement", () => {
const sql = "UPDATE users\nSET name = 'a'\nWHERE id = 1\nSELECT * FROM users;";
const range = statementRangeAtCursor(sql, indexOf(sql, "name"));
expect(range?.sql.trim()).toBe("UPDATE users\nSET name = 'a'\nWHERE id = 1");
});
it("keeps MySQL ALTER TABLE column comments with the column definition", () => {
const sql =
"ALTER TABLE `yb_course_order`\n ADD COLUMN `audit_status` tinyint(4) DEFAULT NULL\n COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝',\n ADD COLUMN `close_reason` varchar(30) DEFAULT NULL\n COMMENT '关闭原因:timeout-超时关闭,cancel-取消关闭,refund-退款关闭',\n ADD COLUMN `paid_completion_time` datetime DEFAULT NULL\n COMMENT '订单完成支付(付清)时间 首次全额支付完成时记录,全部退款后不重置';";
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "close_reason"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL ALTER TABLE drop column clauses with the statement", () => {
const sql = "ALTER TABLE t\n DROP COLUMN a,\n DROP COLUMN b;";
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "DROP COLUMN b"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL ALTER TABLE alter column clauses with the statement", () => {
const sql = "ALTER TABLE t\n ALTER COLUMN name SET NOT NULL;";
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER COLUMN"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps insert-select with the INSERT statement", () => {
const sql = "INSERT INTO archived_users (id, name)\nSELECT id, name FROM users\nUPDATE users SET archived = 1;";
const range = statementRangeAtCursor(sql, indexOf(sql, "archived_users"));
expect(range?.sql.trim()).toBe("INSERT INTO archived_users (id, name)\nSELECT id, name FROM users");
});
it("keeps explain target SQL with the EXPLAIN statement", () => {
const sql = "EXPLAIN\nSELECT * FROM users\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "EXPLAIN"));
expect(range?.sql.trim()).toBe("EXPLAIN\nSELECT * FROM users");
});
it("keeps MySQL DESC UPDATE joins as one statement", () => {
const sql = "desc update test_orders a\njoin test_users b\non a.id=b.id \nset a.name = '张三'\nwhere b.id > 10;";
expect(statementRangeAtCursor(sql, indexOf(sql, "desc"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "set"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL EXPLAIN UPDATE assignments as one statement", () => {
const sql = "EXPLAIN UPDATE test_orders a\nJOIN test_users b ON a.id=b.id\nSET a.name = '张三'\nWHERE b.id > 10;";
expect(statementRangeAtCursor(sql, indexOf(sql, "SET"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL REPLACE function calls inside UPDATE assignments", () => {
const sql = `UPDATE ecm_archive_prepare_pool
SET
request_json =
REPLACE(
request_json,
'"paperFlag":null',
'"paperFlag":false'
),
process_flag = 0
WHERE request_json LIKE '%"paperFlag":null%';`;
expect(statementRangeAtCursor(sql, indexOf(sql, "REPLACE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("does not merge a plain MySQL DESC table statement with the next query", () => {
const sql = "DESC users\nSELECT * FROM users;";
expect(statementRangeAtCursor(sql, indexOf(sql, "DESC"), "mysql")?.sql.trim()).toBe("DESC users");
expect(statementRangeAtCursor(sql, indexOf(sql, "SELECT"), "mysql")?.sql.trim()).toBe("SELECT * FROM users");
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual(["DESC users", "SELECT * FROM users"]);
});
it("does not include comments between soft statement blocks", () => {
const sql = "SELECT 1\n-- explain the next query\n/* still next query notes */\nSELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("detects a soft statement start after a leading block comment on the same line", () => {
const sql = "SELECT 1\n/* next */ SELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "2"));
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("uses database-specific soft statement keywords", () => {
const sql = "SELECT 1\nDO $$ BEGIN RAISE NOTICE 'x'; END $$;";
expect(statementRangeAtCursor(sql, indexOf(sql, "1"))?.sql.trim()).toBe("SELECT 1\nDO $$ BEGIN RAISE NOTICE 'x'; END $$");
expect(statementRangeAtCursor(sql, indexOf(sql, "1"), "postgres")?.sql.trim()).toBe("SELECT 1");
expect(statementRangeAtCursor(sql, indexOf(sql, "DO"), "postgres")?.sql.trim()).toBe("DO $$ BEGIN RAISE NOTICE 'x'; END $$");
});
it("returns null when the cursor is on a blank line", () => {
const sql = "SELECT 1;\n\nSELECT 2;";
const blankLinePos = sql.indexOf("\n") + 1;
expect(statementRangeAtCursor(sql, blankLinePos)).toBeNull();
});
it("returns null for an empty document", () => {
expect(statementRangeAtCursor("", 0)).toBeNull();
});
it("does not treat comment semicolons as delimiters", () => {
const sql = "SELECT 1; -- drop; this\nSELECT 2;";
const pos = indexOf(sql, "2");
expect(statementRangeAtCursor(sql, pos)?.sql.trim()).toBe("SELECT 2");
});
it("exposes offsets aligned to the statement body", () => {
const sql = " SELECT 1;\nSELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.from).toBe(2);
expect(range?.sql).toBe("SELECT 1");
});
it("skips MySQL delimiter commands when resolving the cursor statement", () => {
const sql = "select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;";
expect(statementRangeAtCursor(sql, indexOf(sql, "COUNT", 2), "mysql")?.sql.trim()).toBe("select COUNT(1) FROM your_table;");
expect(statementRangeAtCursor(sql, indexOf(sql, "delimiter"), "mysql")).toBeNull();
});
it("returns the full Oracle PL/SQL block for cursors inside nested statements", () => {
const range = statementRangeAtCursor(oraclePlSqlFixture, indexOf(oraclePlSqlFixture, "ORDERS_10K", 2), "oracle");
expect(range?.sql.trim()).toBe(oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")));
});
});
describe("executableStatementRanges", () => {
it("returns statement ranges starting only at statement starts", () => {
const sql = "SELECT *\nFROM users\nWHERE active = 1;\nSELECT 2;";
const ranges = executableStatementRanges(sql);
expect(rangeSqlTexts(ranges)).toEqual(["SELECT *\nFROM users\nWHERE active = 1", "SELECT 2"]);
expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("SELECT 2")]);
});
it("returns Redis executable command lines", () => {
const sql = "GET user:1\n# comment\n DEL user:2 ";
const ranges = executableStatementRanges(sql, "redis");
expect(rangeSqlTexts(ranges)).toEqual(["GET user:1", "DEL user:2"]);
expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("DEL")]);
});
it("keeps MySQL REPLACE INTO as an executable statement start", () => {
const sql = "SELECT 1\nREPLACE INTO users (id, name) VALUES (1, 'a');";
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual(["SELECT 1", "REPLACE INTO users (id, name) VALUES (1, 'a')"]);
});
it("returns MongoDB command ranges for newline-separated shell commands", () => {
const sql = 'use archive\ndb.users.find({ status: "open" })\n .limit(5)';
const ranges = executableStatementRanges(sql, "mongodb");
expect(rangeSqlTexts(ranges)).toEqual(["use archive", 'db.users.find({ status: "open" })\n .limit(5)']);
expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("db.users.find")]);
});
it("does not split executable Oracle PL/SQL ranges at inner statement starts", () => {
expect(rangeSqlTexts(executableStatementRanges(oraclePlSqlFixture, "oracle"))).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]);
});
});
describe("fullSqlRange", () => {
it("returns the trimmed full document", () => {
const sql = " SELECT 1; \n";
const range = fullSqlRange(sql);
expect(range?.sql).toBe("SELECT 1;");
});
it("returns null for an empty/whitespace document", () => {
expect(fullSqlRange(" \n ")).toBeNull();
});
});
describe("buildExecutionCandidates", () => {
it("returns a single candidate when only the cursor statement exists", () => {
const sql = "SELECT 1";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates).toHaveLength(1);
expect(candidates[0].kind).toBe("all");
});
it("returns current + all in order for multiple statements", () => {
const sql = "SELECT 1;\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "2"));
expect(candidateKinds(candidates)).toEqual(["cursor", "all"]);
});
it("uses the cursor statement for the first candidate when there is no selection", () => {
const sql = "SELECT *\nFROM users\nWHERE active = 1";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "users"));
expect(candidates).toHaveLength(1);
expect(candidates[0].kind).toBe("all");
});
it("uses the current command line for Redis cursor candidates", () => {
const sql = "GET user:1\nDEL user:2\nHGETALL user:3";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "user:2"), "redis");
expect(candidateSummaries(candidates)).toEqual(["cursor:DEL user:2", "all:GET user:1\nDEL user:2\nHGETALL user:3"]);
expect(candidateLabels(candidates)).toEqual(["currentCommand", "allCommands"]);
});
it("returns only all for Redis when the cursor is on a comment line", () => {
const sql = "GET user:1\n# comment\nDEL user:2";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "comment"), "redis");
expect(candidateSummaries(candidates)).toEqual(["all:GET user:1\n# comment\nDEL user:2"]);
});
it("returns current + all when the cursor is in indentation before a statement", () => {
const sql = "SELECT 1;\n SELECT 2;";
const indentationPos = sql.indexOf(" SELECT 2") + 2;
const candidates = buildExecutionCandidates(sql, indentationPos);
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1;\n SELECT 2;"]);
expect(candidateLabels(candidates)).toEqual(["currentStatement", "allStatements"]);
});
it("uses the current statement when the cursor is immediately after its semicolon before a blank line", () => {
const sql = "select 1;\n\nselect 2;";
const cursorAfterFirstSemicolon = sql.indexOf(";") + 1;
const candidates = buildExecutionCandidates(sql, cursorAfterFirstSemicolon);
expect(candidateSummaries(candidates)).toEqual(["cursor:select 1", "all:select 1;\n\nselect 2;"]);
});
it("dedupes when the cursor statement equals the full document", () => {
const sql = "SELECT 1;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates).toHaveLength(1);
expect(candidates[0].kind).toBe("all");
});
it("returns only 'all' when the cursor is on a blank line", () => {
const sql = "SELECT 1;\n\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, sql.indexOf("\n") + 1);
expect(candidateKinds(candidates)).toEqual(["all"]);
});
it("returns no candidates for an empty document", () => {
expect(buildExecutionCandidates("", 0)).toEqual([]);
});
it("returns only 'all' when the cursor has no statement but the document has SQL", () => {
// Cursor past the end on a trailing blank line.
const sql = "SELECT 1;\nSELECT 2;\n";
const candidates = buildExecutionCandidates(sql, sql.length);
expect(candidateKinds(candidates)).toEqual(["all"]);
});
it("uses the MySQL statement body for delimiter scripts", () => {
const sql = "select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "COUNT", 2), "mysql");
expect(candidateSummaries(candidates)).toEqual(["cursor:select COUNT(1) FROM your_table;", "all:select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;"]);
});
});
describe("hasMultipleExecutionTargets", () => {
it("returns false for a single SQL statement", () => {
expect(hasMultipleExecutionTargets("SELECT 1;")).toBe(false);
});
it("returns true for multiple SQL statements", () => {
expect(hasMultipleExecutionTargets("SELECT 1;\nSELECT 2;")).toBe(true);
});
it("ignores comments when counting SQL statements", () => {
expect(hasMultipleExecutionTargets("-- check one thing\nSELECT 1;")).toBe(false);
});
it("counts executable Redis command lines", () => {
expect(hasMultipleExecutionTargets("GET user:1", "redis")).toBe(false);
expect(hasMultipleExecutionTargets("GET user:1\n# comment\nDEL user:2", "redis")).toBe(true);
});
it("counts MySQL delimiter scripts by executable statements", () => {
const sql = "select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;";
expect(hasMultipleExecutionTargets(sql, "mysql")).toBe(true);
});
it("does not show multiple targets for MySQL DESC UPDATE joins", () => {
const sql = "desc update test_orders a\njoin test_users b\non a.id=b.id \nset a.name = '张三'\nwhere b.id > 10;";
expect(hasMultipleExecutionTargets(sql, "mysql")).toBe(false);
});
});
describe("supportsExecutionTargetPicker", () => {
it("enables the picker for SQL database connections and Redis", () => {
expect(supportsExecutionTargetPicker("mysql")).toBe(true);
expect(supportsExecutionTargetPicker("postgres")).toBe(true);
expect(supportsExecutionTargetPicker("sqlserver")).toBe(true);
expect(supportsExecutionTargetPicker("sqlite")).toBe(true);
expect(supportsExecutionTargetPicker("jdbc")).toBe(true);
expect(supportsExecutionTargetPicker("redis")).toBe(true);
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(false);
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
expect(supportsExecutionTargetPicker("weaviate")).toBe(false);
expect(supportsExecutionTargetPicker("chromadb")).toBe(false);
expect(supportsExecutionTargetPicker("etcd")).toBe(false);
expect(supportsExecutionTargetPicker("zookeeper")).toBe(false);
expect(supportsExecutionTargetPicker("mq")).toBe(false);
expect(supportsExecutionTargetPicker("neo4j")).toBe(false);
expect(supportsExecutionTargetPicker(undefined)).toBe(false);
});
});