278 lines
14 KiB
TypeScript
Vendored
278 lines
14 KiB
TypeScript
Vendored
import { ChangeSet, Text } from "@codemirror/state";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
executableStatementRangeAtCursor,
|
|
executableStatementRangeCacheForDoc,
|
|
executableStatementRangeStartingAt,
|
|
mapStatementGutterStartIndex,
|
|
statementGutterStartIndexForCache,
|
|
statementGutterStartIndexHasStartAt,
|
|
type ExecutableStatementRangeParser,
|
|
} from "@/lib/sql/executableStatementRangeCache";
|
|
|
|
describe("executableStatementRangeCacheForDoc", () => {
|
|
it("tracks MongoDB commands for current-statement framing", () => {
|
|
const sql = 'db.users.find({})\n\ndb.getCollection("audit.logs").countDocuments({})';
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mongodb");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, sql.indexOf("users"))?.sql).toBe("db.users.find({})");
|
|
expect(executableStatementRangeAtCursor(cache, sql.indexOf("audit.logs"))?.sql).toBe('db.getCollection("audit.logs").countDocuments({})');
|
|
expect(executableStatementRangeAtCursor(cache, doc.line(2).from)).toBeNull();
|
|
});
|
|
|
|
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("only exposes the routine start as executable when a MySQL procedure contains a CASE expression", () => {
|
|
const sql = [
|
|
"CREATE PROCEDURE p_case()",
|
|
"BEGIN",
|
|
" INSERT INTO audit_log (status_text)",
|
|
" SELECT CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END;",
|
|
" CASE WHEN active = 1 THEN SET @status_code = 1; ELSE SET @status_code = 0; END CASE;",
|
|
" DELETE FROM stale_rows WHERE expires_at < NOW();",
|
|
"END;",
|
|
].join("\n");
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql.slice(0, -1));
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(6).from)).toBeNull();
|
|
expect(executableStatementRangeAtCursor(cache, doc.line(6).from + 4)?.sql).toBe(sql.slice(0, -1));
|
|
});
|
|
|
|
it("keeps MyBatis parameters in a Kingbase gutter execution range", () => {
|
|
const sql = ["SELECT sum(nvl(a.medfee_sumamt, 0)) AS medfee_sumamt, a.insutype", "FROM yd_org_decla_detail a", "WHERE a.busin_type = '1' AND a.clr_ym = #{ym}", "GROUP BY a.clr_ym, a.insutype;"].join("\n");
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "kingbase");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql.slice(0, -1));
|
|
});
|
|
|
|
it("keeps a valid placeholder-only line executable and respects disabled MyBatis syntax", () => {
|
|
const sql = ["SELECT *", "FROM t", "#{where_clause};"].join("\n");
|
|
const doc = Text.of(sql.split("\n"));
|
|
const enabled = executableStatementRangeCacheForDoc(null, doc, "kingbase", { enabledSyntaxes: ["mybatis"] });
|
|
const disabled = executableStatementRangeCacheForDoc(enabled, doc, "kingbase", { enabledSyntaxes: ["shell"] });
|
|
|
|
expect(executableStatementRangeAtCursor(enabled, doc.line(3).from + 2)?.sql).toBe(sql.slice(0, -1));
|
|
expect(executableStatementRangeAtCursor(disabled, doc.line(3).from + 2)).toBeNull();
|
|
expect(disabled).not.toBe(enabled);
|
|
});
|
|
|
|
it("resolves statements with leading whitespace for gutter run buttons", () => {
|
|
const doc = Text.of([" SELECT 1;", " SELECT 2;", "\t SELECT 3;", "", " "]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe("SELECT 1");
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(2).from)?.sql).toBe("SELECT 2");
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(3).from)?.sql).toBe("SELECT 3");
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(4).from)).toBeNull();
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(5).from)).toBeNull();
|
|
});
|
|
|
|
it.each(["/*& tenant:'gdx' */", "/*&tenant:mctest*/", "/*+ MAX_EXECUTION_TIME(1000) */", "/*@global:true*/", "/*proxy*/"])("resolves the SQL line after a preserved leading directive for gutter execution: %s", (directive) => {
|
|
const sql = `SELECT 0;\n${directive}\nSELECT 1;`;
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(3).from)?.sql).toBe(`${directive}\nSELECT 1`);
|
|
});
|
|
|
|
it("keeps a same-line tenant hint in the gutter execution range", () => {
|
|
const sql = "/*& tenant:'gdx' */ SELECT\n*\nFROM table";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql);
|
|
});
|
|
|
|
it("treats a SQL Server temporary table after a hint as executable content", () => {
|
|
const sql = "/*+ hint */\n#temporary_table";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "sqlserver");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(2).from)?.sql).toBe(sql);
|
|
});
|
|
|
|
it("does not attach ordinary leading block comments to gutter execution", () => {
|
|
const doc = Text.of(["/* comment */", "SELECT 1;", "/* comment only */"]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)).toBeNull();
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(2).from)?.sql).toBe("SELECT 1");
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(3).from)).toBeNull();
|
|
});
|
|
|
|
it("resolves a same-line leading TDSQL directive for gutter execution", () => {
|
|
const sql = "/*sets:allsets */ SELECT 1;";
|
|
const doc = Text.of([sql]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql.slice(0, -1));
|
|
});
|
|
|
|
it("resolves the current statement from a cursor inside a continuation line", () => {
|
|
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 cursor = doc.toString().indexOf("menus");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, cursor)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100");
|
|
});
|
|
|
|
it("keeps indentation and same-line semicolon gaps attached to the current statement", () => {
|
|
const doc = Text.of(["SELECT 1;", " SELECT 2;"]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const indentationCursor = doc.line(2).from + 2;
|
|
const semicolonGapCursor = doc.toString().indexOf(";") + 1;
|
|
|
|
expect(executableStatementRangeAtCursor(cache, indentationCursor)?.sql).toBe("SELECT 2");
|
|
expect(executableStatementRangeAtCursor(cache, semicolonGapCursor)?.sql).toBe("SELECT 1");
|
|
});
|
|
|
|
it("keeps a standalone next-line semicolon attached to the current statement", () => {
|
|
const sql = "SELECT *\nFROM users\n;\n\nSELECT * FROM audit;";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const delimiterCursor = sql.indexOf(";");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, delimiterCursor)?.sql).toBe("SELECT *\nFROM users");
|
|
expect(executableStatementRangeAtCursor(cache, delimiterCursor + 1)?.sql).toBe("SELECT *\nFROM users");
|
|
});
|
|
|
|
it("resolves a trailing-whitespace cursor on a multi-line statement tail to that statement", () => {
|
|
const sql = "WITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length;
|
|
|
|
expect(executableStatementRangeAtCursor(cache, contentEnd)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2");
|
|
expect(executableStatementRangeAtCursor(cache, contentEnd + 1)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2");
|
|
expect(executableStatementRangeAtCursor(cache, contentEnd + 2)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2");
|
|
});
|
|
|
|
it("keeps the simple single-line trailing-whitespace case on the current statement", () => {
|
|
const sql = "SELECT 1;\nSELECT 2 \nSELECT 3;";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length;
|
|
|
|
expect(executableStatementRangeAtCursor(cache, contentEnd)?.sql).toBe("SELECT 2");
|
|
expect(executableStatementRangeAtCursor(cache, contentEnd + 1)?.sql).toBe("SELECT 2");
|
|
});
|
|
|
|
it("keeps a trailing-whitespace cursor on a non-final line of a multi-line statement in the frame cache", () => {
|
|
const sql = "SELECT * FROM `profiles`;\nSELECT * FROM `users` AS uu \nWHERE id = 2\nSELECT * FROM `orders`;";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const markerEnd = sql.indexOf("uu") + 2;
|
|
const lineEnd = sql.indexOf("\n", markerEnd);
|
|
const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2";
|
|
|
|
for (let pos = markerEnd; pos < lineEnd; pos += 1) {
|
|
expect(executableStatementRangeAtCursor(cache, pos)?.sql).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it("does not attach a semicolon after a blank line to the previous statement", () => {
|
|
const sql = "SELECT 1\n\n;";
|
|
const doc = Text.of(sql.split("\n"));
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, sql.indexOf(";"))).toBeNull();
|
|
});
|
|
|
|
it("returns null for blank and pure comment cursor lines", () => {
|
|
const doc = Text.of(["SELECT 1;", "-- comment", "/* block comment */", "", "SELECT 2;"]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, doc.line(2).from + 3)).toBeNull();
|
|
expect(executableStatementRangeAtCursor(cache, doc.line(3).from + 3)).toBeNull();
|
|
expect(executableStatementRangeAtCursor(cache, doc.line(4).from)).toBeNull();
|
|
});
|
|
|
|
it("preserves a same-line TDSQL directive while ignoring a cursor inside its comment", () => {
|
|
const sql = "/*sets:allsets */ SELECT 1;";
|
|
const doc = Text.of([sql]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
|
|
expect(executableStatementRangeAtCursor(cache, sql.indexOf("SELECT"))?.sql).toBe(sql.slice(0, -1));
|
|
expect(executableStatementRangeAtCursor(cache, sql.indexOf("sets"))).toBeNull();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("statementGutterStartIndex", () => {
|
|
it("derives membership from both statement starts and executable line starts", () => {
|
|
const doc = Text.of(["/*+ hint */", "SELECT 1;", "", "SELECT 2;"]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const index = statementGutterStartIndexForCache(cache);
|
|
|
|
// First statement: executable start lands on the directive's following line.
|
|
expect(statementGutterStartIndexHasStartAt(index, doc.line(2).from)).toBe(true);
|
|
expect(statementGutterStartIndexHasStartAt(index, doc.line(4).from)).toBe(true);
|
|
expect(statementGutterStartIndexHasStartAt(index, doc.line(3).from)).toBe(false);
|
|
});
|
|
|
|
it("shifts start positions through insertions and deletions", () => {
|
|
const doc = Text.of(["SELECT 1;", "SELECT 2;"]);
|
|
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
|
const index = statementGutterStartIndexForCache(cache);
|
|
const secondLineStart = doc.line(2).from;
|
|
expect(statementGutterStartIndexHasStartAt(index, secondLineStart)).toBe(true);
|
|
|
|
const header = "-- lead\n";
|
|
const afterInsert = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: 0, insert: header }, doc.length));
|
|
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart)).toBe(false);
|
|
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart + header.length)).toBe(true);
|
|
|
|
const afterDelete = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: "SELECT 1;\n".length, insert: "" }, doc.length));
|
|
expect(statementGutterStartIndexHasStartAt(afterDelete, secondLineStart - "SELECT 1;\n".length)).toBe(true);
|
|
expect(statementGutterStartIndexHasStartAt(afterDelete, 0)).toBe(true);
|
|
});
|
|
});
|