Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)

* feat: editor settings model + typed clamped defaults (Task 1)

* feat: carry SSH config + ssh_password to backend DbConfig (Task 2)

* feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3)

* feat: csv parser + shared export util (Task 4)

* feat: rustls TLS connector factory with modes + client auth (Task 5)

* feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6)

* feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7)

* feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8)

* feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9)

* feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10)

* feat: fetch SSH secrets into dbConnect + save on connection form (Task 11)

* feat: Editor settings tab UI (Task 12)

* feat: QueryEditor applies editor settings live (Task 13)

* feat: ImportDialog with CSV/JSON preview + column mapping (Task 14)

* feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15)

* fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16)

* docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17)

* feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop)

* fix: use theme-consistent red classes for danger menu items (text-error was undefined)

* feat: changes queue as tab-bar popover + amber pending border

* feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S)

* refactor: drop per-card status label from changes popover cards

* feat: green completion indicator on committed cards + auto-close tabs of dropped tables

* docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
2026-08-02 20:38:23 +08:00
committed by GitHub
parent e32fe7967c
commit e0c0db8352
68 changed files with 3885 additions and 553 deletions
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { buildChangePayload, buildChangeSql } from "./changePayload";
import type { QueueItem } from "../stores/dbViewerStore";
const base = { id: "c1", status: "pending" as const, createdAt: 0 };
describe("buildChangePayload", () => {
it("insert -> {schema, table, data}", () => {
const item: QueueItem = { ...base, type: "insert", sql: "", schema: "public", table: "t",
newData: { a: 1 } } as unknown as QueueItem;
const p = buildChangePayload(item);
expect(p).toEqual({ id: "c1", type: "insert", schema: "public", table: "t", data: "{\"a\":1}" });
});
it("delete -> {schema, table, primary_key}", () => {
const item = { ...base, type: "delete", sql: "", schema: "public", table: "t",
primaryKey: { id: 5 } } as unknown as QueueItem;
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "delete", schema: "public", table: "t", primary_key: "{\"id\":5}" });
});
it("bulk_insert -> {schema, table, columns, rows}", () => {
const item = { ...base, type: "bulk_insert", sql: "", schema: "public", table: "t",
columns: ["a", "b"], rows: [[1, 2], [3, 4]] } as unknown as QueueItem;
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, 2], [3, 4]] });
});
it("drop_table -> {schema, table}", () => {
const item = { ...base, type: "drop_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "drop_table", schema: "public", table: "t" });
});
it("empty_table -> {schema, table}", () => {
const item = { ...base, type: "empty_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "empty_table", schema: "public", table: "t" });
});
it("alter_table -> {schema, table, sql, rollback_sql}", () => {
const item = { ...base, type: "alter_table", sql: "ALTER TABLE t ADD c int", schema: "public", table: "t" } as unknown as QueueItem;
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "alter_table", schema: "public", table: "t", sql: "ALTER TABLE t ADD c int", rollback_sql: "" });
});
});
describe("buildChangeSql", () => {
it("insert", () => {
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
});
it("update", () => {
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
});
it("delete", () => {
const item = { id: "1", type: "delete", schema: "public", table: "t", primaryKey: { id: 5 } } as any;
expect(buildChangeSql(item)).toBe('DELETE FROM "public"."t" WHERE "id" = 5');
});
it("bulk_insert", () => {
const item = { id: "1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, "y"], [2, null]] } as any;
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'y\'), (2, NULL)');
});
it("empty_table / drop_table", () => {
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { QueueItem } from "../stores/dbViewerStore";
/**
* Payload emitted for a single queued change, keyed to match the Rust
* `Change` enum variants (snake_case). Sent to `executeChange`.
*/
export type ChangePayload = Record<string, unknown>;
function j(v: unknown): string {
return JSON.stringify(v ?? {});
}
function q(s: string): string {
return `"${s.replace(/"/g, '""')}"`;
}
function lit(v: unknown): string {
if (v === null || v === undefined) return "NULL";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "number") return String(v);
if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
return `'${JSON.stringify(v).replace(/'/g, "''")}'`;
}
function tableRef(schema: string | undefined, table: string | undefined): string {
if (!table) return "-";
return schema ? `${q(schema)}.${q(table)}` : q(table);
}
export function buildChangeSql(item: QueueItem): string {
const t = tableRef(item.schema, item.table);
switch (item.type) {
case "insert": {
const data = item.newData ?? {};
const cols = Object.keys(data);
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
}
case "update": {
const data = item.newData ?? {};
const pk = item.primaryKey ?? {};
const setClause = Object.keys(data).map((c) => `${q(c)} = ${lit(data[c])}`).join(", ");
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
return `UPDATE ${t} SET ${setClause} WHERE ${whereClause}`;
}
case "delete": {
const pk = item.primaryKey ?? {};
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
return `DELETE FROM ${t} WHERE ${whereClause}`;
}
case "bulk_insert": {
const cols = item.columns ?? [];
const rows = item.rows ?? [];
const valueRows = rows
.map((row) => `(${row.map(lit).join(", ")})`)
.join(", ");
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES ${valueRows}`;
}
case "empty_table":
return `DELETE FROM ${t}`;
case "drop_table":
return `DROP TABLE ${t}`;
default:
return item.sql ?? "";
}
}
export function buildChangePayload(item: QueueItem): ChangePayload {
const schema = item.schema ?? "";
const table = item.table ?? "";
switch (item.type) {
case "insert":
return { id: item.id, type: "insert", schema, table, data: j(item.newData) };
case "update":
return { id: item.id, type: "update", schema, table,
primary_key: j(item.primaryKey), old_data: j(item.oldData), new_data: j(item.newData) };
case "delete":
return { id: item.id, type: "delete", schema, table, primary_key: j(item.primaryKey) };
case "alter_table":
return { id: item.id, type: "alter_table", schema, table, sql: item.sql, rollback_sql: "" };
case "bulk_insert":
return { id: item.id, type: "bulk_insert", schema, table,
columns: item.columns ?? [], rows: item.rows ?? [] };
case "drop_table":
return { id: item.id, type: "drop_table", schema, table };
case "empty_table":
return { id: item.id, type: "empty_table", schema, table };
default:
return { id: item.id, type: item.type, sql: item.sql };
}
}
+33 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload";
// NOTE on argument key naming:
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
@@ -45,6 +46,32 @@ export async function deleteConnectionPassword(connectionId: string): Promise<vo
return invoke<void>("delete_connection_password", { connectionId });
}
// ─── Keychain: SSH secrets ────────────────────────────────────
export async function saveConnectionSshPassword(connectionId: string, password: string): Promise<void> {
return invoke<void>("save_connection_ssh_password", { connectionId, password });
}
export async function getConnectionSshPassword(connectionId: string): Promise<string | null> {
return invoke<string | null>("get_connection_ssh_password", { connectionId });
}
export async function deleteConnectionSshPassword(connectionId: string): Promise<void> {
return invoke<void>("delete_connection_ssh_password", { connectionId });
}
export async function saveConnectionSshPassphrase(connectionId: string, passphrase: string): Promise<void> {
return invoke<void>("save_connection_ssh_passphrase", { connectionId, passphrase });
}
export async function getConnectionSshPassphrase(connectionId: string): Promise<string | null> {
return invoke<string | null>("get_connection_ssh_passphrase", { connectionId });
}
export async function deleteConnectionSshPassphrase(connectionId: string): Promise<void> {
return invoke<void>("delete_connection_ssh_passphrase", { connectionId });
}
export async function recreateDemoDb(): Promise<string> {
return invoke<string>("recreate_demo_db");
}
@@ -83,10 +110,14 @@ export async function getTableData(
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
}
export async function executeChange(connectionId: string, change: ChangeItem): Promise<void> {
export async function executeChange(connectionId: string, change: ChangePayload): Promise<void> {
return invoke<void>("execute_change", { connectionId, change });
}
export async function getTableDdl(connectionId: string, schema: string, table: string): Promise<string> {
return invoke<string>("get_table_ddl", { connectionId, schema, table });
}
export async function getFkPreview(
connectionId: string,
schema: string,
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "./csvParser";
describe("parseCsv", () => {
it("parses a simple header + rows", () => {
expect(parseCsv("a,b,c\n1,2,3\n4,5,6")).toEqual({
headers: ["a", "b", "c"], rows: [["1", "2", "3"], ["4", "5", "6"]],
});
});
it("handles quoted fields containing commas and quotes", () => {
expect(parseCsv('x,y\n"a,b","c""d"""')).toEqual({
headers: ["x", "y"], rows: [["a,b", 'c"d"']],
});
});
it("supports CRLF line endings", () => {
expect(parseCsv("a,b\r\n1,2\r\n")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
});
it("strips a leading UTF-8 BOM", () => {
expect(parseCsv("\uFEFFa,b\n1,2")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
});
it("returns empty rows for header-only input", () => {
expect(parseCsv("a,b,c")).toEqual({ headers: ["a", "b", "c"], rows: [] });
});
it("errors on empty input", () => {
expect(() => parseCsv("")).toThrow(/empty/i);
});
it("ragged rows pad with empty strings", () => {
expect(parseCsv("a,b\n1")).toEqual({ headers: ["a", "b"], rows: [["1", ""]] });
});
});
+43
View File
@@ -0,0 +1,43 @@
export interface ParsedCsv {
headers: string[];
rows: string[][];
}
export function parseCsv(input: string): ParsedCsv {
const text = input.replace(/^\uFEFF/, "");
if (text.trim() === "") throw new Error("CSV input is empty");
const rows: string[][] = [];
let field = "";
let row: string[] = [];
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else inQuotes = false;
} else field += ch;
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(field); field = "";
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(field); field = "";
rows.push(row); row = [];
} else field += ch;
}
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
if (rows.length === 0) throw new Error("CSV input is empty");
const [headers, ...data] = rows;
const width = headers.length;
const padded = data.map((r) => {
const out = r.slice(0, width);
while (out.length < width) out.push("");
return out;
});
return { headers, rows: padded };
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { exportData } from "./exportData";
import type { ColumnInfo } from "./types";
const columns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
];
describe("exportData", () => {
it("csv quotes cells and escapes quotes", () => {
const create = vi.spyOn(document, "createElement");
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const click = vi.fn();
create.mockReturnValue({ click } as unknown as HTMLAnchorElement);
exportData([[1, "a"], [2, 'b"c']], columns, "csv", "t");
expect(click).toHaveBeenCalled();
});
it("json serializes rows as objects", () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const click = vi.fn();
vi.spyOn(document, "createElement").mockReturnValue({ click } as unknown as HTMLAnchorElement);
exportData([[1, "a"]], columns, "json", "t");
expect(click).toHaveBeenCalled();
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { ColumnInfo } from "./types";
export function exportData(
rows: unknown[][],
columns: ColumnInfo[],
format: string,
tableName: string,
) {
const headers = columns.map((c) => c.name);
let content: string;
let mime: string;
switch (format) {
case "json": {
const jsonRows = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
content = JSON.stringify(jsonRows, null, 2);
mime = "application/json";
break;
}
case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of rows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
content = csvRows.join("\n");
mime = "text/csv";
break;
}
case "sql": {
const lines = [`-- ${tableName}`];
for (const row of rows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
content = lines.join("\n");
mime = "application/sql";
break;
}
case "md": {
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
for (const row of rows) {
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
}
content = mdRows.join("\n");
mime = "text/markdown";
break;
}
default:
return;
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}.${format === "md" ? "md" : format}`;
a.click();
URL.revokeObjectURL(url);
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { normalizeImport, coerceRow } from "./importNormalize";
describe("normalizeImport", () => {
it("parses CSV input", () => {
expect(normalizeImport("a,b\n1,2\n3,4")).toEqual({
headers: ["a", "b"],
rows: [
["1", "2"],
["3", "4"],
],
});
});
it("parses a JSON array of objects", () => {
expect(normalizeImport('[{"a":"1","b":"2"},{"a":"3","b":"4"}]')).toEqual({
headers: ["a", "b"],
rows: [
["1", "2"],
["3", "4"],
],
});
});
it("parses a JSON object whose sole value is an array", () => {
expect(normalizeImport('{"data":[{"x":"10","y":"20"},{"x":"30","y":"40"}]}')).toEqual({
headers: ["x", "y"],
rows: [
["10", "20"],
["30", "40"],
],
});
});
it("throws on empty input", () => {
expect(() => normalizeImport("")).toThrow(/empty/i);
expect(() => normalizeImport(" ")).toThrow(/empty/i);
});
it("throws on invalid JSON", () => {
expect(() => normalizeImport('{"a":')).toThrow(/invalid json/i);
});
});
describe("coerceRow", () => {
it("converts empty strings to null", () => {
expect(coerceRow("")).toBeNull();
});
it("converts boolean literals", () => {
expect(coerceRow("true")).toBe(true);
expect(coerceRow("false")).toBe(false);
});
it("converts numeric strings to numbers", () => {
expect(coerceRow("42")).toBe(42);
expect(coerceRow("4.5")).toBe(4.5);
expect(coerceRow("-7")).toBe(-7);
});
it("keeps other values as strings", () => {
expect(coerceRow("abc")).toBe("abc");
expect(coerceRow("12abc")).toBe("12abc");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { parseCsv } from "./csvParser";
export interface NormalizedImport {
headers: string[];
rows: string[][];
}
export function normalizeImport(text: string): NormalizedImport {
const trimmed = text.trim();
if (trimmed === "") throw new Error("Import input is empty");
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error("Invalid JSON");
}
let rows: Record<string, unknown>[];
if (Array.isArray(parsed)) {
rows = parsed as Record<string, unknown>[];
} else if (parsed && typeof parsed === "object") {
const values = Object.values(parsed);
const arrays = values.filter((v): v is Record<string, unknown>[] => Array.isArray(v));
if (arrays.length === 1 && values.length === 1) {
rows = arrays[0];
} else {
rows = [parsed as Record<string, unknown>];
}
} else {
throw new Error("JSON import must be an array of objects or an object");
}
if (rows.length === 0) return { headers: [], rows: [] };
const headers = Object.keys(rows[0] ?? {});
const data = rows.map((r) => headers.map((h) => String((r as Record<string, unknown>)[h] ?? "")));
return { headers, rows: data };
}
return parseCsv(text);
}
export function coerceRow(v: string): unknown {
if (v === "") return null;
if (v === "true") return true;
if (v === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
return v;
}
+22
View File
@@ -15,6 +15,7 @@ import type {
TableNode,
GraphColumn,
Relationship,
Settings,
} from "./types";
describe("ActiveView", () => {
@@ -97,6 +98,13 @@ describe("Connection", () => {
});
describe("ConnectionInput", () => {
it("accepts ssh_password", () => {
const input: ConnectionInput = {
name: "x", db_type: "postgresql", host: "h", port: 5432, ssh_password: "ssh-pw",
};
expect(input.ssh_password).toBe("ssh-pw");
});
it("accepts all SSH/SSL fields", () => {
const input: ConnectionInput = {
name: "Test",
@@ -430,4 +438,18 @@ describe("Schema graph types", () => {
};
expect(col.fk_ref).toBeNull();
});
});
describe("Settings", () => {
it("includes the five editor option fields", () => {
const s: Settings = {
confirm_before_delete: true, default_folder_id: null, theme: "dark",
font_size: "medium", default_ports: {}, tag_order: null, table_refresh_rate: 0,
table_page_size: 50, shortcuts: {}, accent_color: "#2563EB",
editor_font_size: 13, editor_font_family: "Space Mono",
editor_word_wrap: "off", editor_minimap: false, editor_tab_size: 4,
};
expect(s.editor_font_size).toBe(13);
expect(s.editor_word_wrap).toBe("off");
});
});
+9 -1
View File
@@ -68,6 +68,7 @@ export interface ConnectionInput {
ssh_user?: string | null;
ssh_auth_method?: "password" | "key" | null;
ssh_private_key_path?: string | null;
ssh_password?: string | null;
ssh_passphrase?: string | null;
// SSL/TLS fields
ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null;
@@ -98,6 +99,11 @@ export interface Settings {
table_page_size: number;
shortcuts: Record<string, string>;
accent_color: string;
editor_font_size: number;
editor_font_family: string;
editor_word_wrap: "off" | "on";
editor_minimap: boolean;
editor_tab_size: number;
}
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
@@ -159,7 +165,9 @@ export type ChangeItemType =
| "update"
| "delete"
| "create_index"
| "drop_index";
| "drop_index"
| "bulk_insert"
| "empty_table";
export interface ChangeItem {
type: ChangeItemType;
+22
View File
@@ -7,6 +7,7 @@ import {
getDescendantFolderIds,
getFolderPathLabel,
isDestructiveQuery,
isSchemaModifyingQuery,
pickDefaultSchema,
} from "./utils";
import type { Connection, Folder, Tag } from "./types";
@@ -414,4 +415,25 @@ describe("pickDefaultSchema", () => {
it("returns null for an empty list", () => {
expect(pickDefaultSchema([])).toBeNull();
});
});
describe("isSchemaModifyingQuery", () => {
it("returns true for CREATE / DROP / ALTER / TRUNCATE", () => {
expect(isSchemaModifyingQuery("CREATE TABLE t (id int)")).toBe(true);
expect(isSchemaModifyingQuery("DROP TABLE t")).toBe(true);
expect(isSchemaModifyingQuery("ALTER TABLE t ADD COLUMN c int")).toBe(true);
expect(isSchemaModifyingQuery("TRUNCATE TABLE t")).toBe(true);
});
it("returns false for data-only and read statements", () => {
expect(isSchemaModifyingQuery("SELECT * FROM t")).toBe(false);
expect(isSchemaModifyingQuery("INSERT INTO t VALUES (1)")).toBe(false);
expect(isSchemaModifyingQuery("UPDATE t SET c = 1")).toBe(false);
expect(isSchemaModifyingQuery("DELETE FROM t")).toBe(false);
expect(isSchemaModifyingQuery("REPLACE INTO t VALUES (1)")).toBe(false);
expect(isSchemaModifyingQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
});
it("strips comments before checking", () => {
expect(isSchemaModifyingQuery("-- note\nCREATE TABLE t (id int)")).toBe(true);
expect(isSchemaModifyingQuery("/* x */ SELECT 1")).toBe(false);
});
});
+31 -9
View File
@@ -149,6 +149,21 @@ const DESTRUCTIVE_KEYWORDS = new Set([
"TRUNCATE", "CREATE", "REPLACE",
]);
/**
* Return the first significant keyword of `sql` (uppercased) after stripping
* comments and collapsing whitespace, or null when there is none.
*/
export function firstSignificantKeyword(sql: string): string | null {
// Strip block comments /* ... */
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
// Strip line comments -- ...
stripped = stripped.replace(/--[^\n]*/g, " ");
// Collapse whitespace
const tokens = stripped.trim().split(/\s+/);
if (tokens.length === 0 || tokens[0].length === 0) return null;
return tokens[0].toUpperCase();
}
/**
* Detect whether `sql` is a data-modifying statement by checking the
* first significant keyword after stripping comments and whitespace.
@@ -158,15 +173,22 @@ const DESTRUCTIVE_KEYWORDS = new Set([
* accidental data loss, not malicious access.
*/
export function isDestructiveQuery(sql: string): boolean {
// Strip block comments /* ... */
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
// Strip line comments -- ...
stripped = stripped.replace(/--[^\n]*/g, " ");
// Collapse whitespace
const tokens = stripped.trim().split(/\s+/);
if (tokens.length === 0 || tokens[0].length === 0) return false;
const first = tokens[0].toUpperCase();
return DESTRUCTIVE_KEYWORDS.has(first);
const first = firstSignificantKeyword(sql);
return first !== null && DESTRUCTIVE_KEYWORDS.has(first);
}
const SCHEMA_MODIFYING_KEYWORDS = new Set([
"CREATE", "DROP", "ALTER", "TRUNCATE",
]);
/**
* Detect whether `sql` changes the database schema (DDL) by checking the
* first significant keyword. Used to auto-refresh the schema tree after a
* successful query run.
*/
export function isSchemaModifyingQuery(sql: string): boolean {
const k = firstSignificantKeyword(sql);
return k !== null && SCHEMA_MODIFYING_KEYWORDS.has(k);
}
/**