refactor(desktop): organize lib modules by domain
This commit is contained in:
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 '<tag>' AS "name" & 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 "<x>" 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user