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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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", ""]] });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user