Files
dbx/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts
T

1273 lines
59 KiB
TypeScript
Vendored

import { describe, expect, it } from "vitest";
import { executionCandidateForMode } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, currentExecutableStatementRange, 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;`;
const oracleIssue2405PlSql = `DECLARE
PRE_TRD_DATE INTEGER ;
BEGIN
SELECT 1 + 2 INTO PRE_TRD_DATE FROM DUAL;
END;`;
const gaussDbNestedProcedure = `CREATE OR REPLACE PROCEDURE public.dbx_issue_4318()
AS
BEGIN
BEGIN
NULL;
END;
NULL;
END;`;
const gaussDbDollarQuotedFunctionScript = `DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid;
CREATE OR REPLACE FUNCTION dbx_issue_4572_tmp_md5_uuid (v_str IN TEXT) RETURNS varchar(36) LANGUAGE PLPGSQL IMMUTABLE AS $function$
DECLARE
str1 TEXT;
BEGIN
str1 := md5(v_str);
RETURN CAST(str1 AS varchar(36));
END$function$;
DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing;`;
const gaussDbIssue4573Script = `CREATE OR REPLACE PROCEDURE createIndex (
dbName IN VARCHAR(32),
tableName IN VARCHAR(64),
indexInfo IN VARCHAR(64),
indexColumns IN VARCHAR(128)
) AS
DECLARE STMT TEXT;
DECLARE flag int;
BEGIN
SELECT
count(*) INTO flag
FROM
PG_CATALOG.PG_INDEXES
WHERE
schemaname = dbName
AND TABLENAME = tableName
AND INDEXNAME = indexInfo;
IF flag = 0 THEN STMT := 'CREATE INDEX ' || indexInfo || ' ON ' || dbName || '.' || tableName || '(' || indexColumns || ')';
EXECUTE STMT;
END IF;
END;
SELECT 1 AS after_procedure;
SELECT 2 AS final_statement;`;
const xuguProgrammableObjectFixtures = [
`CREATE OR REPLACE PROCEDURE dbx_xugu_procedure AS
v_value INTEGER;
BEGIN
v_value := 1;
END;`,
`CREATE PROCEDURE dbx_xugu_procedure_without_replace AS
v_value INTEGER;
BEGIN
v_value := 1;
END;`,
`CREATE OR REPLACE FUNCTION dbx_xugu_function RETURN INTEGER AS
BEGIN
RETURN 1;
END;`,
`CREATE FUNCTION dbx_xugu_function_without_replace RETURN INTEGER AS
BEGIN
RETURN 1;
END;`,
`CREATE OR REPLACE TRIGGER dbx_xugu_trigger
BEFORE INSERT ON dbx_xugu_events
FOR EACH ROW
BEGIN
NULL;
END;`,
`CREATE TRIGGER dbx_xugu_trigger_without_replace
BEFORE INSERT ON dbx_xugu_events
FOR EACH ROW
BEGIN
NULL;
END;`,
`CREATE OR REPLACE PACKAGE BODY dbx_xugu_package AS
PROCEDURE ping AS
BEGIN
NULL;
END ping;
END dbx_xugu_package;`,
`CREATE PACKAGE BODY dbx_xugu_package_without_replace AS
PROCEDURE ping AS
BEGIN
NULL;
END ping;
END dbx_xugu_package_without_replace;`,
`CREATE OR REPLACE FORCE PACKAGE BODY dbx_xugu_force_package AS
PROCEDURE ping AS
BEGIN
NULL;
END ping;
END dbx_xugu_force_package;`,
`CREATE OR REPLACE NOFORCE PACKAGE BODY dbx_xugu_noforce_package AS
PROCEDURE ping AS
BEGIN
NULL;
END ping;
END dbx_xugu_noforce_package;`,
`CREATE OR REPLACE TYPE BODY dbx_xugu_type AS
MEMBER PROCEDURE ping IS
BEGIN
NULL;
END;
END;`,
`CREATE TYPE BODY dbx_xugu_type_without_replace AS
MEMBER PROCEDURE ping IS
BEGIN
NULL;
END;
END;`,
];
const mysqlRoutineFixture = `CREATE PROCEDURE p()
BEGIN
SELECT 1;
IF 1 = 1 THEN
SELECT 'ok';
END IF;
END;
SELECT 2;`;
const mysqlRoutineWithLoopsFixture = `CREATE PROCEDURE p_loop()
BEGIN
WHILE 1 = 0 DO
SELECT 'while; still body';
END WHILE;
REPEAT
SELECT 'repeat; still body';
UNTIL 1 = 1 END REPEAT;
END;
SELECT 2;`;
const mysqlDelimitedRoutineFixture = `DELIMITER //
CREATE PROCEDURE sp_insert_random_users(IN p_count INT)
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE v_name VARCHAR(32);
WHILE i < p_count DO
SET v_name = CONCAT('user_', i);
INSERT INTO t_user (username) VALUES (v_name);
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
CALL sp_insert_random_users(100);`;
const sapHanaDoBlockFixture = `DO
BEGIN
SELECT 1 AS "Result" FROM DUMMY;
END;
SELECT 2 FROM DUMMY;`;
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("keeps SQL Server temporary table names instead of treating them as hash comments", () => {
const sql = "DROP TABLE IF EXISTS #Temp;\nSELECT * FROM ##GlobalTemp;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "sqlserver"))).toEqual(["DROP TABLE IF EXISTS #Temp", "SELECT * FROM ##GlobalTemp"]);
});
it("keeps MyBatis placeholders instead of treating them as hash comments", () => {
const sql = "SELECT * FROM yd_org_decla_detail WHERE clr_ym = #{ym};\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "kingbase"))).toEqual(["SELECT * FROM yd_org_decla_detail WHERE clr_ym = #{ym}", "SELECT 2"]);
});
it("treats malformed or disabled MyBatis prefixes as hash comments", () => {
expect(rangeSqlTexts(splitSqlStatementRanges("SELECT 1; #{1ym};\nSELECT 2", "kingbase"))).toEqual(["SELECT 1", "SELECT 2"]);
expect(rangeSqlTexts(splitSqlStatementRanges("SELECT 1; #{ym};\nSELECT 2", "kingbase", { enabledSyntaxes: ["shell"] }))).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 MySQL routine blocks together without delimiter commands", () => {
const ranges = splitSqlStatementRanges(mysqlRoutineFixture, "mysql");
expect(rangeSqlTexts(ranges)).toEqual([mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]);
expect(ranges[0].sql).toContain("SELECT 1;");
expect(ranges[0].sql).toContain("END IF;");
expect(ranges[0].sql).not.toMatch(/END;$/);
});
it("does not merge regular MySQL transaction statements as routine blocks", () => {
const sql = "BEGIN; INSERT INTO t VALUES (1); COMMIT;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["BEGIN", "INSERT INTO t VALUES (1)", "COMMIT"]);
});
it("treats SQL Server GO lines as batch delimiters", () => {
const sql = "SELECT 1\nGO\nSELECT 2;\n GO 2\nSELECT 3";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "sqlserver"))).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"]);
});
it("does not treat GO inside strings or comments as a SQL Server batch delimiter", () => {
const sql = "SELECT 'GO'\n-- GO\nSELECT 2\nGO\nSELECT 3";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "sqlserver"))).toEqual(["SELECT 'GO'\n-- GO\nSELECT 2", "SELECT 3"]);
});
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/");
});
it("keeps issue #2405 Oracle PL/SQL block together without a slash delimiter", () => {
expect(rangeSqlTexts(splitSqlStatementRanges(oracleIssue2405PlSql, "oracle"))).toEqual([oracleIssue2405PlSql]);
});
it("splits large Dameng package bodies without repeated prefix parsing", () => {
const body = Array.from({ length: 3000 }, (_, index) => ` v_value := v_value + ${index % 10};`).join("\n");
const packageBody = `CREATE OR REPLACE PACKAGE BODY app.big_pkg AS
PROCEDURE run IS
v_value NUMBER := 0;
BEGIN
${body}
END run;
END big_pkg;`;
const sql = `${packageBody}\n/\nSELECT 1;`;
const startedAt = performance.now();
const ranges = splitSqlStatementRanges(sql, "dameng");
const elapsedMs = performance.now() - startedAt;
expect(rangeSqlTexts(ranges)).toEqual([packageBody, "SELECT 1"]);
expect(elapsedMs).toBeLessThan(1000);
});
it("keeps nested GaussDB procedure blocks together", () => {
expect(rangeSqlTexts(splitSqlStatementRanges(gaussDbNestedProcedure, "gaussdb"))).toEqual([gaussDbNestedProcedure]);
});
it("separates GaussDB dollar-quoted functions from surrounding statements", () => {
const ranges = splitSqlStatementRanges(gaussDbDollarQuotedFunctionScript, "gaussdb");
expect(rangeSqlTexts(ranges)).toEqual([
"DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid",
gaussDbDollarQuotedFunctionScript.slice(gaussDbDollarQuotedFunctionScript.indexOf("CREATE"), gaussDbDollarQuotedFunctionScript.lastIndexOf(";\n\nDROP")),
"DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing",
]);
});
it("separates the issue #4573 GaussDB procedure from following statements", () => {
expect(rangeSqlTexts(splitSqlStatementRanges(gaussDbIssue4573Script, "gaussdb"))).toEqual([gaussDbIssue4573Script.slice(0, gaussDbIssue4573Script.indexOf("\n\nSELECT 1")), "SELECT 1 AS after_procedure", "SELECT 2 AS final_statement"]);
});
it("keeps Xugu programmable object DDL together and retains its terminator", () => {
for (const sql of xuguProgrammableObjectFixtures) {
const ranges = splitSqlStatementRanges(`${sql}\nSELECT 1;`, "xugu");
expect(rangeSqlTexts(ranges)).toEqual([sql, "SELECT 1"]);
expect(ranges[0].sql.trimEnd()).toMatch(/END(?:\s+\w+)?;$/);
}
});
it("splits Xugu package specification without a slash before following SQL", () => {
const packageSpec = `CREATE OR REPLACE PACKAGE pkg_utils AS
FUNCTION get_version RETURN VARCHAR2;
PROCEDURE log_message(msg VARCHAR2);
END pkg_utils;`;
const forcePackageSpec = `CREATE OR REPLACE FORCE PACKAGE pkg_utils AS
PROCEDURE ping;
END pkg_utils;`;
const packageSpecWithoutReplace = `CREATE PACKAGE pkg_utils_without_replace AS
PROCEDURE ping;
END pkg_utils_without_replace;`;
expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpec}\nSELECT 1;`, "xugu"))).toEqual([packageSpec, "SELECT 1"]);
expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpec}\n/\nSELECT 1;`, "xugu"))).toEqual([packageSpec, "SELECT 1"]);
expect(rangeSqlTexts(splitSqlStatementRanges(`${forcePackageSpec}\nSELECT 1;`, "xugu"))).toEqual([forcePackageSpec, "SELECT 1"]);
expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpecWithoutReplace}\nSELECT 1;`, "xugu"))).toEqual([packageSpecWithoutReplace, "SELECT 1"]);
});
it("splits plain CREATE TYPE AS OBJECT on semicolon without waiting for END", () => {
const sql = "CREATE OR REPLACE TYPE address_t AS OBJECT (id INT);\nSELECT 1;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "xugu"))).toEqual(["CREATE OR REPLACE TYPE address_t AS OBJECT (id INT)", "SELECT 1"]);
expect(rangeSqlTexts(splitSqlStatementRanges("CREATE TYPE address_t_without_replace AS OBJECT (id INT);\nSELECT 1;", "xugu"))).toEqual(["CREATE TYPE address_t_without_replace AS OBJECT (id INT)", "SELECT 1"]);
});
it("keeps Oracle-style CASE expressions inside Xugu and Oracle routines", () => {
const routine = `CREATE OR REPLACE FUNCTION dbx_case_expr RETURN NUMBER AS
BEGIN
RETURN CASE WHEN 1 = 1 THEN CASE WHEN 2 = 2 THEN 1 ELSE 2 END ELSE 0 END;
END;`;
const caseStatementRoutine = `CREATE OR REPLACE PROCEDURE dbx_case_statement AS
BEGIN
CASE WHEN 1 = 1 THEN NULL; ELSE NULL; END CASE;
END;`;
for (const database of ["xugu", "oracle"] as const) {
expect(rangeSqlTexts(splitSqlStatementRanges(`${routine}\nSELECT 1;`, database))).toEqual([routine, "SELECT 1"]);
expect(rangeSqlTexts(splitSqlStatementRanges(`${caseStatementRoutine}\nSELECT 1;`, database))).toEqual([caseStatementRoutine, "SELECT 1"]);
}
});
it("keeps SAP HANA DO blocks together", () => {
const ranges = splitSqlStatementRanges(sapHanaDoBlockFixture, "saphana");
expect(rangeSqlTexts(ranges)).toEqual([sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")), "SELECT 2 FROM DUMMY"]);
expect(ranges[0].sql).toContain('SELECT 1 AS "Result" FROM DUMMY;');
expect(ranges[0].sql).toContain("END;");
});
});
describe("statementRangeAtCursor", () => {
it("splits Elasticsearch REST requests without semicolons", () => {
const sql = `# node information
GET /_nodes/stats/jvm?pretty
// search orders
POST /orders/_search
{
"query": { "match_all": {} }
}
HEAD /orders`;
for (const databaseType of ["elasticsearch", "easysearch"] as const) {
expect(rangeSqlTexts(splitSqlStatementRanges(sql, databaseType))).toEqual(["GET /_nodes/stats/jvm?pretty", 'POST /orders/_search\n{\n "query": { "match_all": {} }\n}', "HEAD /orders"]);
expect(hasMultipleExecutionTargets(sql, databaseType)).toBe(true);
}
});
it("targets the Elasticsearch request following a comment", () => {
const sql = "# JVM statistics\nGET /_nodes/stats/jvm?pretty\n\nGET /_cluster/health";
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
});
it("splits and targets Elasticsearch requests after block comments", () => {
const sql = `/* node information
including JVM details */
GET /_nodes/stats/jvm?pretty
/* cluster information */
GET /_cluster/health`;
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", "GET /_cluster/health"]);
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM details"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster information"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
});
it("ignores request-looking lines inside Elasticsearch block comments", () => {
const sql = `GET /_cluster/health
/* disabled cleanup
DELETE /important-index
*/
GET /_cat/indices`;
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_cluster/health", "GET /_cat/indices"]);
expect(statementRangeAtCursor(sql, indexOf(sql, "important-index"), "elasticsearch")?.sql).toBe("GET /_cat/indices");
});
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("keeps a standalone next-line semicolon cursor on the current multi-line statement", () => {
const sql = "SELECT *\nFROM system_dept\n;\n\nSELECT * FROM sys;";
const delimiterPos = sql.indexOf(";");
expect(statementRangeAtCursor(sql, delimiterPos)?.sql.trim()).toBe("SELECT *\nFROM system_dept");
expect(statementRangeAtCursor(sql, delimiterPos + 1)?.sql.trim()).toBe("SELECT *\nFROM system_dept");
});
it("assigns a standalone trailing semicolon to the final soft statement", () => {
const sql = "SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;";
const delimiterPos = sql.lastIndexOf(";");
expect(statementRangeAtCursor(sql, delimiterPos, "mysql")?.sql).toBe("SELECT * FROM `t_0001` LIMIT 1");
expect(statementRangeAtCursor(sql, delimiterPos + 1, "mysql")?.sql).toBe("SELECT * FROM `t_0001` LIMIT 1");
});
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 newline set-operation SELECT operands with the cursor statement", () => {
const sql = "select * from tbA\nunion\nselect * from tbB";
const expected = "select * from tbA\nunion\nselect * from tbB";
expect(statementRangeAtCursor(sql, indexOf(sql, "tbA"))?.sql.trim()).toBe(expected);
expect(statementRangeAtCursor(sql, indexOf(sql, "tbB"))?.sql.trim()).toBe(expected);
});
it("keeps newline set-operation operands with ALL modifiers together", () => {
const sql = "select * from tbA\nunion all\nselect * from tbB\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "tbA"));
expect(range?.sql.trim()).toBe("select * from tbA\nunion all\nselect * from tbB");
});
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 CREATE TABLE options with table comments", () => {
const sql = `CREATE TABLE test_1 (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
deleted tinyint NOT NULL DEFAULT 0 COMMENT '删除标志(0:有效 1:无效)',
locked tinyint NOT NULL DEFAULT 0 COMMENT '是否锁定(0.否,1.是)',
version int NOT NULL DEFAULT 0 COMMENT '版本号',
creatorId bigint DEFAULT NULL COMMENT '创建人ID',
createBy varchar(100) DEFAULT NULL COMMENT '创建人名称',
createdTime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updaterId bigint DEFAULT NULL COMMENT '修改人ID',
updatedBy varchar(100) DEFAULT NULL COMMENT '修改人名称',
updatedTime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
PRIMARY KEY (id)
)
ENGINE = INNODB,
CHARACTER SET utf8mb4,
COLLATE utf8mb4_general_ci,
COMMENT = '测试';`;
expect(statementRangeAtCursor(sql, indexOf(sql, "CREATE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "COMMENT ="), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
expect(statementRangeAtCursor(sql, indexOf(sql, "COMMENT ="))?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL CREATE TABLE comments without equals as table options", () => {
const sql = "CREATE TABLE test_2 (\n id bigint NOT NULL\n)\nCOMMENT '测试';";
expect(statementRangeAtCursor(sql, indexOf(sql, "COMMENT"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
expect(statementRangeAtCursor(sql, indexOf(sql, "COMMENT"))?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([sql.slice(0, -1)]);
});
it("does not merge standard COMMENT ON statements into preceding CREATE TABLE statements", () => {
const sql = "CREATE TABLE users (id int)\nCOMMENT ON TABLE users IS 'Users';";
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual(["CREATE TABLE users (id int)", "COMMENT ON TABLE users IS 'Users'"]);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual(["CREATE TABLE users (id int)", "COMMENT ON TABLE users IS 'Users'"]);
});
it("keeps a line-start comment column inside a select projection", () => {
const sql = "SELECT\n id,\n comment,\n created_at\nFROM project_info\nWHERE deleted = 0;";
expect(statementRangeAtCursor(sql, indexOf(sql, "comment"))?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql))).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 MySQL ALTER TABLE truncate partition clauses with the statement", () => {
const sql = "ALTER TABLE ems.r_r_curve_e_hour\nTRUNCATE PARTITION p202602, p202603, p202604, p202605, p202606;";
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "TRUNCATE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL truncate partition clauses when comments separate ALTER TABLE", () => {
const sql = "ALTER /* online ddl */ TABLE t\nTRUNCATE PARTITION p0;";
expect(statementRangeAtCursor(sql, indexOf(sql, "TRUNCATE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps MySQL truncate partition clauses when comments precede PARTITION", () => {
const sql = "ALTER TABLE t\nTRUNCATE /* keep */ PARTITION p0;";
expect(statementRangeAtCursor(sql, indexOf(sql, "TRUNCATE"), "mysql")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]);
});
it("keeps standalone truncate table statements separate from preceding alter statements", () => {
const sql = "ALTER TABLE events ADD COLUMN source varchar(100)\nTRUNCATE TABLE events;";
expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual(["ALTER TABLE events ADD COLUMN source varchar(100)", "TRUNCATE TABLE events"]);
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual(["ALTER TABLE events ADD COLUMN source varchar(100)", "TRUNCATE TABLE events"]);
});
it("keeps issue #4045 ClickHouse ALTER TABLE UPDATE mutation together", () => {
const sql = `ALTER TABLE m2_db.history_data5
UPDATE value = 14.06
WHERE uuid IN (
SELECT uuid
FROM m2_db.pt_uuid_mapping
WHERE point_id = 'TEST_Location_1_L2_METER_2_AE_DIFF_H'
)
AND ts = toDateTime('2026-07-15 12:00:00');`;
expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "UPDATE"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([sql.slice(0, -1)]);
});
it("keeps a simple ClickHouse ALTER TABLE UPDATE mutation together", () => {
const sql = "ALTER TABLE `events` ON CLUSTER 'analytics'\nUPDATE value = 1\nWHERE id = 42;";
expect(statementRangeAtCursor(sql, indexOf(sql, "UPDATE"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([sql.slice(0, -1)]);
});
it("separates statements after a ClickHouse ALTER TABLE UPDATE mutation", () => {
const mutation = "ALTER TABLE events\nUPDATE value = 1\nWHERE id = 42";
const sql = `${mutation};\nSELECT * FROM events;\nUPDATE audit SET processed = 1;`;
expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([mutation, "SELECT * FROM events", "UPDATE audit SET processed = 1"]);
});
it("does not merge independent UPDATE statements into other ALTER statements", () => {
const otherDatabaseSql = "ALTER TABLE events\nUPDATE audit SET processed = 1;";
const clickHouseSql = "ALTER TABLE events ADD COLUMN source String\nUPDATE audit SET processed = 1;";
expect(rangeSqlTexts(executableStatementRanges(otherDatabaseSql, "postgres"))).toEqual(["ALTER TABLE events", "UPDATE audit SET processed = 1"]);
expect(rangeSqlTexts(executableStatementRanges(otherDatabaseSql, "mysql"))).toEqual(["ALTER TABLE events", "UPDATE audit SET processed = 1"]);
expect(rangeSqlTexts(executableStatementRanges(clickHouseSql, "clickhouse"))).toEqual(["ALTER TABLE events ADD COLUMN source String", "UPDATE audit SET processed = 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 issue #3567 EXPLAIN options and CTE target as one statement", () => {
const sql = "explain (analyze,buffers)\nwith tmp as(select* from test.tt)\nselect * from tmp;";
expect(statementRangeAtCursor(sql, indexOf(sql, "explain"), "postgres")?.sql.trim()).toBe(sql.slice(0, -1));
expect(statementRangeAtCursor(sql, indexOf(sql, "select * from tmp"), "postgres")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([sql.slice(0, -1)]);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([sql.slice(0, -1)]);
});
it("keeps EXPLAIN ANALYZE with a CTE main query as one statement", () => {
const sql = "explain analyze\nwith tmp as (select 1)\nselect * from tmp;";
expect(statementRangeAtCursor(sql, indexOf(sql, "explain"), "postgres")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([sql.slice(0, -1)]);
});
it("keeps a plain EXPLAIN CTE target as one statement without merging later queries", () => {
const sql = "EXPLAIN\nWITH tmp AS (SELECT 1)\nSELECT * FROM tmp\nSELECT * FROM logs;";
const expected = "EXPLAIN\nWITH tmp AS (SELECT 1)\nSELECT * FROM tmp";
expect(statementRangeAtCursor(sql, indexOf(sql, "EXPLAIN"))?.sql.trim()).toBe(expected);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([expected, "SELECT * FROM logs"]);
});
it("does not merge a query after an inline EXPLAIN options CTE statement", () => {
const sql = "EXPLAIN (ANALYZE) WITH tmp AS (SELECT 1)\nSELECT * FROM tmp\nSELECT 2;";
const expected = "EXPLAIN (ANALYZE) WITH tmp AS (SELECT 1)\nSELECT * FROM tmp";
expect(statementRangeAtCursor(sql, indexOf(sql, "EXPLAIN"), "postgres")?.sql.trim()).toBe(expected);
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([expected, "SELECT 2"]);
});
it("keeps EXPLAIN options CTE UPDATE assignments as one statement", () => {
const sql = "EXPLAIN (ANALYZE)\nWITH tmp AS (SELECT 1)\nUPDATE t\nSET x = 1;";
expect(statementRangeAtCursor(sql, indexOf(sql, "SET"), "postgres")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([sql.slice(0, -1)]);
});
it("keeps CTE INSERT ... SELECT with the WITH statement", () => {
const sql = "WITH tmp AS (SELECT 1)\nINSERT INTO t (id)\nSELECT * FROM tmp;";
expect(statementRangeAtCursor(sql, indexOf(sql, "INSERT"))?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([sql.slice(0, -1)]);
});
it("does not merge a query after a CTE INSERT ... SELECT statement", () => {
const sql = "WITH tmp AS (SELECT 1)\nINSERT INTO t (id)\nSELECT * FROM tmp\nSELECT 2;";
const expected = "WITH tmp AS (SELECT 1)\nINSERT INTO t (id)\nSELECT * FROM tmp";
expect(statementRangeAtCursor(sql, indexOf(sql, "INSERT"))?.sql.trim()).toBe(expected);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([expected, "SELECT 2"]);
});
it("does not merge a query after an INSERT with a CTE source query", () => {
const sql = "INSERT INTO t (id)\nWITH tmp AS (SELECT 1)\nSELECT * FROM tmp\nSELECT 2;";
const expected = "INSERT INTO t (id)\nWITH tmp AS (SELECT 1)\nSELECT * FROM tmp";
expect(statementRangeAtCursor(sql, indexOf(sql, "INSERT"))?.sql.trim()).toBe(expected);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual([expected, "SELECT 2"]);
});
it("skips block comments inside EXPLAIN options when resolving the target", () => {
const sql = "EXPLAIN (ANALYZE /* ) */) UPDATE t\nSET x = 1;";
expect(statementRangeAtCursor(sql, indexOf(sql, "SET"), "postgres")?.sql.trim()).toBe(sql.slice(0, -1));
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([sql.slice(0, -1)]);
});
it("recovers later statements from an unclosed EXPLAIN option list", () => {
const sql = "EXPLAIN (ANALYZE\nSELECT 1\nSELECT 2;";
const explainSql = "EXPLAIN (ANALYZE\nSELECT 1";
expect(statementRangeAtCursor(sql, indexOf(sql, "EXPLAIN"), "postgres")?.sql.trim()).toBe(explainSql);
expect(statementRangeAtCursor(sql, indexOf(sql, "SELECT 2"), "postgres")?.sql.trim()).toBe("SELECT 2");
expect(rangeSqlTexts(executableStatementRanges(sql, "postgres"))).toEqual([explainSql, "SELECT 2"]);
});
it("does not treat a DESCRIBE subquery paren as EXPLAIN options", () => {
const sql = "DESCRIBE (SELECT 1)\nSELECT 2;";
expect(statementRangeAtCursor(sql, indexOf(sql, "DESCRIBE"), "clickhouse")?.sql.trim()).toBe("DESCRIBE (SELECT 1)");
expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual(["DESCRIBE (SELECT 1)", "SELECT 2"]);
expect(rangeSqlTexts(executableStatementRanges(sql))).toEqual(["DESCRIBE (SELECT 1)", "SELECT 2"]);
});
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 MySQL routine block for cursors inside nested statements", () => {
const range = statementRangeAtCursor(mysqlRoutineFixture, indexOf(mysqlRoutineFixture, "ok"), "mysql");
expect(range?.sql.trim()).toBe(mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim());
});
it("returns null on SQL Server GO batch delimiter lines", () => {
const sql = "SELECT 1\nGO\nSELECT 2";
expect(statementRangeAtCursor(sql, indexOf(sql, "GO"), "sqlserver")).toBeNull();
});
it("returns the current SQL Server batch around GO delimiters", () => {
const sql = "SELECT 1\nGO\nSELECT 2\nGO\nSELECT 3";
expect(statementRangeAtCursor(sql, indexOf(sql, "1"), "sqlserver")?.sql.trim()).toBe("SELECT 1");
expect(statementRangeAtCursor(sql, indexOf(sql, "2"), "sqlserver")?.sql.trim()).toBe("SELECT 2");
expect(statementRangeAtCursor(sql, indexOf(sql, "3"), "sqlserver")?.sql.trim()).toBe("SELECT 3");
});
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/")));
});
it("returns the full issue #2405 Oracle PL/SQL block for cursors inside the block", () => {
for (const cursor of [indexOf(oracleIssue2405PlSql, "PRE_TRD_DATE"), indexOf(oracleIssue2405PlSql, "SELECT 1 + 2"), indexOf(oracleIssue2405PlSql, "END;")]) {
expect(statementRangeAtCursor(oracleIssue2405PlSql, cursor, "oracle")?.sql.trim()).toBe(oracleIssue2405PlSql);
}
});
it("returns the full GaussDB procedure for cursors after a nested block", () => {
const outerNull = indexOf(gaussDbNestedProcedure, "NULL;", 2);
expect(statementRangeAtCursor(gaussDbNestedProcedure, outerNull, "gaussdb")?.sql.trim()).toBe(gaussDbNestedProcedure);
});
it("returns only the issue #4573 GaussDB procedure for a gutter cursor", () => {
const expected = gaussDbIssue4573Script.slice(0, gaussDbIssue4573Script.indexOf("\n\nSELECT 1"));
expect(statementRangeAtCursor(gaussDbIssue4573Script, indexOf(gaussDbIssue4573Script, "count(*)"), "gaussdb")?.sql.trim()).toBe(expected);
});
it("returns the full SAP HANA DO block for cursors inside nested statements", () => {
const range = statementRangeAtCursor(sapHanaDoBlockFixture, indexOf(sapHanaDoBlockFixture, "Result"), "saphana");
expect(range?.sql.trim()).toBe(sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")));
});
});
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"]);
});
it("returns the issue #2405 Oracle PL/SQL block as one executable range", () => {
expect(rangeSqlTexts(executableStatementRanges(oracleIssue2405PlSql, "oracle"))).toEqual([oracleIssue2405PlSql]);
});
it("does not split executable MySQL routine ranges at inner statements", () => {
expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineFixture, "mysql"))).toEqual([mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]);
});
it("does not split executable MySQL routine ranges at WHILE and REPEAT endings", () => {
expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithLoopsFixture, "mysql"))).toEqual([mysqlRoutineWithLoopsFixture.slice(0, mysqlRoutineWithLoopsFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]);
});
it("does not expose run targets for statements inside a delimited MySQL routine", () => {
expect(rangeSqlTexts(executableStatementRanges(mysqlDelimitedRoutineFixture, "mysql"))).toEqual([mysqlDelimitedRoutineFixture.slice(mysqlDelimitedRoutineFixture.indexOf("CREATE PROCEDURE"), mysqlDelimitedRoutineFixture.indexOf(" //\nDELIMITER")), "CALL sp_insert_random_users(100)"]);
});
it("does not split executable SAP HANA DO ranges at inner statements", () => {
expect(rangeSqlTexts(executableStatementRanges(sapHanaDoBlockFixture, "saphana"))).toEqual([sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")), "SELECT 2 FROM DUMMY"]);
});
it("returns executable SQL Server batches without GO delimiter lines", () => {
expect(rangeSqlTexts(executableStatementRanges("SELECT 1\nGO\nSELECT 2;", "sqlserver"))).toEqual(["SELECT 1", "SELECT 2"]);
});
});
describe("currentExecutableStatementRange", () => {
it("uses the current SQL statement range for multi-line DDL", () => {
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-退款关闭';\nSELECT 1;";
expect(currentExecutableStatementRange(sql, indexOf(sql, "close_reason"), "mysql")?.sql.trim()).toBe(sql.slice(0, sql.indexOf(";\nSELECT")));
});
it("returns null on blank and pure comment lines", () => {
const sql = "SELECT 1;\n-- comment\n\nSELECT 2;";
expect(currentExecutableStatementRange(sql, indexOf(sql, "comment"), "mysql")).toBeNull();
expect(currentExecutableStatementRange(sql, sql.indexOf("\n\n") + 1, "mysql")).toBeNull();
});
it("uses the current Redis command line", () => {
const sql = "GET user:1\n DEL user:2\n# comment";
expect(currentExecutableStatementRange(sql, indexOf(sql, "DEL"), "redis")?.sql).toBe("DEL user:2");
expect(currentExecutableStatementRange(sql, indexOf(sql, "comment"), "redis")).toBeNull();
});
it("uses the current MongoDB command range", () => {
const sql = 'db.users.find({})\n\ndb.getCollection("audit.logs").countDocuments({})';
expect(currentExecutableStatementRange(sql, indexOf(sql, "users"), "mongodb")?.sql).toBe("db.users.find({})");
expect(currentExecutableStatementRange(sql, indexOf(sql, "audit.logs"), "mongodb")?.sql).toBe('db.getCollection("audit.logs").countDocuments({})');
});
});
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("preserves leading optimizer hints in current statement candidates", () => {
const hintedSql = "/*+ SET(polar_csi.enable_query on) SET(polar_csi.cost_threshold 0)*/\nselect count(1) from xxx";
const sql = `select 1;\n${hintedSql};`;
const candidates = buildExecutionCandidates(sql, indexOf(sql, "count"), "postgres");
expect(candidates[0].sql).toBe(hintedSql);
});
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 whole set-operation statement for cursor execution candidates", () => {
const sql = "select * from tbA\nunion\nselect * from tbB\nSELECT * FROM logs;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "tbA"));
expect(candidateSummaries(candidates)).toEqual(["cursor:select * from tbA\nunion\nselect * from tbB", "all:select * from tbA\nunion\nselect * from tbB\nSELECT * FROM logs;"]);
});
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("keeps a MySQL FORCE INDEX query current when its semicolon is on the next line", () => {
const firstStatement = `SELECT count(*)
FROM cus_loan_status_copy1 t2
LEFT JOIN case_allocation_details_copy1 t4 FORCE INDEX(idx_debt_case_number)
ON t2.caseno = t4.debt_case_number
WHERE t2.product_name = '12345'
;`;
const sql = `${firstStatement}\n\nSELECT count(*) FROM cus_loan_status_copy1;`;
const candidates = buildExecutionCandidates(sql, indexOf(sql, "12345"), "mysql");
expect(candidateKinds(candidates)).toEqual(["cursor", "all"]);
expect(executionCandidateForMode(candidates, "current")?.sql.trim()).toBe(firstStatement.replace(/\n;$/, ""));
});
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("uses the final soft statement when the cursor follows trailing EOF whitespace", () => {
const sql = "SELECT 1\nSELECT 2 ";
const candidates = buildExecutionCandidates(sql, sql.length);
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1\nSELECT 2"]);
});
it("uses the final soft statement when the cursor is on a standalone trailing semicolon", () => {
const sql = "SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;";
const candidates = buildExecutionCandidates(sql, sql.lastIndexOf(";"), "mysql");
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT * FROM `t_0001` LIMIT 1", "all:SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;"]);
});
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"]);
expect(candidates[0].supportedKinds).toEqual(["all"]);
expect(executionCandidateForMode(candidates, "current")).toBeNull();
expect(executionCandidateForMode(candidates, "all")).toBe(candidates[0]);
});
it("marks a deduplicated single-statement candidate as both current and all", () => {
const sql = "SELECT 1;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates[0].supportedKinds).toEqual(["cursor", "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 ;"]);
});
it("uses the current SQL Server batch for cursor candidates", () => {
const sql = "SELECT 1\nGO\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "2"), "sqlserver");
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1\nGO\nSELECT 2;"]);
});
});
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("counts MySQL routine blocks without delimiter by executable statements", () => {
expect(hasMultipleExecutionTargets(mysqlRoutineFixture, "mysql")).toBe(true);
});
it("counts SQL Server GO batches as multiple execution targets", () => {
expect(hasMultipleExecutionTargets("SELECT 1\nGO\nSELECT 2", "sqlserver")).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, Redis, and Elasticsearch", () => {
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(true);
expect(supportsExecutionTargetPicker("easysearch")).toBe(true);
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);
});
});