fix(sql): respect PostgreSQL ARRAY brackets

This commit is contained in:
jearhe
2026-08-22 14:01:46 +08:00
committed by GitHub
parent 11179676ed
commit f274a599ed
6 changed files with 282 additions and 57 deletions
@@ -628,6 +628,66 @@ SELECT @value AS Message;`;
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
});
it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => {
const sql = `
WITH rec_flow AS (
SELECT
order_no,
ARRAY[
concat(
operator_name, '(', COALESCE(remark, ''), ')[',
CASE operate_action
WHEN 'CREATE' THEN '创建工单'
WHEN 'SUBMIT' THEN '提交至下一处理人'
WHEN 'BACK' THEN '退回上一环节'
WHEN 'FINISH' THEN '已完成'
WHEN 'SUBMIT-CONFIRM' THEN '提交给创建人确认'
WHEN 'COMPLETE' THEN '确认工单'
ELSE operate_action
END, ']'
)
]::varchar[]
|| CASE
WHEN operate_action NOT IN ('FINISH','COMPLETE')
THEN ARRAY[target_handler_name]::varchar[]
ELSE ARRAY[]::varchar[]
END AS name_arr,
rn
FROM (
SELECT
'ORD20260821001' AS order_no,
'张三' AS operator_name,
'发起流程' AS remark,
'SUBMIT' AS operate_action,
'李四' AS target_handler_name,
1 AS rn
) AS mock_t3_flow
)
SELECT to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss');
`;
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
});
it("executes Oracle database-link queries without opening the parameter dialog", async () => {
const sql = "SELECT 1 FROM DUAL@WDHIS160;";
const activeTab = ref<QueryTab | undefined>(queryTab("ORCL"));
@@ -144,7 +144,8 @@ export function useSqlExecution(deps: {
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql }).sql : sql);
const databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type;
const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql);
if (typeof source === "string") return { sql: expand(source) };
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
@@ -116,6 +116,94 @@ describe("extractSqlParameters", () => {
expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["?1", "?2", "?3", "?4", "?5", "?6"]);
});
it("does not expose date format tokens after PostgreSQL ARRAY expressions", () => {
const sql = `
WITH rec_flow AS (
SELECT
order_no,
ARRAY[
concat(
operator_name, '(', COALESCE(remark, ''), ')[',
CASE operate_action
WHEN 'CREATE' THEN '创建工单'
WHEN 'SUBMIT' THEN '提交至下一处理人'
WHEN 'BACK' THEN '退回上一环节'
WHEN 'FINISH' THEN '已完成'
WHEN 'SUBMIT-CONFIRM' THEN '提交给创建人确认'
WHEN 'COMPLETE' THEN '确认工单'
ELSE operate_action
END, ']'
)
]::varchar[]
|| CASE
WHEN operate_action NOT IN ('FINISH','COMPLETE')
THEN ARRAY[target_handler_name]::varchar[]
ELSE ARRAY[]::varchar[]
END AS name_arr,
rn
FROM (
SELECT
'ORD20260821001' AS order_no,
'张三' AS operator_name,
'发起流程' AS remark,
'SUBMIT' AS operate_action,
'李四' AS target_handler_name,
1 AS rn
) AS mock_t3_flow
)
SELECT to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss');
`;
expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual([]);
});
it("keeps PostgreSQL ARRAY literals and subscripts in the lexical stream", () => {
const dateSql = "to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss')";
for (const arrayExpression of ["ARRAY['x']", "ARRAY[']']", "ARRAY['[']", "ARRAY['a]b']", "ARRAY[]::varchar[]"]) {
expect(extractSqlParameters(`SELECT ${arrayExpression}, ${dateSql};`, { databaseType: "postgres" })).toEqual([]);
}
expect(extractSqlParameters("SELECT ARRAY['x'][:array_index], values[:subscript_index];", { databaseType: "postgres" })).toEqual([]);
});
it("does not treat a standalone PostgreSQL date format as a parameter", () => {
expect(extractSqlParameters("SELECT to_char(current_timestamp, 'HH24:MI:SS');", { databaseType: "postgres" })).toEqual([]);
});
it("keeps PostgreSQL named parameters inside ARRAY constructors", () => {
const sql = "SELECT ARRAY[:first_value, :second_value];";
expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["first_value", "second_value"]);
expect(substituteSqlParameters(sql, { first_value: { kind: "number", value: "1" }, second_value: { kind: "number", value: "2" } }, { databaseType: "postgres" })).toBe("SELECT ARRAY[1, 2];");
expect(extractSqlParameters("SELECT :id;", { databaseType: "postgres" })).toEqual(["id"]);
});
it("does not treat PostgreSQL slice bounds as named parameters", () => {
const sql = "SELECT arr[lower:upper], arr[:upper], ARRAY[arr[:nested_upper], :constructor_value];";
expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["constructor_value"]);
expect(substituteSqlParameters(sql, { constructor_value: { kind: "number", value: "7" } }, { databaseType: "postgres" })).toBe("SELECT arr[lower:upper], arr[:upper], ARRAY[arr[:nested_upper], 7];");
});
it("keeps named parameters inside nested PostgreSQL ARRAY constructors and parenthesized subscripts", () => {
const sql = "SELECT ARRAY[[:first_value, :second_value], ARRAY[:third_value]], values[(:subscript_index)];";
expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["first_value", "second_value", "third_value", "subscript_index"]);
});
it("preserves SQL Server bracketed identifiers while scanning parameters", () => {
const sql = "SELECT [column:inside], :actual";
expect(extractSqlParameters(sql, { databaseType: "sqlserver" })).toEqual(["actual"]);
expect(substituteSqlParameters(sql, { actual: { kind: "number", value: "7" } }, { databaseType: "sqlserver" })).toBe("SELECT [column:inside], 7");
});
it.each(["sqlite", "jdbc", "access"] as const)("preserves bracketed identifiers for %s", (databaseType) => {
expect(extractSqlParameters("SELECT [column:inside], :actual", { databaseType })).toEqual(["actual"]);
});
it("keeps historical bracket scanning when no database dialect is supplied", () => {
expect(extractSqlParameters("SELECT [column:inside], :actual")).toEqual(["actual"]);
});
it("keeps question marks as positional placeholders for other databases", () => {
expect(extractSqlParameters("select payload ? 'callingResults' from events", { databaseType: "mysql" })).toEqual(["?1"]);
});
+16
View File
@@ -60,6 +60,22 @@ describe("expandSqlVariables", () => {
expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false });
});
it("keeps PostgreSQL ARRAY literals from corrupting later variable references", () => {
const sql = ["@set selected = 7;", "select ARRAY[']']::varchar[], @selected, ${missing}"].join("\n");
expect(expandSqlVariables(sql, { databaseType: "postgres" }).sql).toBe("select ARRAY[']']::varchar[], 7, ${missing}");
});
it("keeps multiline PostgreSQL ARRAY values in @set declarations", () => {
const sql = ["@set values = ARRAY[", " 'first',", " 'second'", "];", "select @values;"].join("\n");
expect(expandSqlVariables(sql, { databaseType: "postgres" }).sql).toBe("select ARRAY[\n 'first',\n 'second'\n];");
});
it.each(["sqlserver", "sqlite", "jdbc"] as const)("preserves %s bracket identifiers during variable expansion", (databaseType) => {
const sql = ["@set selected = 7;", "select [column:@ignored], @selected"].join("\n");
expect(expandSqlVariables(sql, { databaseType }).sql).toBe("select [column:@ignored], 7");
});
it("keeps a value's own quotes and parentheses intact", () => {
const sql = ["@set filter = (status = 'drafted' and deleted_at is null);", "select * from t where @filter"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from t where (status = 'drafted' and deleted_at is null)");
+88 -42
View File
@@ -227,7 +227,7 @@ export function substituteSqlParameters(sql: string, values: Record<string, SqlP
cursor = occurrence.end;
}
result += sql.slice(cursor);
return decodeMyBatisEntities ? decodeMyBatisXmlComparisonEntities(result) : result;
return decodeMyBatisEntities ? decodeMyBatisXmlComparisonEntities(result, options?.databaseType) : result;
}
const MYBATIS_WHERE_LEADING_CONJUNCTION_RE = /^(?:AND|OR)\s+/i;
@@ -305,7 +305,7 @@ function unquoteListValue(value: string): string {
}
}
function decodeMyBatisXmlComparisonEntities(sql: string): string {
function decodeMyBatisXmlComparisonEntities(sql: string, databaseType?: DatabaseType): string {
let result = "";
let cursor = 0;
let i = 0;
@@ -319,7 +319,7 @@ function decodeMyBatisXmlComparisonEntities(sql: string): string {
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -367,16 +367,19 @@ export function sqlParameterLiteral(input: SqlParameterInput): string {
function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions): ParameterOccurrence[] {
const occurrences: ParameterOccurrence[] = [];
const nativeSqlServerParameters = collectNativeSqlServerParameters(sql);
const supportsNamedParameters = options?.databaseType !== "saphana";
const databaseType = options?.databaseType;
const nativeSqlServerParameters = collectNativeSqlServerParameters(sql, databaseType);
const supportsNamedParameters = databaseType !== "saphana";
const enabledSyntaxes = options?.enabledSyntaxes ? new Set(options.enabledSyntaxes) : null;
const isSyntaxEnabled = (syntax: SqlParameterSyntax) => !enabledSyntaxes || enabledSyntaxes.has(syntax);
const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql) : new Set<number>();
const duckDbStructFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") && options?.databaseType === "duckdb" ? collectDuckDbStructFieldSeparators(sql) : new Set<number>();
const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, options?.databaseType) : new Set<number>();
const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql, databaseType) : new Set<number>();
const duckDbStructFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") && databaseType === "duckdb" ? collectDuckDbStructFieldSeparators(sql) : new Set<number>();
const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, databaseType) : new Set<number>();
let i = 0;
let dollarQuoteEnd = "";
let positionalIndex = 0;
let parenthesisDepth = 0;
const postgresBracketStack: Array<{ constructor: boolean; parenthesisDepth: number }> = [];
while (i < sql.length) {
if (dollarQuoteEnd) {
@@ -391,7 +394,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
const next = sql[i + 1];
if (ch === "<" && isSyntaxEnabled("mybatis")) {
const foreach = readMyBatisForeachAt(sql, i);
const foreach = readMyBatisForeachAt(sql, i, databaseType);
if (foreach) {
occurrences.push({
key: foreach.collection,
@@ -446,8 +449,31 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
i = skipQuoted(sql, i, ch);
continue;
}
if (databaseType === "postgres" && ch === "(") {
parenthesisDepth += 1;
i += 1;
continue;
}
if (databaseType === "postgres" && ch === ")") {
parenthesisDepth = Math.max(0, parenthesisDepth - 1);
i += 1;
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
if (databaseType === "postgres") {
postgresBracketStack.push({
constructor: isPostgresArrayConstructorBracket(sql, i, postgresBracketStack[postgresBracketStack.length - 1]?.constructor ?? false),
parenthesisDepth,
});
i += 1;
continue;
}
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (databaseType === "postgres" && ch === "]") {
postgresBracketStack.pop();
i += 1;
continue;
}
if (ch === "-" && next === "-") {
@@ -477,6 +503,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
sql[i + 1] !== "=" &&
!complexTypeFieldSeparators.has(i) &&
!duckDbStructFieldSeparators.has(i) &&
!isPostgresSliceSeparator(postgresBracketStack, parenthesisDepth) &&
!isDuckDbCompactPrefixAliasSeparator(sql, i, options?.databaseType) &&
!triggerPseudoRecordFieldStarts.has(i) &&
!isMysqlRoutineLabelSeparator(sql, i, name, options?.databaseType)
@@ -544,7 +571,7 @@ function isMysqlRoutineLabelSeparator(sql: string, colonIndex: number, following
return prefix.length === 0 || prefix.endsWith(";") || MYSQL_ROUTINE_LABEL_CONTEXTS.has(previousKeyword(sql, labelStart));
}
function readMyBatisForeachAt(sql: string, start: number): { collection: string; render: MyBatisForeach; end: number } | null {
function readMyBatisForeachAt(sql: string, start: number, databaseType?: DatabaseType): { collection: string; render: MyBatisForeach; end: number } | null {
if (!/^<foreach(?:\s|>)/i.test(sql.slice(start))) return null;
const openingEnd = findXmlTagEnd(sql, start);
if (openingEnd === -1) return null;
@@ -556,7 +583,7 @@ function readMyBatisForeachAt(sql: string, start: number): { collection: string;
const index = attributes.get("index")?.trim();
if (!PARAMETER_NAME_RE.test(collection) || !PARAMETER_NAME_RE.test(item) || (index !== undefined && !PARAMETER_NAME_RE.test(index))) return null;
const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach");
const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach", databaseType);
if (!close) return null;
return {
collection,
@@ -624,7 +651,7 @@ function decodeXmlAttribute(value: string): string {
return value.replace(/&(?:lt|gt|amp|quot|apos);/g, (entity) => ({ "&lt;": "<", "&gt;": ">", "&amp;": "&", "&quot;": '"', "&apos;": "'" })[entity] ?? entity);
}
function findMatchingXmlTagClose(sql: string, start: number, tagName: string): { start: number; end: number } | null {
function findMatchingXmlTagClose(sql: string, start: number, tagName: string, databaseType?: DatabaseType): { start: number; end: number } | null {
let depth = 1;
let i = start;
@@ -637,7 +664,7 @@ function findMatchingXmlTagClose(sql: string, start: number, tagName: string): {
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -865,7 +892,7 @@ function collectTriggerPseudoRecordFieldStarts(sql: string, databaseType?: Datab
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -998,7 +1025,7 @@ function isJdbcxMcpScopedPackage(sql: string, start: number, nameEnd: number): b
}
// Doris-style complex types use colons between field names and types; those are not bind parameters.
function collectComplexTypeFieldSeparators(sql: string): Set<number> {
function collectComplexTypeFieldSeparators(sql: string, databaseType?: DatabaseType): Set<number> {
const separators = new Set<number>();
let i = 0;
let dollarQuoteEnd = "";
@@ -1019,7 +1046,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set<number> {
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1044,7 +1071,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set<number> {
}
const declaration = readComplexTypeDeclaration(sql, i);
if (declaration) {
i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1;
i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators, databaseType) + 1;
continue;
}
i += 1;
@@ -1053,7 +1080,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set<number> {
return separators;
}
function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: number, kind: ComplexTypeDeclarationKind, separators: Set<number>): number {
function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: number, kind: ComplexTypeDeclarationKind, separators: Set<number>, databaseType?: DatabaseType): number {
let i = start;
let genericDepth = 0;
let parenthesisDepth = 0;
@@ -1067,7 +1094,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb
continue;
}
if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i;
const fieldNameEnd = readComplexTypeFieldNameEnd(sql, i, kind);
const fieldNameEnd = readComplexTypeFieldNameEnd(sql, i, kind, databaseType);
if (fieldNameEnd > i) {
const separator = skipSqlWhitespaceAndComments(sql, fieldNameEnd);
if (sql[separator] === ":") {
@@ -1089,7 +1116,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1106,7 +1133,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb
}
const declaration = readComplexTypeDeclaration(sql, i);
if (declaration) {
i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1;
i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators, databaseType) + 1;
continue;
}
if (ch === ";" && genericDepth === 0 && parenthesisDepth === 0) return i;
@@ -1148,12 +1175,12 @@ function readComplexTypeDeclaration(sql: string, start: number): { kind: Complex
return sql[openingBracket] === "<" ? { kind, openingBracket } : null;
}
function readComplexTypeFieldNameEnd(sql: string, start: number, kind: ComplexTypeDeclarationKind): number {
function readComplexTypeFieldNameEnd(sql: string, start: number, kind: ComplexTypeDeclarationKind, databaseType?: DatabaseType): number {
if (kind === "variant") return readVariantFieldNameEnd(sql, start);
const ch = sql[start];
if (ch === '"' || ch === "`") return skipQuoted(sql, start, ch);
if (ch === "[") return skipBracketIdentifier(sql, start);
if (ch === "[") return skipBracketIdentifier(sql, start, databaseType);
if (!PARAMETER_NAME_START_RE.test(ch ?? "")) return start;
let i = start + 1;
@@ -1189,7 +1216,7 @@ function skipSqlWhitespaceAndComments(sql: string, start: number): number {
return i;
}
function collectNativeSqlServerParameters(sql: string): { declared: Set<string>; ignoredStarts: Set<number> } {
function collectNativeSqlServerParameters(sql: string, databaseType?: DatabaseType): { declared: Set<string>; ignoredStarts: Set<number> } {
const declared = new Set<string>();
const ignoredStarts = new Set<number>();
let i = 0;
@@ -1211,7 +1238,7 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set<string>;
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1235,23 +1262,23 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set<string>;
continue;
}
if (matchesWord(sql, i, "declare")) {
i = collectDeclareStatementVariables(sql, i + "declare".length, declared);
i = collectDeclareStatementVariables(sql, i + "declare".length, declared, databaseType);
continue;
}
if (matchesWord(sql, i, "set")) {
i = collectSetStatementVariables(sql, i + "set".length, declared);
i = collectSetStatementVariables(sql, i + "set".length, declared, databaseType);
continue;
}
if (matchesWord(sql, i, "select")) {
i = collectSelectAssignmentVariables(sql, i + "select".length, declared);
i = collectSelectAssignmentVariables(sql, i + "select".length, declared, databaseType);
continue;
}
if ((matchesWord(sql, i, "create") || matchesWord(sql, i, "alter")) && isRoutineDefinitionStart(sql, i)) {
i = collectRoutineDefinitionVariables(sql, i, declared);
i = collectRoutineDefinitionVariables(sql, i, declared, databaseType);
continue;
}
if (matchesWord(sql, i, "exec") || matchesWord(sql, i, "execute")) {
i = collectExecNamedArgumentStarts(sql, i + (matchesWord(sql, i, "exec") ? "exec".length : "execute".length), ignoredStarts);
i = collectExecNamedArgumentStarts(sql, i + (matchesWord(sql, i, "exec") ? "exec".length : "execute".length), ignoredStarts, databaseType);
continue;
}
i += 1;
@@ -1260,7 +1287,7 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set<string>;
return { declared, ignoredStarts };
}
function collectDeclareStatementVariables(sql: string, start: number, declared: Set<string>): number {
function collectDeclareStatementVariables(sql: string, start: number, declared: Set<string>, databaseType?: DatabaseType): number {
let i = start;
while (i < sql.length) {
const ch = sql[i];
@@ -1272,7 +1299,7 @@ function collectDeclareStatementVariables(sql: string, start: number, declared:
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1300,7 +1327,7 @@ function collectDeclareStatementVariables(sql: string, start: number, declared:
return i;
}
function collectSetStatementVariables(sql: string, start: number, declared: Set<string>): number {
function collectSetStatementVariables(sql: string, start: number, declared: Set<string>, databaseType?: DatabaseType): number {
let i = start;
while (i < sql.length) {
const ch = sql[i];
@@ -1312,7 +1339,7 @@ function collectSetStatementVariables(sql: string, start: number, declared: Set<
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1340,7 +1367,7 @@ function collectSetStatementVariables(sql: string, start: number, declared: Set<
return i;
}
function collectSelectAssignmentVariables(sql: string, start: number, declared: Set<string>): number {
function collectSelectAssignmentVariables(sql: string, start: number, declared: Set<string>, databaseType?: DatabaseType): number {
let i = start;
while (i < sql.length) {
const ch = sql[i];
@@ -1353,7 +1380,7 @@ function collectSelectAssignmentVariables(sql: string, start: number, declared:
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1381,7 +1408,7 @@ function collectSelectAssignmentVariables(sql: string, start: number, declared:
return i;
}
function collectRoutineDefinitionVariables(sql: string, start: number, declared: Set<string>): number {
function collectRoutineDefinitionVariables(sql: string, start: number, declared: Set<string>, databaseType?: DatabaseType): number {
let i = start;
while (i < sql.length) {
const ch = sql[i];
@@ -1393,7 +1420,7 @@ function collectRoutineDefinitionVariables(sql: string, start: number, declared:
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1421,7 +1448,7 @@ function collectRoutineDefinitionVariables(sql: string, start: number, declared:
return i;
}
function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStarts: Set<number>): number {
function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStarts: Set<number>, databaseType?: DatabaseType): number {
let i = start;
while (i < sql.length) {
const ch = sql[i];
@@ -1433,7 +1460,7 @@ function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStart
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -1636,7 +1663,12 @@ function skipQuoted(sql: string, start: number, quote: string): number {
return sql.length;
}
function skipBracketIdentifier(sql: string, start: number): number {
function skipBracketIdentifier(sql: string, start: number, databaseType?: DatabaseType): number {
// PostgreSQL uses square brackets for ARRAY constructors and subscripts, not
// for delimited identifiers. Leave the opening bracket in the lexical stream
// so quoted array elements cannot corrupt the following SQL state.
if (databaseType === "postgres") return start + 1;
let i = start + 1;
while (i < sql.length) {
if (sql[i] === "]") {
@@ -1676,6 +1708,20 @@ function isSqlServerTempTableReference(sql: string, start: number): boolean {
return !!previous && SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS.has(previous);
}
function isPostgresArrayConstructorBracket(sql: string, start: number, parentIsConstructor: boolean): boolean {
if (previousKeyword(sql, start) === "array") return true;
if (!parentIsConstructor) return false;
let previous = start - 1;
while (previous >= 0 && /\s/.test(sql[previous])) previous -= 1;
return sql[previous] === "[" || sql[previous] === ",";
}
function isPostgresSliceSeparator(stack: Array<{ constructor: boolean; parenthesisDepth: number }>, parenthesisDepth: number): boolean {
const context = stack[stack.length - 1];
return !!context && !context.constructor && context.parenthesisDepth === parenthesisDepth;
}
function previousKeyword(sql: string, start: number): string {
let end = start - 1;
while (end >= 0 && /\s/.test(sql[end])) end -= 1;
+28 -14
View File
@@ -11,6 +11,8 @@
// reference is only expanded when a matching `@set` declaration exists; any
// unresolved `${name}` or `@name` remains available to the parameter dialog.
import type { DatabaseType } from "@/types/database";
const VARIABLE_NAME_START_RE = /[\p{L}_]/u;
const VARIABLE_NAME_CHAR_RE = /[\p{L}\p{N}_]/u;
@@ -21,6 +23,7 @@ export interface SqlVariableExpansion {
export interface SqlVariableExpansionOptions {
declarationSql?: string;
databaseType?: DatabaseType;
}
interface DeclarationSpan {
@@ -39,7 +42,7 @@ interface DeclarationSpan {
*/
export function expandSqlVariables(sql: string, options: SqlVariableExpansionOptions = {}): SqlVariableExpansion {
const declarationSql = options.declarationSql ?? sql;
const declarations = collectDeclarations(declarationSql);
const declarations = collectDeclarations(declarationSql, options.databaseType);
if (!declarations.length) return { sql, expanded: false };
const values = new Map<string, string>();
@@ -49,18 +52,18 @@ export function expandSqlVariables(sql: string, options: SqlVariableExpansionOpt
// Only spans inside the execution target are removed. Declarations supplied
// by the surrounding document are context and must not affect target offsets.
const targetDeclarations = declarationSql === sql ? declarations : collectDeclarations(sql);
const targetDeclarations = declarationSql === sql ? declarations : collectDeclarations(sql, options.databaseType);
let result = sql;
for (let i = targetDeclarations.length - 1; i >= 0; i -= 1) {
const declaration = targetDeclarations[i];
result = stripDeclaration(result, declaration.start, declaration.end);
}
result = replaceReferences(result, values);
result = replaceReferences(result, values, options.databaseType);
return { sql: result, expanded: result !== sql };
}
function collectDeclarations(sql: string): DeclarationSpan[] {
function collectDeclarations(sql: string, databaseType?: DatabaseType): DeclarationSpan[] {
const declarations: DeclarationSpan[] = [];
let i = 0;
let dollarQuoteEnd = "";
@@ -82,7 +85,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] {
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (ch === "-" && next === "-") {
@@ -102,7 +105,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] {
}
}
if (ch === "@" && matchesWord(sql, i + 1, "set") && isStatementStart(sql, i)) {
const declaration = readDeclaration(sql, i);
const declaration = readDeclaration(sql, i, databaseType);
if (declaration) {
declarations.push(declaration);
i = declaration.end;
@@ -118,7 +121,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] {
// Parse `@set name = value` starting at `start` (the `@`). The value runs until
// the terminating `;` or end of input, honouring nested quotes, comments and
// parentheses so that `IN (...)` lists and quoted strings survive intact.
function readDeclaration(sql: string, start: number): DeclarationSpan | null {
function readDeclaration(sql: string, start: number, databaseType?: DatabaseType): DeclarationSpan | null {
let i = start + 1 + "set".length;
i = skipInlineWhitespace(sql, i);
@@ -132,7 +135,7 @@ function readDeclaration(sql: string, start: number): DeclarationSpan | null {
i = skipInlineWhitespace(sql, i);
const valueStart = i;
const valueEnd = readValueEnd(sql, i);
const valueEnd = readValueEnd(sql, i, databaseType);
const value = sql.slice(valueStart, valueEnd).trim();
if (!value) return null;
@@ -143,9 +146,10 @@ function readDeclaration(sql: string, start: number): DeclarationSpan | null {
return { name, value, start, end };
}
function readValueEnd(sql: string, start: number): number {
function readValueEnd(sql: string, start: number, databaseType?: DatabaseType): number {
let i = start;
let depth = 0;
let bracketDepth = 0;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1];
@@ -154,9 +158,15 @@ function readValueEnd(sql: string, start: number): number {
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
if (databaseType === "postgres") {
bracketDepth += 1;
i += 1;
continue;
}
i = skipBracketIdentifier(sql, i, databaseType);
continue;
}
if (databaseType === "postgres" && ch === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (ch === "-" && next === "-") return i;
if (ch === "/" && next === "*") return i;
if (ch === "$") {
@@ -169,13 +179,13 @@ function readValueEnd(sql: string, start: number): number {
}
if (ch === "(") depth += 1;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if ((ch === ";" || ch === "\n") && depth === 0) return i;
else if ((ch === ";" || ch === "\n") && depth === 0 && bracketDepth === 0) return i;
i += 1;
}
return sql.length;
}
function replaceReferences(sql: string, values: Map<string, string>): string {
function replaceReferences(sql: string, values: Map<string, string>, databaseType?: DatabaseType): string {
let result = "";
let i = 0;
let dollarQuoteEnd = "";
@@ -200,7 +210,7 @@ function replaceReferences(sql: string, values: Map<string, string>): string {
continue;
}
if (ch === "[") {
const end = skipBracketIdentifier(sql, i);
const end = skipBracketIdentifier(sql, i, databaseType);
result += sql.slice(i, end);
i = end;
continue;
@@ -317,7 +327,11 @@ function skipQuoted(sql: string, start: number, quote: string): number {
return sql.length;
}
function skipBracketIdentifier(sql: string, start: number): number {
function skipBracketIdentifier(sql: string, start: number, databaseType?: DatabaseType): number {
// PostgreSQL uses square brackets for ARRAY constructors and subscripts, not
// for delimited identifiers. Leave the opening bracket in the lexical stream.
if (databaseType === "postgres") return start + 1;
let i = start + 1;
while (i < sql.length) {
if (sql[i] === "]") {