diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 74068fff57..9c86de1ab5 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -43,6 +43,51 @@ describe("extractSqlParameters", () => { expect(extractSqlParameters(sql)).toEqual(["?1", "named", "shell_name", "mybatis_name", "sql_server_name"]); }); + it("preserves PostgreSQL JSON question-mark operators", () => { + const sql = ` + select + c."CallResult", + c."CallResult" -> 'data' ? 'callingResults' as "HasResult", + c."CallResult" ?| array['data', 'error'] as "HasAnyResult", + c."CallResult" ?& array['data', 'callingResults'] as "HasAllResults", + c."CallResult" @? '$.data.callingResults' as "MatchesPath" + from "FaultResultCallInfo" as c + `; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual([]); + expect(substituteSqlParameters(sql, {}, { databaseType: "postgres" })).toBe(sql); + }); + + it("keeps PostgreSQL positional placeholders around JSON operators", () => { + const sql = "select ? as input_value from events where id = ? and payload ? ? and payload ?| ? and payload ?& ? limit ?"; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["?1", "?2", "?3", "?4", "?5", "?6"]); + expect( + substituteSqlParameters( + sql, + { + "?1": { kind: "number", value: "1" }, + "?2": { kind: "number", value: "2" }, + "?3": { kind: "string", value: "callingResults" }, + "?4": { kind: "raw", value: "ARRAY['data', 'error']" }, + "?5": { kind: "raw", value: "ARRAY['data', 'callingResults']" }, + "?6": { kind: "number", value: "100" }, + }, + { databaseType: "postgres" }, + ), + ).toBe("select 1 as input_value from events where id = 2 and payload ? 'callingResults' and payload ?| ARRAY['data', 'error'] and payload ?& ARRAY['data', 'callingResults'] limit 100"); + }); + + it("keeps ordinary PostgreSQL positional placeholder contexts", () => { + const sql = "select ?::jsonb, coalesce(?, '{}'::jsonb) from events where ? = id order by ? limit ? offset ?"; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["?1", "?2", "?3", "?4", "?5", "?6"]); + }); + + it("keeps question marks as positional placeholders for other databases", () => { + expect(extractSqlParameters("select payload ? 'callingResults' from events", { databaseType: "mysql" })).toEqual(["?1"]); + }); + it("ignores npm scoped packages in JDBCX MCP command arguments", () => { const sql = '{{ mcp(cmd=npx, args=-y @modelcontextprotocol/server-everything, tool=echo): {"message":"hello"} }}'; expect(extractSqlParameters(sql)).toEqual([]); diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 48aa42316a..b357abfcc2 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -47,6 +47,88 @@ const PARAMETER_NAME_RE = /^[\p{L}_][\p{L}\p{N}_]*$/u; const PARAMETER_NAME_START_RE = /[\p{L}_]/u; const PARAMETER_NAME_CHAR_RE = /[\p{L}\p{N}_]/u; const SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS = new Set(["table", "from", "join", "into", "update", "truncate"]); +const POSTGRES_QUESTION_PARAMETER_PREFIX_KEYWORDS = new Set([ + "all", + "and", + "any", + "as", + "between", + "by", + "case", + "collate", + "distinct", + "else", + "fetch", + "filter", + "first", + "for", + "from", + "group", + "groups", + "having", + "ilike", + "in", + "interval", + "into", + "is", + "like", + "limit", + "next", + "not", + "offset", + "on", + "or", + "order", + "over", + "partition", + "placing", + "range", + "returning", + "rows", + "select", + "set", + "similar", + "some", + "then", + "to", + "using", + "values", + "when", + "where", + "window", + "zone", +]); +const POSTGRES_QUESTION_OPERATOR_TRAILING_KEYWORDS = new Set([ + "and", + "as", + "between", + "else", + "end", + "except", + "fetch", + "filter", + "from", + "group", + "having", + "ilike", + "in", + "intersect", + "is", + "join", + "like", + "limit", + "offset", + "on", + "or", + "order", + "over", + "returning", + "then", + "union", + "when", + "where", + "window", +]); export function readSqlBracedParameterAt(sql: string, start: number, options?: SqlParameterOptions): SqlBracedParameter | null { const open = sql.slice(start, start + 2); @@ -168,6 +250,10 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) continue; } if (ch === "?" && isSyntaxEnabled("positional")) { + if (isPostgresQuestionMarkOperator(sql, i, options?.databaseType)) { + i += 1; + continue; + } positionalIndex += 1; const key = `?${positionalIndex}`; occurrences.push({ key, name: key, syntax: "positional", token: "?", start: i, end: i + 1 }); @@ -242,6 +328,50 @@ function isOracleDatabaseLinkMarker(sql: string, index: number, databaseType: Da return PARAMETER_NAME_CHAR_RE.test(previous) || previous === "$" || previous === "#" || previous === '"'; } +function isPostgresQuestionMarkOperator(sql: string, index: number, databaseType: DatabaseType | undefined): boolean { + if (databaseType !== "postgres") return false; + if (sql[index - 1] === "@" || sql[index + 1] === "|" || sql[index + 1] === "&") return true; + + const previousIndex = previousSqlSignificantIndex(sql, index); + if (previousIndex < 0 || !canEndPostgresExpression(sql, previousIndex)) return false; + + const nextIndex = skipSqlWhitespaceAndComments(sql, index + 1); + if (nextIndex >= sql.length) return false; + const next = sql[nextIndex]; + if (next === "'" || next === '"' || next === "`" || next === "[" || next === "(" || next === "$" || next === ":" || next === "#" || next === "@" || next === "?" || next === "+" || next === "-") return true; + if (!PARAMETER_NAME_CHAR_RE.test(next)) return false; + + let end = nextIndex + 1; + while (end < sql.length && PARAMETER_NAME_CHAR_RE.test(sql[end])) end += 1; + return !POSTGRES_QUESTION_OPERATOR_TRAILING_KEYWORDS.has(sql.slice(nextIndex, end).toLowerCase()); +} + +function previousSqlSignificantIndex(sql: string, start: number): number { + let index = start - 1; + while (index >= 0) { + while (index >= 0 && /\s/.test(sql[index])) index -= 1; + if (index >= 1 && sql[index - 1] === "*" && sql[index] === "/") { + const commentStart = sql.lastIndexOf("/*", index - 1); + if (commentStart >= 0) { + index = commentStart - 1; + continue; + } + } + return index; + } + return -1; +} + +function canEndPostgresExpression(sql: string, end: number): boolean { + const previous = sql[end]; + if (previous === ")" || previous === "]" || previous === "}" || previous === "'" || previous === '"' || previous === "`" || previous === "$") return true; + if (!PARAMETER_NAME_CHAR_RE.test(previous)) return false; + + let start = end; + while (start > 0 && PARAMETER_NAME_CHAR_RE.test(sql[start - 1])) start -= 1; + return !POSTGRES_QUESTION_PARAMETER_PREFIX_KEYWORDS.has(sql.slice(start, end + 1).toLowerCase()); +} + function collectDuckDbStructFieldSeparators(sql: string): Set { const separators = new Set(); const contexts: DuckDbStructLiteralContext[] = [];