Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,30 @@ describe("useSqlExecution", () => {
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", { tabId: "tab-1" });
});

it("executes an unsemicoloned @set followed by a query without producing empty SQL or silently cancelling", async () => {
const sql = ["@set user_id = 42", "select * from users where id = @user_id"].join("\n");
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
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: ["id"], rows: [["42"]], 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),
resolveExecutableSql: async () => sql,
activeOutputView,
});

await execution.tryExecute(sql);

expect(executeCurrentSql).toHaveBeenCalledWith("select * from users where id = 42", { tabId: "tab-1" });
});

it("opens the result table for a multi-statement batch by default", async () => {
const sql = "SELECT 1;\nSELECT 2;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
Expand Down
71 changes: 71 additions & 0 deletions apps/desktop/src/lib/__tests__/sql/sqlVariables.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,75 @@ describe("expandSqlVariables", () => {
const sql = "select @settings from t";
expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false });
});

it("inlines a multiline value starting on a new line after =", () => {
const sql = ["@set cond =", " from users", " where id = 42;", "select * @cond"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users\n where id = 42");
});

it("terminates a value starting on the same line as = at newline without spanning lines", () => {
const sql = ["@set cond = from users", "where id = 42;", "select * @cond"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("where id = 42;\nselect * from users");
});

it("expands a declaration without semicolon followed by an ordinary statement", () => {
const sql = ["@set user_id = 42", "select * from users where id = @user_id"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users where id = 42");
});

it("preserves following statement when an unsemicoloned declaration has a line comment", () => {
const sql = ["@set user_id = 42 -- user id", "select * from users where id = @user_id"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("-- user id\nselect * from users where id = 42");
});

it("preserves subsequent statements when an unsemicoloned declaration is followed by multiple statements", () => {
const sql = ["@set user_id = 42", "select * from users where id = @user_id;", "select 1;"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users where id = 42;\nselect 1;");
});

it("does not swallow selected statement into preceding unsemicoloned declaration", () => {
const selectedSql = "select * from users where id = @user_id";
const declarationSql = ["@set user_id = 42", selectedSql].join("\n");
expect(expandSqlVariables(selectedSql, { declarationSql }).sql).toBe("select * from users where id = 42");
});

it("expands an unsemicoloned declaration with CRLF line endings", () => {
const sql = ["@set user_id = 42", "select * from users where id = @user_id"].join("\r\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users where id = 42");
});

it("expands a semicoloned declaration with CRLF line endings", () => {
const sql = ["@set user_id = 42;", "select * from users where id = @user_id"].join("\r\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users where id = 42");
});

it("inlines a multiline value with CRLF line endings", () => {
const sql = ["@set cond =", " from users", " where id = 42;", "select * @cond"].join("\r\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users\r\n where id = 42");
});

it("supports sequential declarations with CRLF line endings", () => {
const sql = ["@set a = 1", "@set b = 2;", "select @a, @b"].join("\r\n");
expect(expandSqlVariables(sql).sql).toBe("select 1, 2");
});

it("inlines multiline values with line and block comments", () => {
const sql = ["@set cond =", " -- target table", " from users /* active accounts */", " where status = 'active';", "select id @cond"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select id -- target table\n from users /* active accounts */\n where status = 'active'");
});

it("handles semicolons inside comments within a multiline value", () => {
const sql = ["@set cond =", " from users -- note: filter by id; active only", " where id = 42;", "select * @cond"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users -- note: filter by id; active only\n where id = 42");
});

it("supports multiple multiline declarations in sequence", () => {
const sql = ["@set cond_a =", " from users", " where id = 1;", "@set cond_b =", " from orders", " where id = 2;", "select * @cond_a union all select * @cond_b"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from users\n where id = 1 union all select * from orders\n where id = 2");
});

it("terminates declaration at newline when followed by another @set declaration without semicolon", () => {
const sql = ["@set a = 1", "@set b = 2;", "select @a, @b"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select 1, 2");
});
});
81 changes: 72 additions & 9 deletions apps/desktop/src/lib/sql/sqlVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,12 @@ function collectDeclarations(sql: string, databaseType?: DatabaseType): Declarat
return declarations;
}

// 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.
// Parse `@set name = value` starting at `start` (the `@`). Values that start on
// the line following `=` (i.e. `=` is immediately followed by a newline) are
// treated as multiline values and continue across lines until a terminating `;`,
// the next `@set` declaration, or EOF. Values starting on the same line as `=`
// terminate at newline or comments (honouring nested quotes, dollar quotes,
// comments and parentheses/brackets).
function readDeclaration(sql: string, start: number, databaseType?: DatabaseType): DeclarationSpan | null {
let i = start + 1 + "set".length;
i = skipInlineWhitespace(sql, i);
Expand All @@ -149,8 +152,15 @@ function readDeclaration(sql: string, start: number, databaseType?: DatabaseType
i += 1;
i = skipInlineWhitespace(sql, i);

const isMultiline = sql[i] === "\n" || (sql[i] === "\r" && sql[i + 1] === "\n") || sql[i] === "\r";
if (sql[i] === "\r" && sql[i + 1] === "\n") {
i += 2;
} else if (sql[i] === "\n" || sql[i] === "\r") {
i += 1;
}

const valueStart = i;
const valueEnd = readValueEnd(sql, i, databaseType);
const valueEnd = readValueEnd(sql, i, databaseType, isMultiline);
const value = sql.slice(valueStart, valueEnd).trim();
if (!value) return null;

Expand All @@ -161,7 +171,7 @@ function readDeclaration(sql: string, start: number, databaseType?: DatabaseType
return { name, value, start, end };
}

function readValueEnd(sql: string, start: number, databaseType?: DatabaseType): number {
function readValueEnd(sql: string, start: number, databaseType?: DatabaseType, isMultiline = false): number {
let i = start;
let depth = 0;
let bracketDepth = 0;
Expand All @@ -182,8 +192,28 @@ function readValueEnd(sql: string, start: number, databaseType?: DatabaseType):
continue;
}
if (databaseType === "postgres" && ch === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (ch === "-" && next === "-") return i;
if (ch === "/" && next === "*") return i;
if (ch === "-" && next === "-") {
if (!isMultiline && depth === 0 && bracketDepth === 0) {
return i;
}
const lineEnd = skipLine(sql, i + 2);
if (depth === 0 && bracketDepth === 0 && isNextDeclaration(sql, lineEnd)) {
return i;
}
i = lineEnd;
continue;
}
if (ch === "/" && next === "*") {
if (!isMultiline && depth === 0 && bracketDepth === 0) {
return i;
}
const blockEnd = skipBlockComment(sql, i + 2);
if (depth === 0 && bracketDepth === 0 && isNextDeclaration(sql, blockEnd)) {
return i;
}
i = blockEnd;
continue;
}
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
Expand All @@ -194,7 +224,18 @@ function readValueEnd(sql: string, start: number, databaseType?: DatabaseType):
}
if (ch === "(") depth += 1;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if ((ch === ";" || ch === "\n") && depth === 0 && bracketDepth === 0) return i;
else if (ch === ";" && depth === 0 && bracketDepth === 0) return i;
else if (ch === "\r" && next === "\n" && depth === 0 && bracketDepth === 0) {
if (!isMultiline || isNextDeclaration(sql, i + 2)) {
return i;
}
i += 2;
continue;
} else if ((ch === "\n" || ch === "\r") && depth === 0 && bracketDepth === 0) {
if (!isMultiline || isNextDeclaration(sql, i + 1)) {
return i;
}
}
i += 1;
}
return sql.length;
Expand Down Expand Up @@ -304,6 +345,28 @@ function matchesWord(sql: string, start: number, word: string): boolean {
return !VARIABLE_NAME_CHAR_RE.test(sql[start + word.length] ?? "");
}

function isNextDeclaration(sql: string, start: number): boolean {
let j = start;
while (j < sql.length) {
const ch = sql[j];
const next = sql[j + 1];
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
j += 1;
continue;
}
if (ch === "-" && next === "-") {
j = skipLine(sql, j + 2);
continue;
}
if (ch === "/" && next === "*") {
j = skipBlockComment(sql, j + 2);
continue;
}
break;
}
return sql[j] === "@" && matchesWord(sql, j + 1, "set");
}

function readVariableName(sql: string, start: number): string {
if (!VARIABLE_NAME_START_RE.test(sql[start] ?? "")) return "";
let i = start + 1;
Expand All @@ -313,7 +376,7 @@ function readVariableName(sql: string, start: number): string {

function skipInlineWhitespace(sql: string, start: number): number {
let i = start;
while (i < sql.length && (sql[i] === " " || sql[i] === "\t" || sql[i] === "\r")) i += 1;
while (i < sql.length && (sql[i] === " " || sql[i] === "\t")) i += 1;
return i;
}

Expand Down
Loading