From c3b4467d62ab3a48681ce4cc2af0a7416bcce01e Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Thu, 2 Apr 2026 14:56:46 +0800 Subject: [PATCH 01/15] test: expand frontend unit test coverage across core modules Add unit tests for previously uncovered logic in the frontend layer: - api.ts: isTauri(), normalizeImportDriver(), getImportDriverCapability(), invoke routing (Tauri path, mock mode, error fallback), and command name mapping for all API namespaces - DataGrid/tableView/utils.ts: sortRows, collectSearchMatches, escapeSQL, quoteIdent, calculateAutoColumnWidths, and additional formatSQLValue cases - connection-form/rules.ts: isMysqlFamilyDriver, isFileBasedDriver, allowsHostWithPort, requiresPasswordOnCreate, normalizeTextValue - connection-form/validate.ts: happy paths, required field validation, password rules per driver and mode, host format checks - sqlEditorDatabase.ts: normalizeDatabaseOptions and resolvePreferredDatabase edge cases (empty list, no fallback, all-undefined params) - keyboard.ts: isModKey boolean combinations and null target guard - Sidebar/connection-list/helpers.ts: sanitizeConnectionErrorMessage, getExportFilter, and getConnectionStatusLabel Co-Authored-By: Claude Sonnet 4.6 --- .../DataGrid/tableView/utils.unit.test.ts | 175 +++++++++++ .../connection-list/helpers.unit.test.ts | 92 ++++++ src/lib/connection-form/rules.unit.test.ts | 67 ++++- src/lib/connection-form/validate.unit.test.ts | 99 +++++++ src/lib/keyboard.unit.test.ts | 29 ++ src/lib/sqlEditorDatabase.unit.test.ts | 40 +++ src/services/api.unit.test.ts | 273 ++++++++++++++++++ 7 files changed, 773 insertions(+), 2 deletions(-) create mode 100644 src/components/business/Sidebar/connection-list/helpers.unit.test.ts create mode 100644 src/lib/keyboard.unit.test.ts create mode 100644 src/services/api.unit.test.ts diff --git a/src/components/business/DataGrid/tableView/utils.unit.test.ts b/src/components/business/DataGrid/tableView/utils.unit.test.ts index d30b84de..552c3b01 100644 --- a/src/components/business/DataGrid/tableView/utils.unit.test.ts +++ b/src/components/business/DataGrid/tableView/utils.unit.test.ts @@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test"; import { buildDeleteStatement, buildUpdateStatement, + calculateAutoColumnWidths, canMutateClickHouseTable, + collectSearchMatches, + escapeSQL, formatInsertSQLValue, formatSQLValue, getQualifiedTableName, isClickHouseMergeTreeEngine, isInsertColumnRequired, + quoteIdent, + sortRows, } from "./utils"; describe("formatSQLValue", () => { @@ -157,6 +162,176 @@ describe("clickhouse mutation guards", () => { }); }); +describe("formatSQLValue: additional cases", () => { + test("maps empty string with null/undefined originalValue to NULL", () => { + expect(formatSQLValue("", null, "execution")).toBe("NULL"); + expect(formatSQLValue("", undefined, "execution")).toBe("NULL"); + }); + + test("returns trimmed numeric for number originalValue", () => { + expect(formatSQLValue("42", 42, "execution")).toBe("42"); + expect(formatSQLValue("-3.14", 3.14, "execution")).toBe("-3.14"); + }); + + test("throws in execution mode for invalid number originalValue", () => { + expect(() => formatSQLValue("abc", 99, "execution")).toThrow( + 'Invalid numeric value: "abc"', + ); + }); + + test("does not throw in copy mode for invalid boolean", () => { + expect(() => formatSQLValue("yes", true, "copy")).not.toThrow(); + }); + + test("quotes plain string values and escapes single quotes", () => { + expect(formatSQLValue("hello", "hello", "execution")).toBe("'hello'"); + expect(formatSQLValue("it's", "it's", "execution")).toBe("'it''s'"); + }); +}); + +describe("escapeSQL", () => { + test("doubles single quotes", () => { + expect(escapeSQL("it's")).toBe("it''s"); + expect(escapeSQL("''")).toBe("''''"); + }); + + test("passes through strings without single quotes unchanged", () => { + expect(escapeSQL("hello world")).toBe("hello world"); + expect(escapeSQL("")).toBe(""); + }); +}); + +describe("quoteIdent", () => { + test("uses backticks for mysql family and clickhouse", () => { + expect(quoteIdent("mysql", "my_table")).toBe("`my_table`"); + expect(quoteIdent("tidb", "my_table")).toBe("`my_table`"); + expect(quoteIdent("mariadb", "my_table")).toBe("`my_table`"); + expect(quoteIdent("clickhouse", "my_table")).toBe("`my_table`"); + }); + + test("uses brackets for mssql and escapes ] inside name", () => { + expect(quoteIdent("mssql", "my_table")).toBe("[my_table]"); + expect(quoteIdent("mssql", "tab]le")).toBe("[tab]]le]"); + }); + + test("uses double quotes for other drivers", () => { + expect(quoteIdent("postgres", "my_table")).toBe('"my_table"'); + expect(quoteIdent("sqlite", "my_table")).toBe('"my_table"'); + expect(quoteIdent(undefined, "my_table")).toBe('"my_table"'); + }); +}); + +describe("sortRows", () => { + const rows = [ + { id: 3, name: "charlie" }, + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]; + + test("returns original data when no sort parameters given", () => { + expect(sortRows(rows)).toBe(rows); + expect(sortRows(rows, "id")).toBe(rows); + }); + + test("sorts numeric column ascending", () => { + const sorted = sortRows(rows, "id", "asc"); + expect(sorted.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + test("sorts numeric column descending", () => { + const sorted = sortRows(rows, "id", "desc"); + expect(sorted.map((r) => r.id)).toEqual([3, 2, 1]); + }); + + test("sorts string column ascending", () => { + const sorted = sortRows(rows, "name", "asc"); + expect(sorted.map((r) => r.name)).toEqual(["alice", "bob", "charlie"]); + }); + + test("places null values at the end", () => { + const data = [{ v: null }, { v: 2 }, { v: 1 }]; + const sorted = sortRows(data, "v", "asc"); + expect(sorted[sorted.length - 1].v).toBeNull(); + }); + + test("does not mutate original array", () => { + const original = [...rows]; + sortRows(rows, "id", "asc"); + expect(rows).toEqual(original); + }); +}); + +describe("collectSearchMatches", () => { + const data = [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + { id: 3, name: "alice" }, + ]; + const columns = ["id", "name"]; + const identity = (_row: number, _col: string, val: any) => val; + + test("returns empty array for empty keyword", () => { + expect(collectSearchMatches(data, columns, "", identity)).toEqual([]); + }); + + test("finds matches across rows and columns", () => { + const matches = collectSearchMatches(data, columns, "alice", identity); + expect(matches.length).toBe(2); + expect(matches.map((m) => m.row)).toEqual([0, 2]); + }); + + test("uses getCellDisplayValue result for comparison", () => { + const display = (_row: number, col: string, val: any) => + col === "id" ? `ID:${val}` : val; + const matches = collectSearchMatches(data, columns, "id:1", display); + expect(matches.length).toBe(1); + expect(matches[0].col).toBe("id"); + }); + + test("skips null and undefined cell values", () => { + const withNulls = [{ id: null, name: undefined }]; + const matches = collectSearchMatches(withNulls, ["id", "name"], "null", identity); + expect(matches).toEqual([]); + }); +}); + +describe("calculateAutoColumnWidths", () => { + test("returns empty object for empty data or columns", () => { + expect(calculateAutoColumnWidths({ data: [], columns: ["a"], columnWidths: {} })).toEqual({}); + expect(calculateAutoColumnWidths({ data: [{ a: 1 }], columns: [], columnWidths: {} })).toEqual({}); + }); + + test("skips columns with a pre-set width", () => { + const result = calculateAutoColumnWidths({ + data: [{ a: "hello" }], + columns: ["a"], + columnWidths: { a: 200 }, + }); + expect(result).toEqual({}); + }); + + test("computes width and respects min/max bounds", () => { + const result = calculateAutoColumnWidths({ + data: [{ col: "x" }], + columns: ["col"], + columnWidths: {}, + }); + expect(result["col"]).toBeGreaterThanOrEqual(100); + expect(result["col"]).toBeLessThanOrEqual(900); + }); + + test("caps sampled data length at 100 characters", () => { + const longValue = "a".repeat(200); + const result = calculateAutoColumnWidths({ + data: [{ col: longValue }], + columns: ["col"], + columnWidths: {}, + }); + // cap at 100 chars → 100 * 9 + 36 = 936 → capped at 900 + expect(result["col"]).toBe(900); + }); +}); + describe("mutation statement builders", () => { test("builds clickhouse alter update/delete statements", () => { expect( diff --git a/src/components/business/Sidebar/connection-list/helpers.unit.test.ts b/src/components/business/Sidebar/connection-list/helpers.unit.test.ts new file mode 100644 index 00000000..b01ccde9 --- /dev/null +++ b/src/components/business/Sidebar/connection-list/helpers.unit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { + sanitizeConnectionErrorMessage, + getExportFilter, + getConnectionStatusLabel, +} from "./helpers"; + +describe("sanitizeConnectionErrorMessage", () => { + test("strips leading bracketed tags", () => { + expect(sanitizeConnectionErrorMessage("[ERROR] connection refused")).toBe( + "connection refused", + ); + }); + + test("strips multiple consecutive bracketed tags", () => { + expect( + sanitizeConnectionErrorMessage("[DB][CONN] authentication failed"), + ).toBe("authentication failed"); + }); + + test("leaves messages without leading tags unchanged", () => { + expect(sanitizeConnectionErrorMessage("timeout after 30s")).toBe( + "timeout after 30s", + ); + }); + + test("trims whitespace after stripping tags", () => { + expect(sanitizeConnectionErrorMessage("[TAG] message ")).toBe("message"); + }); + + test("returns empty string for empty input", () => { + expect(sanitizeConnectionErrorMessage("")).toBe(""); + }); + + test("does not strip tags that appear mid-message", () => { + expect( + sanitizeConnectionErrorMessage("failed: [REASON] bad password"), + ).toBe("failed: [REASON] bad password"); + }); +}); + +describe("getExportFilter", () => { + test("returns csv filter for csv format", () => { + const filter = getExportFilter("csv"); + expect(filter).toEqual([{ name: "CSV", extensions: ["csv"] }]); + }); + + test("returns json filter for json format", () => { + const filter = getExportFilter("json"); + expect(filter).toEqual([{ name: "JSON", extensions: ["json"] }]); + }); + + test("returns sql filter for sql format", () => { + const filter = getExportFilter("sql"); + expect(filter).toEqual([{ name: "SQL", extensions: ["sql"] }]); + }); +}); + +describe("getConnectionStatusLabel", () => { + test("returns 'Connected' for success state", () => { + expect(getConnectionStatusLabel({ connectState: "success" })).toBe( + "Connected", + ); + }); + + test("returns 'Connection failed' for error state without message", () => { + expect(getConnectionStatusLabel({ connectState: "error" })).toBe( + "Connection failed", + ); + }); + + test("includes error message when provided", () => { + expect( + getConnectionStatusLabel({ + connectState: "error", + connectError: "timeout", + }), + ).toBe("Connection failed: timeout"); + }); + + test("returns 'Connecting' for connecting state", () => { + expect(getConnectionStatusLabel({ connectState: "connecting" })).toBe( + "Connecting", + ); + }); + + test("returns 'Not connected' for idle state", () => { + expect(getConnectionStatusLabel({ connectState: "idle" })).toBe( + "Not connected", + ); + }); +}); diff --git a/src/lib/connection-form/rules.unit.test.ts b/src/lib/connection-form/rules.unit.test.ts index c225fe9f..ee3ee846 100644 --- a/src/lib/connection-form/rules.unit.test.ts +++ b/src/lib/connection-form/rules.unit.test.ts @@ -1,10 +1,73 @@ import { describe, expect, test } from "bun:test"; import { - parseHostEmbeddedPort, - normalizePortNumber, + allowsHostWithPort, + isFileBasedDriver, + isMysqlFamilyDriver, normalizeConnectionFormInput, + normalizePortNumber, + normalizeTextValue, + parseHostEmbeddedPort, + requiresPasswordOnCreate, } from "./rules"; +describe("isMysqlFamilyDriver", () => { + test("recognizes mysql family", () => { + expect(isMysqlFamilyDriver("mysql")).toBe(true); + expect(isMysqlFamilyDriver("mariadb")).toBe(true); + expect(isMysqlFamilyDriver("tidb")).toBe(true); + }); + + test("rejects non-mysql drivers", () => { + expect(isMysqlFamilyDriver("postgres")).toBe(false); + expect(isMysqlFamilyDriver("sqlite")).toBe(false); + }); +}); + +describe("isFileBasedDriver", () => { + test("recognizes file-based drivers", () => { + expect(isFileBasedDriver("sqlite")).toBe(true); + expect(isFileBasedDriver("duckdb")).toBe(true); + }); + + test("rejects network drivers", () => { + expect(isFileBasedDriver("mysql")).toBe(false); + expect(isFileBasedDriver("postgres")).toBe(false); + }); +}); + +describe("allowsHostWithPort / requiresPasswordOnCreate", () => { + test("only mysql family allows host:port notation", () => { + expect(allowsHostWithPort("mysql")).toBe(true); + expect(allowsHostWithPort("postgres")).toBe(false); + }); + + test("non-mysql drivers require password on create", () => { + expect(requiresPasswordOnCreate("postgres")).toBe(true); + expect(requiresPasswordOnCreate("mysql")).toBe(false); + }); +}); + +describe("normalizeTextValue", () => { + test("returns undefined for undefined and null", () => { + expect(normalizeTextValue(undefined)).toBeUndefined(); + expect(normalizeTextValue(null as any)).toBeUndefined(); + }); + + test("returns undefined for blank strings when emptyToUndefined is true (default)", () => { + expect(normalizeTextValue("")).toBeUndefined(); + expect(normalizeTextValue(" ")).toBeUndefined(); + }); + + test("returns empty string for blank when emptyToUndefined is false", () => { + expect(normalizeTextValue("", false)).toBe(""); + expect(normalizeTextValue(" ", false)).toBe(""); + }); + + test("trims surrounding whitespace", () => { + expect(normalizeTextValue(" hello ")).toBe("hello"); + }); +}); + describe("parseHostEmbeddedPort", () => { test("parses host:port when valid", () => { expect(parseHostEmbeddedPort("127.0.0.1:3306", undefined)).toEqual({ diff --git a/src/lib/connection-form/validate.unit.test.ts b/src/lib/connection-form/validate.unit.test.ts index bff279b5..cb9b7a26 100644 --- a/src/lib/connection-form/validate.unit.test.ts +++ b/src/lib/connection-form/validate.unit.test.ts @@ -9,6 +9,105 @@ const baseForm = { password: "pwd", }; +describe("validateConnectionFormInput: happy paths", () => { + test("valid postgres form returns no issues", () => { + expect(validateConnectionFormInput(baseForm as any, "create")).toEqual([]); + }); + + test("valid sqlite form with filePath returns no issues", () => { + expect( + validateConnectionFormInput( + { driver: "sqlite", filePath: "/data/app.db" } as any, + "create", + ), + ).toEqual([]); + }); +}); + +describe("validateConnectionFormInput: required fields", () => { + test("requires host for network drivers", () => { + const issues = validateConnectionFormInput( + { ...baseForm, host: "" } as any, + "create", + ); + expect(issues.map((i) => i.key)).toContain( + "connection.dialog.inputValidation.hostRequired", + ); + }); + + test("requires username for network drivers", () => { + const issues = validateConnectionFormInput( + { ...baseForm, username: "" } as any, + "create", + ); + expect(issues.map((i) => i.key)).toContain( + "connection.dialog.inputValidation.usernameRequired", + ); + }); + + test("rejects out-of-range port", () => { + const issues = validateConnectionFormInput( + { ...baseForm, port: 99999 } as any, + "create", + ); + expect(issues.map((i) => i.key)).toContain( + "connection.dialog.inputValidation.portRange", + ); + }); + + test("requires password on create for non-mysql drivers", () => { + const issues = validateConnectionFormInput( + { ...baseForm, password: "" } as any, + "create", + ); + expect(issues.map((i) => i.key)).toContain( + "connection.dialog.inputValidation.passwordRequired", + ); + }); + + test("does not require password for mysql family on create", () => { + const keys = validateConnectionFormInput( + { ...baseForm, driver: "mysql", password: "" } as any, + "create", + ).map((i) => i.key); + expect(keys).not.toContain( + "connection.dialog.inputValidation.passwordRequired", + ); + }); + + test("does not require password in edit mode", () => { + const keys = validateConnectionFormInput( + { ...baseForm, password: "" } as any, + "edit", + ).map((i) => i.key); + expect(keys).not.toContain( + "connection.dialog.inputValidation.passwordRequired", + ); + }); +}); + +describe("validateConnectionFormInput: host format", () => { + test("rejects host with whitespace", () => { + const issues = validateConnectionFormInput( + { ...baseForm, host: "my host" } as any, + "create", + ); + expect(issues.map((i) => i.key)).toContain( + "connection.dialog.inputValidation.hostWhitespace", + ); + }); + + test("does not reject IPv6 bracket notation", () => { + const keys = validateConnectionFormInput( + { ...baseForm, host: "[::1]" } as any, + "create", + ).map((i) => i.key); + expect(keys).not.toContain( + "connection.dialog.inputValidation.hostPortNotAllowed", + ); + }); +}); + describe("validateConnectionFormInput", () => { test("requires filePath for file-based drivers", () => { const issues = validateConnectionFormInput( diff --git a/src/lib/keyboard.unit.test.ts b/src/lib/keyboard.unit.test.ts new file mode 100644 index 00000000..d0ae9173 --- /dev/null +++ b/src/lib/keyboard.unit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { isModKey, isEditableTarget } from "./keyboard"; + +describe("isModKey", () => { + test("returns true when metaKey is set", () => { + expect(isModKey({ metaKey: true, ctrlKey: false })).toBe(true); + }); + + test("returns true when ctrlKey is set", () => { + expect(isModKey({ metaKey: false, ctrlKey: true })).toBe(true); + }); + + test("returns true when both are set", () => { + expect(isModKey({ metaKey: true, ctrlKey: true })).toBe(true); + }); + + test("returns false when neither is set", () => { + expect(isModKey({ metaKey: false, ctrlKey: false })).toBe(false); + }); +}); + +describe("isEditableTarget", () => { + test("returns false for null target", () => { + expect(isEditableTarget(null)).toBe(false); + }); + + // Note: further branches (HTMLElement.isContentEditable, element.closest) require + // a DOM environment (jsdom/happy-dom) and are covered by integration/E2E tests. +}); diff --git a/src/lib/sqlEditorDatabase.unit.test.ts b/src/lib/sqlEditorDatabase.unit.test.ts index 06e3f733..c883c1b8 100644 --- a/src/lib/sqlEditorDatabase.unit.test.ts +++ b/src/lib/sqlEditorDatabase.unit.test.ts @@ -11,6 +11,26 @@ describe("normalizeDatabaseOptions", () => { normalizeDatabaseOptions([" app ", "analytics", "app"], "archive"), ).toEqual(["archive", "app", "analytics"]); }); + + test("returns empty array for empty input and no fallback", () => { + expect(normalizeDatabaseOptions([])).toEqual([]); + }); + + test("filters out blank names", () => { + expect(normalizeDatabaseOptions(["", " ", "app"])).toEqual(["app"]); + }); + + test("does not prepend fallback if it already exists in list", () => { + expect(normalizeDatabaseOptions(["app", "analytics"], "app")).toEqual([ + "app", + "analytics", + ]); + }); + + test("does not prepend blank fallback", () => { + expect(normalizeDatabaseOptions(["app"], "")).toEqual(["app"]); + expect(normalizeDatabaseOptions(["app"], " ")).toEqual(["app"]); + }); }); describe("resolvePreferredDatabase", () => { @@ -43,4 +63,24 @@ describe("resolvePreferredDatabase", () => { }), ).toBe("analytics"); }); + + test("returns preferred when no available databases list provided", () => { + expect( + resolvePreferredDatabase({ + preferredDatabase: "mydb", + }), + ).toBe("mydb"); + }); + + test("returns connectionDatabase when no preferred and no available list", () => { + expect( + resolvePreferredDatabase({ + connectionDatabase: "defaultdb", + }), + ).toBe("defaultdb"); + }); + + test("returns undefined when everything is empty", () => { + expect(resolvePreferredDatabase({})).toBeUndefined(); + }); }); diff --git a/src/services/api.unit.test.ts b/src/services/api.unit.test.ts new file mode 100644 index 00000000..8d63dee1 --- /dev/null +++ b/src/services/api.unit.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"; + +// Must be declared before importing the module under test so bun resolves mocks first +let tauriInvokeImpl: (cmd: string, args?: any) => Promise = async () => + "tauri-result"; +mock.module("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: any) => tauriInvokeImpl(cmd, args), +})); + +let mockInvokeImpl: (cmd: string, args?: any) => Promise = async () => + "mock-result"; +mock.module("./mocks", () => ({ + invokeMock: (cmd: string, args?: any) => mockInvokeImpl(cmd, args), +})); + +import { + isTauri, + normalizeImportDriver, + getImportDriverCapability, + api, +} from "./api"; + +// Bun test runs in a Node-like env without a DOM. +// isTauri() checks `typeof window !== "undefined"`, so we simulate it via globalThis. +const g = globalThis as any; + +// ─── isTauri ───────────────────────────────────────────────────────────────── + +describe("isTauri", () => { + afterEach(() => { + delete g.window; + }); + + test("returns false when window is undefined", () => { + delete g.window; + expect(isTauri()).toBe(false); + }); + + test("returns false when window exists but __TAURI_INTERNALS__ is absent", () => { + g.window = {}; + expect(isTauri()).toBe(false); + }); + + test("returns true when __TAURI_INTERNALS__ is present on window", () => { + g.window = { __TAURI_INTERNALS__: {} }; + expect(isTauri()).toBe(true); + }); +}); + +// ─── normalizeImportDriver ─────────────────────────────────────────────────── + +describe("normalizeImportDriver", () => { + test("normalizes postgresql aliases to postgres", () => { + expect(normalizeImportDriver("postgresql")).toBe("postgres"); + expect(normalizeImportDriver("pgsql")).toBe("postgres"); + }); + + test("is case-insensitive and trims whitespace", () => { + expect(normalizeImportDriver("PostgreSQL")).toBe("postgres"); + expect(normalizeImportDriver(" PGSQL ")).toBe("postgres"); + expect(normalizeImportDriver(" MySQL ")).toBe("mysql"); + }); + + test("passes through known drivers unchanged", () => { + const passThrough = [ + "postgres", + "mysql", + "mariadb", + "tidb", + "sqlite", + "duckdb", + "mssql", + "clickhouse", + ]; + for (const driver of passThrough) { + expect(normalizeImportDriver(driver)).toBe(driver); + } + }); + + test("returns empty string for empty / falsy input", () => { + expect(normalizeImportDriver("")).toBe(""); + expect(normalizeImportDriver(null as any)).toBe(""); + expect(normalizeImportDriver(undefined as any)).toBe(""); + }); +}); + +// ─── getImportDriverCapability ─────────────────────────────────────────────── + +describe("getImportDriverCapability", () => { + test("clickhouse is read-only-not-supported", () => { + expect(getImportDriverCapability("clickhouse")).toBe( + "read_only_not_supported", + ); + }); + + test("all writable drivers are supported", () => { + const supported = [ + "postgres", + "postgresql", // via normalizeImportDriver + "pgsql", + "mysql", + "mariadb", + "tidb", + "sqlite", + "duckdb", + "mssql", + ]; + for (const driver of supported) { + expect(getImportDriverCapability(driver)).toBe("supported"); + } + }); + + test("unknown drivers are unsupported", () => { + expect(getImportDriverCapability("oracle")).toBe("unsupported"); + expect(getImportDriverCapability("")).toBe("unsupported"); + expect(getImportDriverCapability("mongodb")).toBe("unsupported"); + }); +}); + +// ─── invoke routing ────────────────────────────────────────────────────────── + +describe("invoke: no Tauri + no mock mode", () => { + beforeEach(() => { + delete g.window; + delete (import.meta.env as any).VITE_USE_MOCK; + }); + + test("throws a descriptive error", async () => { + await expect(api.connections.list()).rejects.toThrow( + "Tauri API not available", + ); + }); + + test("error message mentions bun tauri dev", async () => { + let msg = ""; + try { + await api.connections.list(); + } catch (e) { + msg = (e as Error).message; + } + expect(msg).toContain("bun tauri dev"); + }); +}); + +describe("invoke: Tauri environment", () => { + beforeEach(() => { + g.window = { __TAURI_INTERNALS__: {} }; + }); + + afterEach(() => { + delete g.window; + tauriInvokeImpl = async () => "tauri-result"; + }); + + test("delegates to tauriInvoke and returns its result", async () => { + const expected = [{ id: 1 }]; + tauriInvokeImpl = async (cmd) => { + expect(cmd).toBe("get_connections"); + return expected; + }; + const result = await api.connections.list(); + expect(result).toBe(expected); + }); + + test("forwards command name correctly for query.execute", async () => { + let capturedCmd = ""; + let capturedArgs: any = null; + tauriInvokeImpl = async (cmd, args) => { + capturedCmd = cmd; + capturedArgs = args; + return { data: [], rowCount: 0, columns: [], timeTakenMs: 0, success: true }; + }; + + await api.query.execute(42, "SELECT 1", "mydb", "sql_editor"); + + expect(capturedCmd).toBe("execute_query"); + expect(capturedArgs).toMatchObject({ id: 42, query: "SELECT 1", database: "mydb" }); + }); + + test("tauriInvoke error propagates to caller", async () => { + tauriInvokeImpl = async () => { + throw new Error("connection refused"); + }; + await expect(api.connections.list()).rejects.toThrow("connection refused"); + }); +}); + +describe("invoke: mock mode (VITE_USE_MOCK=true)", () => { + beforeEach(() => { + delete g.window; + (import.meta.env as any).VITE_USE_MOCK = "true"; + }); + + afterEach(() => { + delete (import.meta.env as any).VITE_USE_MOCK; + mockInvokeImpl = async () => "mock-result"; + }); + + test("delegates to invokeMock and returns its result", async () => { + const expected = [{ id: 99, name: "mock-conn" }]; + mockInvokeImpl = async (cmd) => { + expect(cmd).toBe("get_connections"); + return expected; + }; + const result = await api.connections.list(); + expect(result).toBe(expected); + }); + + test("invokeMock error propagates to caller", async () => { + mockInvokeImpl = async () => { + throw new Error("mock error"); + }; + await expect(api.connections.list()).rejects.toThrow("mock error"); + }); + + test("Tauri path takes precedence over mock mode when both are active", async () => { + g.window = { __TAURI_INTERNALS__: {} }; + let usedTauri = false; + tauriInvokeImpl = async () => { + usedTauri = true; + return []; + }; + mockInvokeImpl = async () => { + throw new Error("should not be called"); + }; + + await api.connections.list(); + expect(usedTauri).toBe(true); + + delete g.window; + }); +}); + +// ─── api command mapping spot-checks ───────────────────────────────────────── + +describe("api command mapping", () => { + beforeEach(() => { + g.window = { __TAURI_INTERNALS__: {} }; + }); + + afterEach(() => { + delete g.window; + tauriInvokeImpl = async () => undefined; + }); + + const commands: [string, () => Promise][] = [ + ["list_sql_execution_logs", () => api.sqlLogs.list()], + ["list_tables", () => api.metadata.listTables(1)], + ["get_table_ddl", () => api.metadata.getTableDDL(1, "db", "public", "t")], + ["get_table_metadata", () => api.metadata.getTableMetadata(1, "db", "public", "t")], + ["get_connections", () => api.connections.list()], + ["create_connection", () => api.connections.create({ driver: "postgres" })], + ["delete_connection", () => api.connections.delete(1)], + ["get_saved_queries", () => api.queries.list()], + ["delete_saved_query", () => api.queries.delete(1)], + ["ai_list_providers", () => api.ai.providers.list()], + ["ai_delete_provider", () => api.ai.providers.delete(1)], + ["ai_list_conversations", () => api.ai.conversations.list()], + ["cancel_query", () => api.query.cancel("uuid-abc", "qid-1")], + ]; + + for (const [expectedCmd, callFn] of commands) { + test(`api method maps to Tauri command "${expectedCmd}"`, async () => { + let captured = ""; + tauriInvokeImpl = async (cmd) => { + captured = cmd; + return undefined; + }; + await callFn().catch(() => {}); + expect(captured).toBe(expectedCmd); + }); + } +}); From 934001b13e9468d30d9a1b5524755435267b6426 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Thu, 2 Apr 2026 14:57:23 +0800 Subject: [PATCH 02/15] fix: handle PolarDB-X prepared statement protocol error PolarDB-X returns a vendor-specific error message containing "Preparedoes not support" rather than the standard error code 1295, causing queries to fail instead of falling back to simple protocol. Extend is_prepared_protocol_unsupported_error() to match this variant and add a regression test with the exact PolarDB-X error string. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/db/drivers/mysql.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/db/drivers/mysql.rs b/src-tauri/src/db/drivers/mysql.rs index f1533bb0..4fa891a4 100644 --- a/src-tauri/src/db/drivers/mysql.rs +++ b/src-tauri/src/db/drivers/mysql.rs @@ -167,9 +167,12 @@ fn cleanup_ca_file_opt(path: Option<&PathBuf>) { fn is_prepared_protocol_unsupported_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); - lower.contains("1295") || lower.contains("prepared statement protocol") + lower.contains("1295") + || lower.contains("prepared statement protocol") + || lower.contains("preparedoes not support") // PolarDB-X } + impl Drop for MysqlDriver { fn drop(&mut self) { cleanup_ca_file_opt(self.ca_cert_path.as_ref()); @@ -1364,6 +1367,9 @@ mod tests { assert!(is_prepared_protocol_unsupported_error( "prepared statement protocol is unsupported" )); + assert!(is_prepared_protocol_unsupported_error( + "error returned from database: 0 (HYo00):[1b6d607a89402000][10.233.70.102:3306][polardbx]Preparedoes not support sql: SELECT 1" + )); assert!(!is_prepared_protocol_unsupported_error( "syntax error near ...", )); From 9c34fc26749f7628e768650bc30ef18c81deaccf Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Thu, 2 Apr 2026 15:40:57 +0800 Subject: [PATCH 03/15] refactor: centralize driver metadata in driver-registry.tsx Create src/lib/driver-registry.tsx as the single source of truth for all driver attributes (ports, flags, icons, import capability). Update api.ts, rules.ts, helpers.tsx, ConnectionList.tsx, and SavedQueriesList.tsx to derive data from the registry instead of inline arrays/switch statements. Adding a new driver now requires editing only one file. Add driver-registry.unit.test.ts with 28 test cases covering all helper functions and registry invariants. Co-Authored-By: Claude Sonnet 4.6 --- .../business/Sidebar/ConnectionList.tsx | 87 ++---- .../business/Sidebar/SavedQueriesList.tsx | 58 +--- .../Sidebar/connection-list/helpers.tsx | 62 +--- src/lib/connection-form/rules.ts | 13 +- src/lib/driver-registry.tsx | 185 ++++++++++++ src/lib/driver-registry.unit.test.ts | 280 ++++++++++++++++++ src/services/api.ts | 37 +-- 7 files changed, 510 insertions(+), 212 deletions(-) create mode 100644 src/lib/driver-registry.tsx create mode 100644 src/lib/driver-registry.unit.test.ts diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index 98dc5b1f..fbf469e1 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -61,10 +61,19 @@ import type { Driver, SavedQuery, } from "@/services/api"; +import { + DRIVER_REGISTRY, + getConnectionIcon, + getDefaultPort, + isFileBasedDriver, + supportsSSLCA, + isMysqlFamilyDriver, + supportsCreateDatabase, + supportsSchemaBrowsing, +} from "@/lib/driver-registry"; import { toast } from "sonner"; import { TreeNode } from "./connection-list/TreeNode"; import { - getConnectionIcon, getExportDefaultName, getExportFilter, renderConnectionStatusIndicator, @@ -149,14 +158,6 @@ const defaultForm: ConnectionForm = { sshUsername: "", }; -const createDatabaseSupportedDrivers: Driver[] = [ - "postgres", - "mysql", - "mariadb", - "tidb", - "clickhouse", - "mssql", -]; const defaultCreateDatabaseForm: CreateDatabaseForm = { name: "", @@ -190,7 +191,6 @@ const mssqlCollationOptions = [ "Chinese_PRC_CI_AS", "Japanese_CI_AS", ]; -const schemaNodeDrivers: Driver[] = ["postgres", "mssql"]; interface ConnectionListProps { onTableSelect?: ( connection: string, @@ -321,9 +321,9 @@ export function ConnectionList({ const [isImportConfirmOpen, setIsImportConfirmOpen] = useState(false); const supportsCreateDatabaseForDriver = (driver: Driver) => - createDatabaseSupportedDrivers.includes(driver); + supportsCreateDatabase(driver); const supportsSchemaNodeForDriver = (driver: Driver) => - schemaNodeDrivers.includes(driver); + supportsSchemaBrowsing(driver); const getSchemaNodeKey = (databaseKey: string, schema: string) => `${databaseKey}::${schema}`; const getTableNodeKey = ( @@ -501,20 +501,13 @@ export function ConnectionList({ [], ); - const isFileBased = form.driver === "sqlite" || form.driver === "duckdb"; - const supportsSslCa = - form.driver === "postgres" || - form.driver === "mysql" || - form.driver === "tidb" || - form.driver === "mariadb"; - const isPasswordRequiredOnCreate = useMemo(() => { + const isFileBased = isFileBasedDriver(form.driver); + const supportsSslCa = supportsSSLCA(form.driver); + const isPasswordRequiredOnCreate = useMemo( // MySQL-compatible engines (including TiDB and MariaDB) can be configured without password. - return ( - form.driver !== "mysql" && - form.driver !== "tidb" && - form.driver !== "mariadb" - ); - }, [form.driver]); + () => !isMysqlFamilyDriver(form.driver), + [form.driver], + ); const normalizedForm = useMemo( () => normalizeConnectionFormInput(form), [form], @@ -1780,20 +1773,7 @@ export function ConnectionList({ setForm((f) => ({ ...f, driver: v, - port: - v === "postgres" - ? 5432 - : v === "mysql" - ? 3306 - : v === "mariadb" - ? 3306 - : v === "tidb" - ? 4000 - : v === "clickhouse" - ? 8123 - : v === "mssql" - ? 1433 - : f.port, + port: getDefaultPort(v) ?? f.port, })) } > @@ -1805,14 +1785,11 @@ export function ConnectionList({ /> - PostgreSQL - MySQL - MariaDB - TiDB - SQLite - DuckDB - ClickHouse - SQL Server + {DRIVER_REGISTRY.map((d) => ( + + {d.label} + + ))} @@ -1851,19 +1828,9 @@ export function ConnectionList({ setForm((f) => ({ diff --git a/src/components/business/Sidebar/SavedQueriesList.tsx b/src/components/business/Sidebar/SavedQueriesList.tsx index a958b34a..cd7ba7b0 100644 --- a/src/components/business/Sidebar/SavedQueriesList.tsx +++ b/src/components/business/Sidebar/SavedQueriesList.tsx @@ -1,15 +1,7 @@ import { useEffect, useState } from "react"; import { api, SavedQuery, Driver } from "@/services/api"; import { Button } from "@/components/ui/button"; -import { - Database, - RefreshCw, - Trash2, - Edit3, - Search, - Server, - Plus, -} from "lucide-react"; +import { RefreshCw, Trash2, Edit3, Search, Plus } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Dialog, @@ -27,53 +19,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useTranslation } from "react-i18next"; -import { - siMysql, - siPostgresql, - siSqlite, - siDuckdb, - type SimpleIcon, -} from "simple-icons"; - -const renderSimpleIcon = (icon: SimpleIcon) => ( - -); - -const getConnectionIcon = (driver?: Driver): React.ReactNode => { - const normalized = String(driver || "") - .trim() - .toLowerCase(); - - switch (normalized) { - case "postgres": - case "postgresql": - case "pgsql": - return renderSimpleIcon(siPostgresql); - case "mysql": - case "tidb": - case "mariadb": - return renderSimpleIcon(siMysql); - case "sqlite": - case "sqlite3": - return renderSimpleIcon(siSqlite); - case "duckdb": - return renderSimpleIcon(siDuckdb); - case "clickhouse": - case "mssql": - return ; - default: - return ; - } -}; +import { getConnectionIcon } from "@/lib/driver-registry"; interface SavedQueriesListProps { onSelectQuery: (query: SavedQuery) => void; diff --git a/src/components/business/Sidebar/connection-list/helpers.tsx b/src/components/business/Sidebar/connection-list/helpers.tsx index 950ed017..b30c13d3 100644 --- a/src/components/business/Sidebar/connection-list/helpers.tsx +++ b/src/components/business/Sidebar/connection-list/helpers.tsx @@ -1,68 +1,12 @@ -import type { ReactNode } from "react"; -import { - Database, - Server, - CircleDot, - CheckCircle2, - XCircle, - Loader2, -} from "lucide-react"; -import { - siMysql, - siPostgresql, - siSqlite, - siClickhouse, - siDuckdb, - type SimpleIcon, -} from "simple-icons"; -import type { Driver } from "@/services/api"; +import { CircleDot, CheckCircle2, XCircle, Loader2 } from "lucide-react"; + +export { getConnectionIcon } from "@/lib/driver-registry"; export interface ConnectionStatusLike { connectState: "idle" | "connecting" | "success" | "error"; connectError?: string; } -const renderSimpleIcon = (icon: SimpleIcon) => ( - -); - -export const getConnectionIcon = (driver: Driver | string): ReactNode => { - const normalized = String(driver || "") - .trim() - .toLowerCase(); - - switch (normalized) { - case "postgres": - case "postgresql": - case "pgsql": - return renderSimpleIcon(siPostgresql); - case "mysql": - case "tidb": - case "mariadb": - return renderSimpleIcon(siMysql); - case "sqlite": - case "sqlite3": - return renderSimpleIcon(siSqlite); - case "duckdb": - return renderSimpleIcon(siDuckdb); - case "clickhouse": - return renderSimpleIcon(siClickhouse); - case "mssql": - return ; - default: - return ; - } -}; - export const sanitizeConnectionErrorMessage = (message: string) => message.replace(/^(?:\s*\[[^\]]+\])+\s*/g, "").trim(); diff --git a/src/lib/connection-form/rules.ts b/src/lib/connection-form/rules.ts index 55973671..52769c32 100644 --- a/src/lib/connection-form/rules.ts +++ b/src/lib/connection-form/rules.ts @@ -1,13 +1,10 @@ import type { ConnectionForm, Driver } from "@/services/api"; +import { + isMysqlFamilyDriver, + isFileBasedDriver, +} from "@/lib/driver-registry"; -const mysqlFamilyDrivers: Driver[] = ["mysql", "mariadb", "tidb"]; -const fileBasedDrivers: Driver[] = ["sqlite", "duckdb"]; - -export const isMysqlFamilyDriver = (driver: Driver) => - mysqlFamilyDrivers.includes(driver); - -export const isFileBasedDriver = (driver: Driver) => - fileBasedDrivers.includes(driver); +export { isMysqlFamilyDriver, isFileBasedDriver }; export const allowsHostWithPort = (driver: Driver) => isMysqlFamilyDriver(driver); diff --git a/src/lib/driver-registry.tsx b/src/lib/driver-registry.tsx new file mode 100644 index 00000000..37612be2 --- /dev/null +++ b/src/lib/driver-registry.tsx @@ -0,0 +1,185 @@ +import type { ReactNode } from "react"; +import { Database, Server } from "lucide-react"; +import { + siMysql, + siPostgresql, + siSqlite, + siClickhouse, + siDuckdb, +} from "simple-icons"; + +export type ImportDriverCapability = + | "supported" + | "read_only_not_supported" + | "unsupported"; + +const DRIVER_IDS = [ + "postgres", + "mysql", + "mariadb", + "tidb", + "sqlite", + "duckdb", + "clickhouse", + "mssql", +] as const; + +export type Driver = (typeof DRIVER_IDS)[number]; + +const renderSimpleIcon = (icon: { path: string }) => ( + +); + +export interface DriverConfig { + id: Driver; + label: string; + defaultPort: number | null; + isFileBased: boolean; + isMysqlFamily: boolean; + supportsSSLCA: boolean; + supportsSchemaBrowsing: boolean; + supportsCreateDatabase: boolean; + importCapability: ImportDriverCapability; + icon: () => ReactNode; +} + +export const DRIVER_REGISTRY: DriverConfig[] = [ + { + id: "postgres", + label: "PostgreSQL", + defaultPort: 5432, + isFileBased: false, + isMysqlFamily: false, + supportsSSLCA: true, + supportsSchemaBrowsing: true, + supportsCreateDatabase: true, + importCapability: "supported", + icon: () => renderSimpleIcon(siPostgresql), + }, + { + id: "mysql", + label: "MySQL", + defaultPort: 3306, + isFileBased: false, + isMysqlFamily: true, + supportsSSLCA: true, + supportsSchemaBrowsing: false, + supportsCreateDatabase: true, + importCapability: "supported", + icon: () => renderSimpleIcon(siMysql), + }, + { + id: "mariadb", + label: "MariaDB", + defaultPort: 3306, + isFileBased: false, + isMysqlFamily: true, + supportsSSLCA: true, + supportsSchemaBrowsing: false, + supportsCreateDatabase: true, + importCapability: "supported", + icon: () => renderSimpleIcon(siMysql), + }, + { + id: "tidb", + label: "TiDB", + defaultPort: 4000, + isFileBased: false, + isMysqlFamily: true, + supportsSSLCA: true, + supportsSchemaBrowsing: false, + supportsCreateDatabase: true, + importCapability: "supported", + icon: () => renderSimpleIcon(siMysql), + }, + { + id: "sqlite", + label: "SQLite", + defaultPort: null, + isFileBased: true, + isMysqlFamily: false, + supportsSSLCA: false, + supportsSchemaBrowsing: false, + supportsCreateDatabase: false, + importCapability: "supported", + icon: () => renderSimpleIcon(siSqlite), + }, + { + id: "duckdb", + label: "DuckDB", + defaultPort: null, + isFileBased: true, + isMysqlFamily: false, + supportsSSLCA: false, + supportsSchemaBrowsing: false, + supportsCreateDatabase: false, + importCapability: "supported", + icon: () => renderSimpleIcon(siDuckdb), + }, + { + id: "clickhouse", + label: "ClickHouse", + defaultPort: 8123, + isFileBased: false, + isMysqlFamily: false, + supportsSSLCA: false, + supportsSchemaBrowsing: false, + supportsCreateDatabase: true, + importCapability: "read_only_not_supported", + icon: () => renderSimpleIcon(siClickhouse), + }, + { + id: "mssql", + label: "SQL Server", + defaultPort: 1433, + isFileBased: false, + isMysqlFamily: false, + supportsSSLCA: false, + supportsSchemaBrowsing: true, + supportsCreateDatabase: true, + importCapability: "supported", + icon: () => , + }, +]; + +export const getDriverConfig = (driver: Driver): DriverConfig => + DRIVER_REGISTRY.find((d) => d.id === driver)!; + +export const getDefaultPort = (driver: Driver): number | null => + getDriverConfig(driver).defaultPort; + +export const isFileBasedDriver = (driver: Driver): boolean => + getDriverConfig(driver).isFileBased; + +export const isMysqlFamilyDriver = (driver: Driver): boolean => + getDriverConfig(driver).isMysqlFamily; + +export const supportsSSLCA = (driver: Driver): boolean => + getDriverConfig(driver).supportsSSLCA; + +export const supportsCreateDatabase = (driver: Driver): boolean => + getDriverConfig(driver).supportsCreateDatabase; + +export const supportsSchemaBrowsing = (driver: Driver): boolean => + getDriverConfig(driver).supportsSchemaBrowsing; + +export const getConnectionIcon = ( + driver: Driver | string | undefined, +): ReactNode => { + const config = DRIVER_REGISTRY.find((d) => d.id === driver); + if (config) return config.icon(); + const normalized = String(driver || "").trim().toLowerCase(); + if (normalized === "postgresql" || normalized === "pgsql") + return getConnectionIcon("postgres"); + if (normalized === "sqlite3") return getConnectionIcon("sqlite"); + return ; +}; diff --git a/src/lib/driver-registry.unit.test.ts b/src/lib/driver-registry.unit.test.ts new file mode 100644 index 00000000..b0394ec4 --- /dev/null +++ b/src/lib/driver-registry.unit.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from "bun:test"; +import { + DRIVER_REGISTRY, + getDriverConfig, + getDefaultPort, + isFileBasedDriver, + isMysqlFamilyDriver, + supportsSSLCA, + supportsCreateDatabase, + supportsSchemaBrowsing, + getConnectionIcon, + type Driver, +} from "./driver-registry"; + +// ─── Registry completeness ──────────────────────────────────────────────────── + +describe("DRIVER_REGISTRY", () => { + test("contains all 8 supported drivers", () => { + const ids = DRIVER_REGISTRY.map((d) => d.id); + expect(ids).toContain("postgres"); + expect(ids).toContain("mysql"); + expect(ids).toContain("mariadb"); + expect(ids).toContain("tidb"); + expect(ids).toContain("sqlite"); + expect(ids).toContain("duckdb"); + expect(ids).toContain("clickhouse"); + expect(ids).toContain("mssql"); + expect(DRIVER_REGISTRY).toHaveLength(8); + }); + + test("has no duplicate IDs", () => { + const ids = DRIVER_REGISTRY.map((d) => d.id); + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }); + + test("every entry has a non-empty label", () => { + for (const d of DRIVER_REGISTRY) { + expect(d.label.length).toBeGreaterThan(0); + } + }); + + test("every entry has an icon function", () => { + for (const d of DRIVER_REGISTRY) { + expect(typeof d.icon).toBe("function"); + } + }); +}); + +// ─── Registry invariants ────────────────────────────────────────────────────── + +describe("registry invariants", () => { + test("file-based drivers always have null defaultPort", () => { + for (const d of DRIVER_REGISTRY) { + if (d.isFileBased) { + expect(d.defaultPort).toBeNull(); + } + } + }); + + test("network drivers always have a positive integer defaultPort", () => { + for (const d of DRIVER_REGISTRY) { + if (!d.isFileBased) { + expect(typeof d.defaultPort).toBe("number"); + expect(d.defaultPort).toBeGreaterThan(0); + } + } + }); + + test("mysql-family drivers are not file-based", () => { + for (const d of DRIVER_REGISTRY) { + if (d.isMysqlFamily) { + expect(d.isFileBased).toBe(false); + } + } + }); + + test("file-based drivers do not support SSL CA", () => { + for (const d of DRIVER_REGISTRY) { + if (d.isFileBased) { + expect(d.supportsSSLCA).toBe(false); + } + } + }); + + test("file-based drivers do not support create database", () => { + for (const d of DRIVER_REGISTRY) { + if (d.isFileBased) { + expect(d.supportsCreateDatabase).toBe(false); + } + } + }); +}); + +// ─── getDriverConfig ────────────────────────────────────────────────────────── + +describe("getDriverConfig", () => { + test("returns the correct config for each driver", () => { + expect(getDriverConfig("postgres").label).toBe("PostgreSQL"); + expect(getDriverConfig("mysql").label).toBe("MySQL"); + expect(getDriverConfig("mssql").label).toBe("SQL Server"); + expect(getDriverConfig("clickhouse").label).toBe("ClickHouse"); + expect(getDriverConfig("duckdb").label).toBe("DuckDB"); + }); +}); + +// ─── getDefaultPort ─────────────────────────────────────────────────────────── + +describe("getDefaultPort", () => { + test("returns correct ports for network drivers", () => { + expect(getDefaultPort("postgres")).toBe(5432); + expect(getDefaultPort("mysql")).toBe(3306); + expect(getDefaultPort("mariadb")).toBe(3306); + expect(getDefaultPort("tidb")).toBe(4000); + expect(getDefaultPort("clickhouse")).toBe(8123); + expect(getDefaultPort("mssql")).toBe(1433); + }); + + test("returns null for file-based drivers", () => { + expect(getDefaultPort("sqlite")).toBeNull(); + expect(getDefaultPort("duckdb")).toBeNull(); + }); +}); + +// ─── isFileBasedDriver ──────────────────────────────────────────────────────── + +describe("isFileBasedDriver", () => { + test("returns true for file-based drivers", () => { + expect(isFileBasedDriver("sqlite")).toBe(true); + expect(isFileBasedDriver("duckdb")).toBe(true); + }); + + test("returns false for network drivers", () => { + const networkDrivers: Driver[] = [ + "postgres", + "mysql", + "mariadb", + "tidb", + "clickhouse", + "mssql", + ]; + for (const d of networkDrivers) { + expect(isFileBasedDriver(d)).toBe(false); + } + }); +}); + +// ─── isMysqlFamilyDriver ────────────────────────────────────────────────────── + +describe("isMysqlFamilyDriver", () => { + test("returns true for MySQL-family drivers", () => { + expect(isMysqlFamilyDriver("mysql")).toBe(true); + expect(isMysqlFamilyDriver("mariadb")).toBe(true); + expect(isMysqlFamilyDriver("tidb")).toBe(true); + }); + + test("returns false for non-MySQL drivers", () => { + const others: Driver[] = [ + "postgres", + "sqlite", + "duckdb", + "clickhouse", + "mssql", + ]; + for (const d of others) { + expect(isMysqlFamilyDriver(d)).toBe(false); + } + }); +}); + +// ─── supportsSSLCA ──────────────────────────────────────────────────────────── + +describe("supportsSSLCA", () => { + test("returns true for drivers with SSL CA support", () => { + expect(supportsSSLCA("postgres")).toBe(true); + expect(supportsSSLCA("mysql")).toBe(true); + expect(supportsSSLCA("mariadb")).toBe(true); + expect(supportsSSLCA("tidb")).toBe(true); + }); + + test("returns false for drivers without SSL CA support", () => { + expect(supportsSSLCA("sqlite")).toBe(false); + expect(supportsSSLCA("duckdb")).toBe(false); + expect(supportsSSLCA("clickhouse")).toBe(false); + expect(supportsSSLCA("mssql")).toBe(false); + }); +}); + +// ─── supportsCreateDatabase ─────────────────────────────────────────────────── + +describe("supportsCreateDatabase", () => { + test("returns true for drivers that can create databases", () => { + expect(supportsCreateDatabase("postgres")).toBe(true); + expect(supportsCreateDatabase("mysql")).toBe(true); + expect(supportsCreateDatabase("mariadb")).toBe(true); + expect(supportsCreateDatabase("tidb")).toBe(true); + expect(supportsCreateDatabase("clickhouse")).toBe(true); + expect(supportsCreateDatabase("mssql")).toBe(true); + }); + + test("returns false for file-based drivers", () => { + expect(supportsCreateDatabase("sqlite")).toBe(false); + expect(supportsCreateDatabase("duckdb")).toBe(false); + }); +}); + +// ─── supportsSchemaBrowsing ─────────────────────────────────────────────────── + +describe("supportsSchemaBrowsing", () => { + test("returns true for drivers with schema node support", () => { + expect(supportsSchemaBrowsing("postgres")).toBe(true); + expect(supportsSchemaBrowsing("mssql")).toBe(true); + }); + + test("returns false for drivers without schema node support", () => { + const noSchema: Driver[] = [ + "mysql", + "mariadb", + "tidb", + "sqlite", + "duckdb", + "clickhouse", + ]; + for (const d of noSchema) { + expect(supportsSchemaBrowsing(d)).toBe(false); + } + }); +}); + +// ─── importCapability ───────────────────────────────────────────────────────── + +describe("importCapability", () => { + test("clickhouse is read_only_not_supported", () => { + expect(getDriverConfig("clickhouse").importCapability).toBe( + "read_only_not_supported", + ); + }); + + test("all other drivers are supported", () => { + const supported: Driver[] = [ + "postgres", + "mysql", + "mariadb", + "tidb", + "sqlite", + "duckdb", + "mssql", + ]; + for (const d of supported) { + expect(getDriverConfig(d).importCapability).toBe("supported"); + } + }); +}); + +// ─── getConnectionIcon ──────────────────────────────────────────────────────── + +describe("getConnectionIcon", () => { + test("returns a non-null value for all registered drivers", () => { + for (const d of DRIVER_REGISTRY) { + const icon = getConnectionIcon(d.id); + expect(icon).not.toBeNull(); + expect(icon).not.toBeUndefined(); + } + }); + + test("handles common aliases", () => { + expect(getConnectionIcon("postgresql")).not.toBeNull(); + expect(getConnectionIcon("pgsql")).not.toBeNull(); + expect(getConnectionIcon("sqlite3")).not.toBeNull(); + }); + + test("returns fallback icon for unknown drivers", () => { + expect(getConnectionIcon("oracle")).not.toBeNull(); + expect(getConnectionIcon("mongodb")).not.toBeNull(); + }); + + test("handles undefined input", () => { + expect(getConnectionIcon(undefined)).not.toBeNull(); + }); +}); diff --git a/src/services/api.ts b/src/services/api.ts index d3e512eb..bcdad2d2 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -61,20 +61,12 @@ export interface SqlExecutionLog { executedAt: string; } -export type Driver = - | "postgres" - | "sqlite" - | "duckdb" - | "mysql" - | "tidb" - | "mariadb" - | "clickhouse" - | "mssql"; - -export type ImportDriverCapability = - | "supported" - | "read_only_not_supported" - | "unsupported"; +import { + DRIVER_REGISTRY, + type Driver, + type ImportDriverCapability, +} from "@/lib/driver-registry"; +export type { Driver, ImportDriverCapability } from "@/lib/driver-registry"; export const normalizeImportDriver = (driver: string): string => { const normalized = (driver || "").trim().toLowerCase(); @@ -88,21 +80,8 @@ export const getImportDriverCapability = ( driver: string, ): ImportDriverCapability => { const normalized = normalizeImportDriver(driver); - if (normalized === "clickhouse") { - return "read_only_not_supported"; - } - if ( - normalized === "postgres" || - normalized === "mysql" || - normalized === "mariadb" || - normalized === "tidb" || - normalized === "sqlite" || - normalized === "duckdb" || - normalized === "mssql" - ) { - return "supported"; - } - return "unsupported"; + const config = DRIVER_REGISTRY.find((d) => d.id === normalized); + return config?.importCapability ?? "unsupported"; }; export interface ConnectionForm { driver: Driver; From 2b4b8794e07944d6395e895f6c77325886d0bf86 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Thu, 2 Apr 2026 16:37:42 +0800 Subject: [PATCH 04/15] docs: add new-db skill and ADD_NEW_DB.md driver onboarding guide - Add .claude/commands/new-db.md: /new-db slash command that scaffolds all boilerplate for a new database driver (Rust driver file, mod.rs registration, ssh.rs port, connection_input, Cargo.toml, frontend driver-registry, i18n, integration test skeleton) - Add ADD_NEW_DB.md: precise reference checklist with exact file paths, line numbers, and common pitfalls for adding a new database driver - Update CLAUDE.md: link to ADD_NEW_DB.md from the driver development section so Claude loads it automatically on each session Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/new-db.md | 438 +++++++++++++++++++++++++++++++++++++ ADD_NEW_DB.md | 342 +++++++++++++++++++++++++++++ CLAUDE.md | 2 + 3 files changed, 782 insertions(+) create mode 100644 .claude/commands/new-db.md create mode 100644 ADD_NEW_DB.md diff --git a/.claude/commands/new-db.md b/.claude/commands/new-db.md new file mode 100644 index 00000000..890c7179 --- /dev/null +++ b/.claude/commands/new-db.md @@ -0,0 +1,438 @@ +# /new-db — Scaffold a new database driver for DbPaw + +Scaffold all boilerplate needed to add a new database type to DbPaw. + +**Usage:** `/new-db [network|file] [mysql-family]` + +**Examples:** +- `/new-db Redis 6379 redis-rs network` +- `/new-db CockroachDB 26257 sqlx network` +- `/new-db LanceDB 0 lancedb file` + +--- + +## Parse $ARGUMENTS + +Extract the following variables from `$ARGUMENTS`: + +- `DB_NAME` — display name, e.g. `Redis` (keep original casing) +- `DRIVER_ID` — lowercase of DB_NAME, e.g. `redis` +- `DEFAULT_PORT` — integer, e.g. `6379`; use `0` if file-based +- `RUST_CRATE` — crate name on crates.io, e.g. `redis` +- `IS_FILE_BASED` — `true` if `DEFAULT_PORT == 0` OR `file` flag is present; else `false` +- `IS_MYSQL_FAMILY` — `true` if `mysql-family` flag is present; else `false` +- `ENV_PREFIX` — `DRIVER_ID` uppercased, e.g. `REDIS` + +If `$ARGUMENTS` is empty or unclear, ask the user before proceeding. + +--- + +## Execution Steps + +Work through each step in order. After completing each numbered step, confirm with a brief status message before continuing. + +--- + +### Step 1 — Create the Rust driver file + +**CREATE** `src-tauri/src/db/drivers/{DRIVER_ID}.rs` + +Use the most appropriate reference driver as a template: +- **sqlx-based (most SQL databases):** copy structure from `src-tauri/src/db/drivers/mysql.rs` +- **HTTP-based:** copy structure from `src-tauri/src/db/drivers/clickhouse.rs` +- **File-based / embedded:** copy structure from `src-tauri/src/db/drivers/duckdb.rs` + +The file must contain: + +1. **Struct definition:** + ```rust + pub struct {DbName}Driver { + // connection pool or client + pub ssh_tunnel: Option, + } + ``` + +2. **`connect()` function** that: + - Handles SSH tunnel if `IS_FILE_BASED == false`: + ```rust + let mut ssh_tunnel = None; + if let Some(true) = form.ssh_enabled { + let tunnel = crate::ssh::start_ssh_tunnel(form) + .map_err(|e| format!("[CONN_FAILED] SSH tunnel failed: {}", e))?; + // Override host/port to tunnel local endpoint + ssh_tunnel = Some(tunnel); + } + ``` + - Builds connection string / config + - Creates a connection pool or client + - Returns `Ok(Self { ..., ssh_tunnel })` + - Maps all connection errors via `super::conn_failed_error(&e)` + +3. **`#[async_trait] impl DatabaseDriver for {DbName}Driver`** implementing all 13 methods: + - `test_connection` — run a simple health-check query (e.g. `SELECT 1`) + - `list_databases` — query the database catalog + - `list_tables` — query information_schema or equivalent; return `Vec` + - `get_table_structure` — return column definitions as `TableStructure` + - `get_table_metadata` — return row count, indexes, size as `TableMetadata` + - `get_table_ddl` — return `CREATE TABLE ...` DDL string + - `get_table_data` — paginated SELECT with optional sort/filter; return `TableDataResponse` + - `get_table_data_chunk` — same signature as `get_table_data`; implement identically unless streaming is needed + - `execute_query` — run arbitrary SQL; return `QueryResult` + - `execute_query_with_id` — only override if the DB supports cancellable queries; otherwise leave as default (already inherited from trait) + - `get_schema_overview` — return counts of tables/views per schema as `SchemaOverview` + - `close` — close pool / drop resources + + Error prefix conventions (must be followed exactly — frontend parses these): + - Connection errors: `[CONN_FAILED] ...` + - Query errors: `[QUERY_ERROR] ...` + - Validation errors: `[VALIDATION_ERROR] ...` + - Unsupported operations: `[NOT_SUPPORTED] ...` + + Use `super::strip_trailing_statement_terminator(&sql)` before executing user queries. + +--- + +### Step 2 — Register the driver in `mod.rs` + +**UPDATE** `src-tauri/src/db/drivers/mod.rs` + +1. Add at the top with the other `use self::` imports (keep alphabetical order): + ```rust + use self::{DRIVER_ID}::{DbName}Driver; + ``` + +2. Add with the other `pub mod` declarations (keep alphabetical order): + ```rust + pub mod {DRIVER_ID}; + ``` + +3. Add a match arm inside the `connect()` function (before the `_ => Err(...)` catch-all): + ```rust + "{DRIVER_ID}" => { + let driver = {DbName}Driver::connect(form).await?; + Ok(Box::new(driver) as Box) + } + ``` + +--- + +### Step 3 — Add SSH default port + +**UPDATE** `src-tauri/src/ssh.rs` + +In the `default_port` match block at approximately line 48: + +```rust +let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { + "mysql" => 3306, + "mssql" => 1433, + "clickhouse" => 9000, + "sqlite" => 0, + // ADD THIS LINE: + "{DRIVER_ID}" => {DEFAULT_PORT}, + _ => 5432, +}; +``` + +If `IS_FILE_BASED == true`, use `0` as the port value. + +--- + +### Step 4 — Update connection input normalization + +**UPDATE** `src-tauri/src/connection_input/mod.rs` + +- If `IS_MYSQL_FAMILY == true`: add `| "{DRIVER_ID}"` to the mysql-family match on line 57: + ```rust + if matches!(driver.as_str(), "mysql" | "mariadb" | "tidb" | "{DRIVER_ID}") { + ``` + +- If `IS_FILE_BASED == true`: add `| "{DRIVER_ID}"` to the file-based match on line 65: + ```rust + if matches!(driver.as_str(), "sqlite" | "duckdb" | "{DRIVER_ID}") { + ``` + +- If neither: no change needed to this file. + +--- + +### Step 5 — Add Cargo dependency + +**UPDATE** `src-tauri/Cargo.toml` + +Add the new crate under `[dependencies]`. Look up the latest stable version before adding. + +```toml +{RUST_CRATE} = "LATEST_VERSION" +``` + +Add any required feature flags based on the driver's needs (async runtime, TLS, connection pooling, etc.). + +--- + +### Step 6 — Register in frontend driver registry + +**UPDATE** `src/lib/driver-registry.tsx` + +This is the **single frontend entry point** — all other frontend files (`api.ts`, `rules.ts`, +`ConnectionList.tsx`, `helpers.tsx`) derive their data from this registry automatically. + +**6a.** Add `"{DRIVER_ID}"` to the `DRIVER_IDS` tuple (lines 16–25): + +```typescript +const DRIVER_IDS = [ + "postgres", + // ... existing entries ... + "{DRIVER_ID}", // ADD THIS +] as const; +``` + +**6b.** Add a `DriverConfig` entry to `DRIVER_REGISTRY` (lines 55–152), before the closing `];`: + +```typescript +{ + id: "{DRIVER_ID}", + label: "{DB_NAME}", + defaultPort: {DEFAULT_PORT}, // use null if IS_FILE_BASED == true + isFileBased: {IS_FILE_BASED}, + isMysqlFamily: {IS_MYSQL_FAMILY}, + supportsSSLCA: false, // true only if driver verifies SSL CA certs + supportsSchemaBrowsing: false, // true if driver exposes named schemas (like postgres/mssql) + supportsCreateDatabase: true, // false for file-based and read-only drivers + importCapability: "supported", // "supported" | "read_only_not_supported" | "unsupported" + icon: () => renderSimpleIcon(si{DbName}), // or if no simple-icons entry +}, +``` + +For the icon: check if `simple-icons` exports `si{DbName}`. If yes, add the import at the top of the file: +```typescript +import { ..., si{DbName} } from "simple-icons"; +``` +If no matching icon exists, use `` (already imported from lucide-react). + +--- + +### Step 7 — Create integration test files + +**CREATE** `src-tauri/tests/common/{DRIVER_ID}_context.rs` + +Follow the exact pattern of `src-tauri/tests/common/mysql_context.rs`: + +```rust +mod shared; + +use dbpaw_lib::models::ConnectionForm; +use std::env; +use std::time::Duration; +use testcontainers::clients::Cli; +use testcontainers::core::WaitFor; +use testcontainers::{Container, GenericImage, RunnableImage}; + +pub use shared::{connect_with_retry, should_reuse_local_db}; + +pub fn {DRIVER_ID}_form_from_test_context<'a>( + docker: Option<&'a Cli>, +) -> (Option>, ConnectionForm) { + if should_reuse_local_db() { + return (None, {DRIVER_ID}_form_from_local_env()); + } + shared::ensure_docker_available(); + + let docker = docker.expect("docker client is required when IT_REUSE_LOCAL_DB is not enabled"); + let image = GenericImage::new("{docker_image}", "{docker_tag}") + .with_env_var("{ENV_PREFIX}_PASSWORD", "123456") + .with_env_var("{ENV_PREFIX}_DATABASE", "test_db") + .with_wait_for(WaitFor::seconds(5)) + .with_exposed_port({DEFAULT_PORT}); + let runnable = + RunnableImage::from(image).with_container_name(shared::unique_container_name("{DRIVER_ID}")); + let container = docker.run(runnable); + let port = container.get_host_port_ipv4({DEFAULT_PORT}); + + shared::wait_for_port("127.0.0.1", port, Duration::from_secs(45)); + + let mut form = ConnectionForm { + driver: "{DRIVER_ID}".to_string(), + host: Some("127.0.0.1".to_string()), + port: Some(i64::from(port)), + username: Some("root".to_string()), + password: Some("123456".to_string()), + database: Some("test_db".to_string()), + ..Default::default() + }; + apply_{DRIVER_ID}_env_overrides(&mut form); + (Some(container), form) +} + +fn {DRIVER_ID}_form_from_local_env() -> ConnectionForm { + let mut form = ConnectionForm { + driver: "{DRIVER_ID}".to_string(), + host: Some(shared::env_or("{ENV_PREFIX}_HOST", "localhost")), + port: Some(shared::env_i64("{ENV_PREFIX}_PORT", {DEFAULT_PORT})), + username: Some(shared::env_or("{ENV_PREFIX}_USER", "root")), + password: Some(shared::env_or("{ENV_PREFIX}_PASSWORD", "123456")), + database: Some(shared::env_or("{ENV_PREFIX}_DB", "test_db")), + ..Default::default() + }; + apply_{DRIVER_ID}_env_overrides(&mut form); + form +} + +fn apply_{DRIVER_ID}_env_overrides(form: &mut ConnectionForm) { + if let Ok(host) = env::var("{ENV_PREFIX}_HOST") { form.host = Some(host); } + if let Ok(port) = env::var("{ENV_PREFIX}_PORT") { + form.port = Some(port.parse::().expect("{ENV_PREFIX}_PORT should be a valid number")); + } + if let Ok(user) = env::var("{ENV_PREFIX}_USER") { form.username = Some(user); } + if let Ok(password) = env::var("{ENV_PREFIX}_PASSWORD") { form.password = Some(password); } + if let Ok(database) = env::var("{ENV_PREFIX}_DB") { form.database = Some(database); } +} +``` + +Replace `{docker_image}` and `{docker_tag}` with the official Docker Hub image name and tag for this database. + +--- + +**CREATE** `src-tauri/tests/{DRIVER_ID}_integration.rs` + +Follow the pattern of `src-tauri/tests/mysql_integration.rs`: + +```rust +#[path = "common/{DRIVER_ID}_context.rs"] +mod {DRIVER_ID}_context; + +use dbpaw_lib::db::drivers::{DRIVER_ID}::{DbName}Driver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use testcontainers::clients::Cli; + +#[tokio::test] +#[ignore] +async fn test_{DRIVER_ID}_integration_flow() { + let docker = (!{DRIVER_ID}_context::should_reuse_local_db()).then(Cli::default); + let (_container, form) = {DRIVER_ID}_context::{DRIVER_ID}_form_from_test_context(docker.as_ref()); + let database = form.database.clone(); + + let driver: {DbName}Driver = + {DRIVER_ID}_context::connect_with_retry(|| {DbName}Driver::connect(&form)).await; + + // 1. test_connection + let result = driver.test_connection().await; + assert!(result.is_ok(), "Connection failed: {:?}", result.err()); + + // 2. list_databases + let dbs = driver.list_databases().await; + assert!(dbs.is_ok(), "list_databases failed: {:?}", dbs.err()); + assert!(!dbs.unwrap().is_empty()); + + // 3. Per-database operations + if let Some(db_name) = database { + let table_name = "test_{DRIVER_ID}_integration"; + + // list_tables + let tables = driver.list_tables(Some(db_name.clone())).await; + assert!(tables.is_ok(), "list_tables failed: {:?}", tables.err()); + + // execute_query: create table (adapt DDL to target database SQL dialect) + let _ = driver.execute_query(format!( + "CREATE TABLE IF NOT EXISTS {} (id INT PRIMARY KEY, name VARCHAR(50))", + table_name + )).await.expect("create table failed"); + + // execute_query: insert + let _ = driver.execute_query(format!( + "DELETE FROM {} WHERE id = 1", table_name + )).await; + driver.execute_query(format!( + "INSERT INTO {} (id, name) VALUES (1, 'DbPaw')", table_name + )).await.expect("insert failed"); + + // execute_query: select + let result = driver.execute_query(format!( + "SELECT * FROM {} WHERE id = 1", table_name + )).await.expect("select failed"); + assert_eq!(result.row_count, 1); + if let Some(row) = result.data.first() { + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("DbPaw")); + } + + // get_table_structure + let structure = driver.get_table_structure(db_name.clone(), table_name.to_string()).await; + assert!(structure.is_ok(), "get_table_structure failed: {:?}", structure.err()); + + // get_table_data + let data = driver.get_table_data( + db_name.clone(), table_name.to_string(), + 1, 20, None, None, None, None, + ).await; + assert!(data.is_ok(), "get_table_data failed: {:?}", data.err()); + + // get_table_ddl + let ddl = driver.get_table_ddl(db_name.clone(), table_name.to_string()).await; + assert!(ddl.is_ok(), "get_table_ddl failed: {:?}", ddl.err()); + + // get_schema_overview + let overview = driver.get_schema_overview(Some(db_name.clone())).await; + assert!(overview.is_ok(), "get_schema_overview failed: {:?}", overview.err()); + + // cleanup + let _ = driver.execute_query(format!("DROP TABLE {}", table_name)).await; + println!("{DB_NAME} integration test passed"); + } +} +``` + +--- + +**CREATE** `src-tauri/tests/{DRIVER_ID}_command_integration.rs` + +Follow the pattern of `src-tauri/tests/mysql_command_integration.rs`. Key points: +- Import `{DRIVER_ID}_context` with `#[path = "common/{DRIVER_ID}_context.rs"]` +- Use `connection::test_connection_ephemeral`, `metadata::*`, `query::execute_by_conn_direct` +- Test: ephemeral connect, list databases, list tables, execute query, get table structure + +--- + +### Step 8 — Update i18n (file-based drivers only) + +**Only if `IS_FILE_BASED == true`**, update all three locale files: +- `src/lib/i18n/locales/en.ts` +- `src/lib/i18n/locales/zh.ts` +- `src/lib/i18n/locales/ja.ts` + +Look for the `sqliteFilePath` / `duckdbFilePath` section and add analogous entries: +- `en.ts`: `{DRIVER_ID}FilePath: "{DB_NAME} File"`, `{DRIVER_ID}Path: "/path/to/db.{DRIVER_ID}"` +- `zh.ts`: appropriate Chinese translation +- `ja.ts`: appropriate Japanese translation + +--- + +### Step 9 — Update test integration script + +**UPDATE** `scripts/test-integration.sh` + +Add a named case for the new driver and include it in the `all)` block. Follow the exact pattern of existing cases in the file. + +--- + +## Final Verification + +Run these three checks and fix any errors before declaring done: + +```bash +bun run typecheck +bun run lint +cargo check --manifest-path src-tauri/Cargo.toml +``` + +Report the result of each check to the user. + +--- + +## Common Pitfalls + +- **Don't forget `ssh.rs`** — missing a case means SSH tunnel uses wrong default port silently +- **Error prefixes are parsed by the frontend** — must use `[CONN_FAILED]`, `[QUERY_ERROR]`, `[VALIDATION_ERROR]`, `[NOT_SUPPORTED]` exactly +- **`execute_query_with_id`** — do NOT override this unless the database actually supports query cancellation; the trait provides a sensible default +- **`get_table_data` vs `get_table_data_chunk`** — both must be implemented; they share the same signature; implement identically unless the driver has streaming support +- **Port type** — `ConnectionForm` uses `i64` for port; cast to `u16` with `form.port.unwrap_or({DEFAULT_PORT}) as u16` after validation +- **`strip_trailing_statement_terminator`** — call `super::strip_trailing_statement_terminator(&sql)` before executing user-provided SQL diff --git a/ADD_NEW_DB.md b/ADD_NEW_DB.md new file mode 100644 index 00000000..c25ef4bd --- /dev/null +++ b/ADD_NEW_DB.md @@ -0,0 +1,342 @@ +# ADD_NEW_DB — DbPaw 新增数据库驱动操作手册 + +本文档记录新增一个数据库驱动类型时需要修改的全部文件,包含精确路径、行号和改法。 + +--- + +## 术语约定 + +- `{driver}` — 小写 driver ID,与前端 `DRIVER_IDS` 保持一致(例:`oracle`) +- `{DriverName}` — PascalCase(例:`Oracle`) +- `network` 型 — 通过 host:port 连接(postgres、mysql、mssql、clickhouse 等) +- `file` 型 — 通过本地文件路径连接(sqlite、duckdb) + +--- + +## Step 1:创建 Rust Driver 文件 + +**文件:** `src-tauri/src/db/drivers/{driver}.rs`(新建) + +参考模板选择: +- **PostgreSQL-like**(独立 schema、SSl CA、sqlx)→ 复制 `postgres.rs` +- **MySQL-like**(共享驱动、MySQL 协议)→ 复制 `mysql.rs` +- **HTTP API 型**(ClickHouse-like)→ 复制 `clickhouse.rs` +- **嵌入式/文件型**(无网络连接)→ 复制 `duckdb.rs` + +必须实现 `DatabaseDriver` trait 的全部方法(定义见 `src-tauri/src/db/drivers/mod.rs:64-121`): + +``` +test_connection, get_databases, get_table_names, get_table_structure, +get_table_info, get_table_data, execute_query, cancel_query, +get_schema_names, get_table_ddl, get_schema_overview, close +``` + +--- + +## Step 2:注册到 `drivers/mod.rs` + +**文件:** `src-tauri/src/db/drivers/mod.rs` + +### 2a. 顶部 use 语句(第 1-6 行附近) + +在现有的 `use self::...` 行中加入: + +```rust +use self::{driver}::{DriverName}Driver; +``` + +### 2b. mod 声明(第 13-18 行附近) + +在现有的 `pub mod ...` 行中加入: + +```rust +pub mod {driver}; +``` + +### 2c. `connect()` match 分支(第 133-163 行) + +在 `_ =>` 分支前加入: + +```rust +"{driver}" => { + let driver = {DriverName}Driver::connect(form).await?; + Ok(Box::new(driver) as Box) +} +``` + +**注意(MySQL family):** 如果是 MySQL 协议兼容的变体(如 PolarDB),可以复用 `MysqlDriver`,直接在第 139 行的现有 arm 里加 `| "{driver}"`: + +```rust +"mysql" | "tidb" | "mariadb" | "{driver}" => { +``` + +--- + +## Step 3:SSH 默认端口(仅 network 型) + +**文件:** `src-tauri/src/ssh.rs`,第 48-54 行 + +在 `_ => 5432` 之前加入一行: + +```rust +"{driver}" => {PORT}, +``` + +**示例(当前内容):** + +```rust +let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { + "mysql" => 3306, + "mssql" => 1433, + "clickhouse" => 9000, + "sqlite" => 0, + // ← 在这里加新 driver + _ => 5432, // postgres and unknown drivers +}; +``` + +**注意:** +- file 型 driver 不走 SSH 隧道的端口逻辑,但若要防止 fallback 到 5432,可加 `"sqlite" => 0,` 同款的占位。 +- 端口 0 不会通过第 56-58 行的校验(`1..=65535`),file 型 driver 传 `port=None` 即可,无需额外处理。 +- 忘记加这一行不会 crash,但 SSH 连接会用 5432 作为默认端口,导致隧道目标端口错误。 + +--- + +## Step 4:连接表单校验 + +**文件:** `src-tauri/src/connection_input/mod.rs` + +### 4a. network 型 — 无需修改 + +第 69-71 行的 `else if form.host.is_none()` 已覆盖所有 network 型 driver。 + +### 4b. file 型 — 第 65 行 + +在现有的 `matches!` 中加入新 driver: + +```rust +if matches!(driver.as_str(), "sqlite" | "duckdb" | "{driver}") { +``` + +### 4c. MySQL family(支持 host:port 嵌入语法)— 第 57 行 + +如果新 driver 允许 `host:port` 写法(如 `localhost:3307`),在现有 `matches!` 中加入: + +```rust +if matches!(driver.as_str(), "mysql" | "mariadb" | "tidb" | "{driver}") { +``` + +--- + +## Step 5:import/export 事务语法(如支持 import) + +**文件:** `src-tauri/src/commands/transfer.rs`,第 615-628 行(`import_transaction_sql` 函数) + +根据 driver 支持的事务语法加入 match arm: + +```rust +// BEGIN / COMMIT / ROLLBACK(与 postgres 相同) +"postgres" | "sqlite" | "duckdb" | "{driver}" => Ok(("BEGIN", "COMMIT", "ROLLBACK")), + +// 或 START TRANSACTION(MySQL 系) +"mysql" | "mariadb" | "tidb" | "{driver}" => Ok(("START TRANSACTION", "COMMIT", "ROLLBACK")), + +// 不支持 import +"{driver}" => Err("[UNSUPPORTED] Driver {driver} is read-only in this import flow".to_string()), +``` + +--- + +## Step 6:create_database 支持(如支持) + +**文件:** `src-tauri/src/commands/connection.rs` + +两处需要改(`create_database_by_id` 约第 262 行,`create_database_by_id_direct` 约第 343 行): + +**6a. 从"不支持"排除列表移除**(file 型专用黑名单,network 型无需改): + +```rust +// 第 262、343 行: +if matches!(driver.as_str(), "sqlite" | "duckdb") { // 不要在此加 network 型 driver +``` + +**6b. 在 match 中加入建库 SQL**(第 269-319、350-400 行): + +```rust +"{driver}" => { + let sql = format!("CREATE DATABASE {}", quote_ident(&db_name)); + super::execute_with_retry(&state, id, None, |driver| { + let sql_clone = sql.clone(); + async move { driver.execute_query(sql_clone).await.map(|_| ()) } + }) + .await +} +``` + +--- + +## Step 7:前端 driver-registry.tsx(必改) + +**文件:** `src/lib/driver-registry.tsx` + +### 7a. DRIVER_IDS(第 16-25 行) + +在 `as const` 数组中加入新 driver ID: + +```typescript +const DRIVER_IDS = [ + "postgres", + "mysql", + // ... + "{driver}", // ← 加在这里 +] as const; +``` + +### 7b. DRIVER_REGISTRY(第 55-152 行) + +在数组末尾(`];` 之前)加入一条记录: + +```typescript +{ + id: "{driver}", + label: "DisplayName", + defaultPort: 1234, // file 型填 null + isFileBased: false, // file 型填 true + isMysqlFamily: false, // MySQL 协议兼容时填 true + supportsSSLCA: false, // 支持 SSL CA 证书验证时填 true(需后端也支持) + supportsSchemaBrowsing: false, // 支持 schema 列表时填 true + supportsCreateDatabase: true, // 支持 CREATE DATABASE 时填 true + importCapability: "supported", // "supported" | "read_only_not_supported" | "unsupported" + icon: () => renderSimpleIcon(si{DriverName}), // 或 +}, +``` + +**图标规则:** +- 优先从 `simple-icons` 导入:`import { si{DriverName} } from "simple-icons";` +- 无 simple-icons 时用 ``(通用服务器图标)或 `` + +**这一个文件改完,以下前端逻辑自动生效(无需再改):** +- `src/services/api.ts` — `Driver` 类型 +- `src/lib/connection-form/rules.ts` — MySQL family / file-based 数组 +- `src/components/business/Sidebar/connection-list/helpers.tsx` — 图标映射 +- `src/components/business/Sidebar/ConnectionList.tsx` — SelectItem、默认 port、SSL/file 条件渲染 + +--- + +## Step 8:i18n(仅 file 型 driver) + +**文件:** `src/lib/i18n/locales/en.ts`、`zh.ts`、`ja.ts` + +file 型 driver 需要在三个 locale 文件里加"文件路径"标签和占位符。 + +在 `en.ts` 中搜索 `duckdbFilePath`(约第 221 行)附近加入: + +```typescript +{driver}FilePath: "{DriverName} File Path", +{driver}Path: "/path/to/db.{driver}", +``` + +zh.ts 和 ja.ts 同理加入对应翻译。 + +--- + +## Step 9:Cargo.toml 依赖 + +**文件:** `src-tauri/Cargo.toml` + +按驱动依赖类型选择: + +| 类型 | 做法 | +|------|------| +| 使用 sqlx(postgres/mysql 系)| 在 sqlx `features` 列表加 driver 名(第 34 行) | +| 独立 crate(如 DuckDB)| 加一行 `{driver} = { version = "x.y", features = [...] }` | +| HTTP 协议(如 ClickHouse)| 加 HTTP client 依赖(参考 clickhouse.rs 的 import) | +| 微软协议(MSSQL)| 使用 `tiberius`(已有,无需重复加) | + +--- + +## Step 10:集成测试骨架 + +**新建 3 个文件**(参考同类 driver 复制修改): + +``` +src-tauri/tests/common/{driver}_context.rs ← testcontainers 容器配置 +src-tauri/tests/{driver}_integration.rs ← DatabaseDriver trait 方法直接测试 +src-tauri/tests/{driver}_command_integration.rs ← Tauri command 层测试 +``` + +在 `src-tauri/tests/common/mod.rs` 中加入模块声明: + +```rust +pub mod {driver}_context; +``` + +更新 `scripts/test-integration.sh` 加入新 driver(搜索其他 driver 名的赋值行)。 + +**可选:** 如果 driver 支持多语句事务,创建: + +``` +src-tauri/tests/{driver}_stateful_command_integration.rs +``` + +参考 `postgres_stateful_command_integration.rs`。 + +--- + +## 验证 Checklist + +每次新增 driver 后执行: + +```bash +# 必须全部通过 +bun run typecheck +bun run lint +cargo check --manifest-path src-tauri/Cargo.toml + +# 有条件时执行 +bun run test:unit +IT_DB={driver} bun run test:integration # 需要 Docker +``` + +快速一键验证: + +```bash +bun run test:smoke # typecheck + lint + unit tests +``` + +--- + +## 常见陷阱 + +| 陷阱 | 后果 | 解法 | +|------|------|------| +| 忘记改 `ssh.rs` 默认端口 | SSH 隧道目标端口错误(fallback 到 5432) | Step 3 | +| file 型 driver 未加入 `connection_input` 的 matches! | 校验报"host cannot be empty"而不是"file path" | Step 4b | +| 前端 `DRIVER_IDS` 加了但 `DRIVER_REGISTRY` 没加 | TypeScript 编译报错,图标/port 逻辑异常 | Step 7 | +| 图标使用了不存在的 simple-icons 导出名 | 前端运行时崩溃 | 验证 `si{DriverName}` 是否存在于 `simple-icons` 包 | +| 忘记改 `import_transaction_sql` | import 功能对新 driver 返回"不支持"或使用错误事务语法 | Step 5 | +| MySQL family 新 driver 未加入 `connection_input` 的 mysql arm | `host:port` 嵌入写法不被解析 | Step 4c | +| i18n 只改了 en.ts | 中文/日文界面显示 key 字符串而非翻译文本 | Step 8 三个文件都要改 | + +--- + +## 文件改动汇总 + +| 文件 | 类型 | 条件 | +|------|------|------| +| `src-tauri/src/db/drivers/{driver}.rs` | 新建 | 必须 | +| `src-tauri/src/db/drivers/mod.rs` | 改 | 必须(3处) | +| `src-tauri/src/ssh.rs` | 改 | network 型 | +| `src-tauri/src/connection_input/mod.rs` | 改 | file 型或 MySQL family | +| `src-tauri/src/commands/transfer.rs` | 改 | 支持 import 时 | +| `src-tauri/src/commands/connection.rs` | 改 | 支持 create database 时 | +| `src-tauri/Cargo.toml` | 改 | 必须 | +| `src/lib/driver-registry.tsx` | 改 | 必须(前端唯一入口) | +| `src/lib/i18n/locales/en.ts` | 改 | file 型 | +| `src/lib/i18n/locales/zh.ts` | 改 | file 型 | +| `src/lib/i18n/locales/ja.ts` | 改 | file 型 | +| `src-tauri/tests/common/{driver}_context.rs` | 新建 | 集成测试 | +| `src-tauri/tests/{driver}_integration.rs` | 新建 | 集成测试 | +| `src-tauri/tests/{driver}_command_integration.rs` | 新建 | 集成测试 | +| `src-tauri/tests/common/mod.rs` | 改 | 集成测试 | +| `scripts/test-integration.sh` | 改 | 集成测试 | diff --git a/CLAUDE.md b/CLAUDE.md index a946e349..ab2ac0cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,8 @@ Database Drivers → Driver integration tests (*_integration.rs) ### Database Driver Development +For a complete step-by-step checklist (exact file paths, line numbers, and gotchas), see [ADD_NEW_DB.md](ADD_NEW_DB.md). Use the `/new-db` skill to scaffold automatically. + When adding/modifying database drivers: 1. Implement `DatabaseDriver` trait in `src-tauri/src/db/drivers/.rs` 2. Add to driver enum in `src-tauri/src/db/drivers/mod.rs` From 40fbaabb6afc4f41fab3d17bea7461b2cb75619b Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 3 Apr 2026 19:22:08 +0800 Subject: [PATCH 05/15] feat(oracle): add Oracle database driver support Implement Oracle driver via the `oracle` crate (OCI-based), register it in the driver enum and connection dispatcher, add SSH tunnel default port (1521), wire up import transaction SQL (SELECT 1 FROM DUAL / COMMIT / ROLLBACK), and register the driver config in the frontend registry. Unit tests updated to reflect 9 supported drivers and Oracle-specific capabilities (schema browsing, import support). Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/Cargo.lock | 42 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/transfer.rs | 5 + src-tauri/src/db/drivers/mod.rs | 6 + src-tauri/src/db/drivers/oracle.rs | 817 +++++++++++++++++++++++++++ src-tauri/src/ssh.rs | 1 + src/lib/driver-registry.tsx | 13 + src/lib/driver-registry.unit.test.ts | 10 +- src/services/api.unit.test.ts | 2 +- 9 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/db/drivers/oracle.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4799250a..7f44fe04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1144,6 +1144,7 @@ dependencies = [ "chrono", "duckdb", "futures-util", + "oracle", "rand 0.8.5", "reqwest 0.12.28", "rust_decimal", @@ -3364,6 +3365,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "odpic-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3448,6 +3458,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0" +dependencies = [ + "cc", + "odpic-sys", + "once_cell", + "oracle_procmacro", + "paste", + "rustversion", +] + +[[package]] +name = "oracle_procmacro" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3526,6 +3562,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6575ed17..76ff1e4c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ futures-util = "0.3" aes-gcm = "0.10" base64 = "0.22" duckdb = { version = "1.2.2", features = ["bundled"] } +oracle = "0.6" [dev-dependencies] testcontainers = "0.15.0" diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index ff1cf336..fbbe0f5f 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -620,6 +620,7 @@ fn import_transaction_sql<'a>( "COMMIT TRANSACTION", "ROLLBACK TRANSACTION", )), + "oracle" => Ok(("SELECT 1 FROM DUAL", "COMMIT", "ROLLBACK")), "clickhouse" => { Err("[UNSUPPORTED] Driver clickhouse is read-only in this import flow".to_string()) } @@ -1671,6 +1672,10 @@ mod tests { "ROLLBACK TRANSACTION" ) ); + assert_eq!( + import_transaction_sql("oracle", "oracle").unwrap(), + ("SELECT 1 FROM DUAL", "COMMIT", "ROLLBACK") + ); assert!(import_transaction_sql("clickhouse", "clickhouse").is_err()); } diff --git a/src-tauri/src/db/drivers/mod.rs b/src-tauri/src/db/drivers/mod.rs index f83d4076..3aa03945 100644 --- a/src-tauri/src/db/drivers/mod.rs +++ b/src-tauri/src/db/drivers/mod.rs @@ -2,6 +2,7 @@ use self::clickhouse::ClickHouseDriver; use self::duckdb::DuckdbDriver; use self::mssql::MssqlDriver; use self::mysql::MysqlDriver; +use self::oracle::OracleDriver; use self::postgres::PostgresDriver; use self::sqlite::SqliteDriver; use crate::models::{ @@ -14,6 +15,7 @@ pub mod clickhouse; pub mod duckdb; pub mod mssql; pub mod mysql; +pub mod oracle; pub mod postgres; pub mod sqlite; @@ -156,6 +158,10 @@ pub async fn connect(form: &ConnectionForm) -> Result, S let driver = MssqlDriver::connect(form).await?; Ok(Box::new(driver) as Box) } + "oracle" => { + let driver = OracleDriver::connect(form).await?; + Ok(Box::new(driver) as Box) + } _ => Err(format!( "[UNSUPPORTED] Driver {} not supported", form.driver diff --git a/src-tauri/src/db/drivers/oracle.rs b/src-tauri/src/db/drivers/oracle.rs new file mode 100644 index 00000000..a11d461e --- /dev/null +++ b/src-tauri/src/db/drivers/oracle.rs @@ -0,0 +1,817 @@ +use super::{conn_failed_error, strip_trailing_statement_terminator, DatabaseDriver}; +use crate::models::{ + ColumnInfo, ColumnSchema, ConnectionForm, ForeignKeyInfo, IndexInfo, QueryColumn, QueryResult, + SchemaOverview, TableDataResponse, TableInfo, TableMetadata, TableSchema, TableStructure, +}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct OracleDriver { + config: OracleConfig, + _ssh_tunnel: Option, +} + +#[derive(Clone)] +struct OracleConfig { + host: String, + port: u16, + /// Oracle Easy Connect service name (e.g. "ORCL", "FREE", "XE") + service_name: String, + username: String, + password: String, +} + +fn build_connect_string(cfg: &OracleConfig) -> String { + format!("//{}:{}/{}", cfg.host, cfg.port, cfg.service_name) +} + +/// Oracle uses double-quote identifiers. Upper-case is the Oracle default. +fn quote_ident(ident: &str) -> String { + format!("\"{}\"", ident.replace('"', "\"\"")) +} + +fn escape_literal(value: &str) -> String { + value.replace('\'', "''") +} + +fn first_sql_keyword(sql: &str) -> Option { + let bytes = sql.as_bytes(); + let len = bytes.len(); + let mut i = 0; + loop { + while i < len && (bytes[i].is_ascii_whitespace() || bytes[i] == b';') { + i += 1; + } + if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' { + i += 2; + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + if i + 1 >= len { + return None; + } + i += 2; + continue; + } + break; + } + if i >= len { + return None; + } + let start = i; + while i < len && bytes[i].is_ascii_alphabetic() { + i += 1; + } + if start == i { + return None; + } + Some(sql[start..i].to_ascii_lowercase()) +} + +/// Convert a single Oracle column value (by index) into a `serde_json::Value`. +/// +/// Strategy: try integer → float → bytes → string → null. This cascade avoids +/// the need to inspect `OracleType` while still producing sensible JSON types +/// for the common Oracle types (NUMBER, VARCHAR2, DATE, TIMESTAMP, CLOB, BLOB). +/// +/// Precision note: very large NUMBER values (> i64::MAX) fall through to f64, +/// which may lose precision. This is acceptable for a v1 display client. +fn oracle_value_to_json(row: &oracle::Row, idx: usize) -> serde_json::Value { + // Try integer (covers NUMBER(p,0), INTEGER, SMALLINT, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => return serde_json::Value::Number(v.into()), + Err(_) => {} + } + // Try float (covers NUMBER(p,s) with fractional part, FLOAT, BINARY_FLOAT, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => { + if let Some(n) = serde_json::Number::from_f64(v) { + return serde_json::Value::Number(n); + } + return serde_json::Value::String(v.to_string()); + } + Err(_) => {} + } + // Try bytes (covers BLOB, RAW — returned as hex string) + match row.get::<_, Option>>(idx) { + Ok(None) => return serde_json::Value::Null, + Ok(Some(v)) => { + return serde_json::Value::String(v.iter().map(|b| format!("{b:02x}")).collect()); + } + Err(_) => {} + } + // Try string (covers VARCHAR2, NVARCHAR2, CHAR, DATE, TIMESTAMP, CLOB, etc.) + match row.get::<_, Option>(idx) { + Ok(None) => serde_json::Value::Null, + Ok(Some(v)) => serde_json::Value::String(v), + Err(_) => serde_json::Value::Null, + } +} + +impl OracleDriver { + pub async fn connect(form: &ConnectionForm) -> Result { + let mut effective_form = form.clone(); + let mut ssh_tunnel = None; + + if let Some(true) = form.ssh_enabled { + let tunnel = crate::ssh::start_ssh_tunnel(form)?; + effective_form.host = Some("127.0.0.1".to_string()); + effective_form.port = Some(tunnel.local_port as i64); + ssh_tunnel = Some(tunnel); + } + + let host = effective_form + .host + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or("[VALIDATION_ERROR] host cannot be empty")?; + let port = effective_form.port.unwrap_or(1521); + if !(1..=65535).contains(&port) { + return Err("[VALIDATION_ERROR] port out of range".to_string()); + } + let service_name = effective_form + .database + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "ORCL".to_string()); + let username = effective_form + .username + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or("[VALIDATION_ERROR] username cannot be empty")?; + let password = effective_form.password.clone().unwrap_or_default(); + + let config = OracleConfig { + host, + port: port as u16, + service_name, + username, + password, + }; + let driver = Self { + config, + _ssh_tunnel: ssh_tunnel, + }; + driver.test_connection().await?; + Ok(driver) + } + + /// Run a blocking Oracle OCI call on tokio's blocking thread pool. + /// + /// A fresh `oracle::Connection` is created for each call (reconnect-per-call + /// pattern). This avoids the complexity of sharing a `!Sync` OCI handle + /// across async tasks at the cost of a new TCP handshake per driver method. + /// For a desktop database-client with low concurrency this is acceptable. + /// + /// DML statements are followed by an explicit `conn.commit()` so that the + /// work is persisted even though the connection is dropped at the end of + /// the closure. + async fn run_blocking(&self, f: F) -> Result + where + F: FnOnce(oracle::Connection) -> Result + Send + 'static, + T: Send + 'static, + { + let cfg = self.config.clone(); + tokio::task::spawn_blocking(move || { + let connect_string = build_connect_string(&cfg); + let conn = + oracle::Connection::connect(&cfg.username, &cfg.password, &connect_string) + .map_err(|e| conn_failed_error(&e))?; + f(conn) + }) + .await + .map_err(|e| format!("[ORACLE_ERROR] {e}"))? + } +} + +#[async_trait] +impl DatabaseDriver for OracleDriver { + async fn close(&self) { + // No persistent connection to close. + } + + async fn test_connection(&self) -> Result<(), String> { + self.run_blocking(|conn| { + conn.query("SELECT 1 FROM DUAL", &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[CONN_FAILED] {e}"))? + .next() + .ok_or("[CONN_FAILED] Empty response from DUAL")? + .map_err(|e| format!("[CONN_FAILED] {e}"))?; + Ok(()) + }) + .await + } + + /// In Oracle, "databases" map to schemas (users visible via ALL_USERS). + async fn list_databases(&self) -> Result, String> { + self.run_blocking(|conn| { + let rows = conn + .query( + "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME", + &[] as &[&dyn oracle::sql_type::ToSql], + ) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut result = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let name: Option = row.get(0).ok().flatten(); + if let Some(n) = name { + if !n.is_empty() { + result.push(n); + } + } + } + Ok(result) + }) + .await + } + + async fn list_tables(&self, schema: Option) -> Result, String> { + let schema_upper = schema + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()); + self.run_blocking(move |conn| { + let sql = if let Some(ref s) = schema_upper { + format!( + "SELECT OWNER, TABLE_NAME, 'table' AS TABLE_TYPE \ + FROM ALL_TABLES WHERE OWNER = '{}' \ + UNION ALL \ + SELECT OWNER, VIEW_NAME, 'view' \ + FROM ALL_VIEWS WHERE OWNER = '{}' \ + ORDER BY 1, 2", + escape_literal(s), + escape_literal(s), + ) + } else { + "SELECT OWNER, TABLE_NAME, 'table' AS TABLE_TYPE \ + FROM ALL_TABLES \ + UNION ALL \ + SELECT OWNER, VIEW_NAME, 'view' \ + FROM ALL_VIEWS \ + ORDER BY 1, 2" + .to_string() + }; + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut result = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let schema_name: Option = row.get(0).ok().flatten(); + let table_name: Option = row.get(1).ok().flatten(); + let table_type: Option = row.get(2).ok().flatten(); + if let (Some(s), Some(t), Some(ty)) = (schema_name, table_name, table_type) { + result.push(TableInfo { + schema: s, + name: t, + r#type: ty, + }); + } + } + Ok(result) + }) + .await + } + + async fn get_table_structure( + &self, + schema: String, + table: String, + ) -> Result { + self.run_blocking(move |conn| { + // Primary keys + let pk_sql = format!( + "SELECT ac.COLUMN_NAME \ + FROM ALL_CONSTRAINTS con \ + JOIN ALL_CONS_COLUMNS ac \ + ON con.CONSTRAINT_NAME = ac.CONSTRAINT_NAME \ + AND con.OWNER = ac.OWNER \ + WHERE con.CONSTRAINT_TYPE = 'P' \ + AND con.OWNER = '{}' \ + AND con.TABLE_NAME = '{}' \ + ORDER BY ac.POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let pk_rows = conn + .query(&pk_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut pk_set = std::collections::HashSet::::new(); + for row_result in pk_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let col: Option = row.get(0).ok().flatten(); + if let Some(c) = col { + pk_set.insert(c); + } + } + + // Columns + let col_sql = format!( + "SELECT \ + COLUMN_NAME, \ + DATA_TYPE || \ + CASE \ + WHEN DATA_TYPE IN ('VARCHAR2','NVARCHAR2','CHAR','NCHAR') \ + THEN '(' || CHAR_LENGTH || ')' \ + WHEN DATA_TYPE = 'NUMBER' AND DATA_PRECISION IS NOT NULL \ + THEN '(' || DATA_PRECISION || \ + CASE WHEN DATA_SCALE > 0 THEN ',' || DATA_SCALE ELSE '' END \ + || ')' \ + ELSE '' \ + END AS FULL_TYPE, \ + NULLABLE, \ + DATA_DEFAULT \ + FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '{}' AND TABLE_NAME = '{}' \ + ORDER BY COLUMN_ID", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let col_rows = conn + .query(&col_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut columns = Vec::new(); + for row_result in col_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let name: Option = row.get(0).ok().flatten(); + let col_type: Option = row.get(1).ok().flatten(); + let nullable: Option = row.get(2).ok().flatten(); + let default_val: Option = row.get(3).ok().flatten(); + if let (Some(name), Some(col_type)) = (name, col_type) { + let is_nullable = nullable.as_deref() != Some("N"); + let default_value = default_val + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()); + let primary_key = pk_set.contains(&name); + columns.push(ColumnInfo { + name, + r#type: col_type, + nullable: is_nullable, + default_value, + primary_key, + comment: None, + }); + } + } + Ok(TableStructure { columns }) + }) + .await + } + + async fn get_table_metadata( + &self, + schema: String, + table: String, + ) -> Result { + let columns = self + .get_table_structure(schema.clone(), table.clone()) + .await? + .columns; + + let (indexes, foreign_keys) = self + .run_blocking(move |conn| { + // Indexes + let idx_sql = format!( + "SELECT i.INDEX_NAME, \ + CASE WHEN i.UNIQUENESS = 'UNIQUE' THEN 1 ELSE 0 END AS IS_UNIQUE, \ + i.INDEX_TYPE, \ + ic.COLUMN_NAME, \ + ic.COLUMN_POSITION \ + FROM ALL_INDEXES i \ + JOIN ALL_IND_COLUMNS ic \ + ON ic.INDEX_NAME = i.INDEX_NAME \ + AND ic.TABLE_OWNER = i.TABLE_OWNER \ + WHERE i.TABLE_OWNER = '{}' \ + AND i.TABLE_NAME = '{}' \ + ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let idx_rows = conn + .query(&idx_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut idx_map: HashMap, Vec<(i64, String)>)> = + HashMap::new(); + for row_result in idx_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let idx_name: Option = row.get(0).ok().flatten(); + let is_unique: Option = row.get(1).ok().flatten(); + let idx_type: Option = row.get(2).ok().flatten(); + let col_name: Option = row.get(3).ok().flatten(); + let position: Option = row.get(4).ok().flatten(); + if let (Some(name), Some(col_name)) = (idx_name, col_name) { + let unique = is_unique.unwrap_or(0) == 1; + let pos = position.unwrap_or(0); + let entry = idx_map + .entry(name) + .or_insert((unique, idx_type.clone(), Vec::new())); + entry.0 = unique; + if entry.1.is_none() { + entry.1 = idx_type; + } + entry.2.push((pos, col_name)); + } + } + let mut indexes: Vec = idx_map + .into_iter() + .map(|(name, (unique, index_type, mut cols))| { + cols.sort_by_key(|(pos, _)| *pos); + IndexInfo { + name, + unique, + index_type, + columns: cols.into_iter().map(|(_, c)| c).collect(), + } + }) + .collect(); + indexes.sort_by(|a, b| a.name.cmp(&b.name)); + + // Foreign keys + let fk_sql = format!( + "SELECT c.CONSTRAINT_NAME, \ + cc.COLUMN_NAME, \ + rc.OWNER AS REF_OWNER, \ + rc.TABLE_NAME AS REF_TABLE, \ + rcc.COLUMN_NAME AS REF_COLUMN, \ + c.DELETE_RULE \ + FROM ALL_CONSTRAINTS c \ + JOIN ALL_CONS_COLUMNS cc \ + ON cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME \ + AND cc.OWNER = c.OWNER \ + JOIN ALL_CONSTRAINTS rc \ + ON rc.CONSTRAINT_NAME = c.R_CONSTRAINT_NAME \ + AND rc.OWNER = c.R_OWNER \ + JOIN ALL_CONS_COLUMNS rcc \ + ON rcc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME \ + AND rcc.OWNER = rc.OWNER \ + AND rcc.POSITION = cc.POSITION \ + WHERE c.CONSTRAINT_TYPE = 'R' \ + AND c.OWNER = '{}' \ + AND c.TABLE_NAME = '{}' \ + ORDER BY c.CONSTRAINT_NAME, cc.POSITION", + escape_literal(&schema.to_uppercase()), + escape_literal(&table.to_uppercase()), + ); + let fk_rows = conn + .query(&fk_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut foreign_keys = Vec::new(); + for row_result in fk_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let fk_name: Option = row.get(0).ok().flatten(); + let col_name: Option = row.get(1).ok().flatten(); + let ref_schema: Option = row.get(2).ok().flatten(); + let ref_table: Option = row.get(3).ok().flatten(); + let ref_col: Option = row.get(4).ok().flatten(); + let delete_rule: Option = row.get(5).ok().flatten(); + if let (Some(fk_name), Some(col_name), Some(ref_table), Some(ref_col)) = + (fk_name, col_name, ref_table, ref_col) + { + foreign_keys.push(ForeignKeyInfo { + name: fk_name, + column: col_name, + referenced_schema: ref_schema, + referenced_table: ref_table, + referenced_column: ref_col, + on_update: None, // Oracle does not support ON UPDATE in FK constraints + on_delete: delete_rule, + }); + } + } + Ok((indexes, foreign_keys)) + }) + .await?; + + Ok(TableMetadata { + columns, + indexes, + foreign_keys, + clickhouse_extra: None, + }) + } + + /// Returns the table DDL using DBMS_METADATA.GET_DDL. + /// Requires EXECUTE privilege on DBMS_METADATA (granted to public in most installs). + async fn get_table_ddl(&self, schema: String, table: String) -> Result { + self.run_blocking(move |conn| { + let sql = format!( + "SELECT DBMS_METADATA.GET_DDL('TABLE', '{}', '{}') AS DDL FROM DUAL", + escape_literal(&table.to_uppercase()), + escape_literal(&schema.to_uppercase()), + ); + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let ddl: Option = row.get(0).ok().flatten(); + if let Some(d) = ddl { + return Ok(d.trim().to_string()); + } + } + Err("[QUERY_ERROR] DBMS_METADATA.GET_DDL returned no result".to_string()) + }) + .await + } + + /// Paginated table data. Requires Oracle 12c+ for OFFSET/FETCH syntax. + async fn get_table_data( + &self, + schema: String, + table: String, + page: i64, + limit: i64, + sort_column: Option, + sort_direction: Option, + filter: Option, + order_by: Option, + ) -> Result { + let start = std::time::Instant::now(); + let safe_page = if page < 1 { 1 } else { page }; + let safe_limit = if limit < 1 { 100 } else { limit }; + let offset = (safe_page - 1) * safe_limit; + + let filter = filter.map(|f| super::normalize_quotes(&f)); + let order_by = order_by.map(|f| super::normalize_quotes(&f)); + + let table_ref = format!( + "{}.{}", + quote_ident(&schema.to_uppercase()), + quote_ident(&table.to_uppercase()) + ); + + let where_clause = match &filter { + Some(f) if !f.trim().is_empty() => format!(" WHERE {}", f.trim()), + _ => String::new(), + }; + + let order_clause = if let Some(ref raw) = order_by { + if raw.trim().is_empty() { + String::new() + } else { + format!(" ORDER BY {}", raw.trim()) + } + } else if let Some(ref col) = sort_column { + let dir = if matches!(sort_direction.as_deref(), Some("desc")) { + "DESC" + } else { + "ASC" + }; + format!(" ORDER BY {} {}", quote_ident(col), dir) + } else { + String::new() + }; + + self.run_blocking(move |conn| { + // Total count + let count_sql = format!("SELECT COUNT(*) FROM {}{}", table_ref, where_clause); + let count_rows = conn + .query(&count_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut total: i64 = 0; + for row_result in count_rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + total = row.get::<_, Option>(0).ok().flatten().unwrap_or(0); + } + + // Paginated data (Oracle 12c+ OFFSET/FETCH) + let data_sql = format!( + "SELECT * FROM {}{}{} OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", + table_ref, where_clause, order_clause, offset, safe_limit + ); + let rows = conn + .query(&data_sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + + // Collect column metadata before consuming rows + let col_names: Vec = rows + .column_info() + .iter() + .map(|c| c.name().to_string()) + .collect(); + + let mut data = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut map = serde_json::Map::new(); + for (i, name) in col_names.iter().enumerate() { + map.insert(name.clone(), oracle_value_to_json(&row, i)); + } + data.push(serde_json::Value::Object(map)); + } + + Ok(TableDataResponse { + data, + total, + page: safe_page, + limit: safe_limit, + execution_time_ms: start.elapsed().as_millis() as i64, + }) + }) + .await + } + + async fn get_table_data_chunk( + &self, + schema: String, + table: String, + page: i64, + limit: i64, + sort_column: Option, + sort_direction: Option, + filter: Option, + order_by: Option, + ) -> Result { + self.get_table_data( + schema, + table, + page, + limit, + sort_column, + sort_direction, + filter, + order_by, + ) + .await + } + + async fn execute_query(&self, sql: String) -> Result { + let start = std::time::Instant::now(); + let sql_clean = strip_trailing_statement_terminator(&sql).to_string(); + let first_kw = first_sql_keyword(&sql_clean); + let is_read = matches!( + first_kw.as_deref(), + Some("select") | Some("with") | Some("show") + ); + + self.run_blocking(move |conn| { + if is_read { + let rows = conn + .query(&sql_clean, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + + // Collect column metadata before consuming rows + let col_info: Vec<(String, String)> = rows + .column_info() + .iter() + .map(|c| (c.name().to_string(), format!("{}", c.oracle_type()))) + .collect(); + let columns: Vec = col_info + .iter() + .map(|(name, ty)| QueryColumn { + name: name.clone(), + r#type: ty.clone(), + }) + .collect(); + + let mut data = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut map = serde_json::Map::new(); + for (i, (name, _)) in col_info.iter().enumerate() { + map.insert(name.clone(), oracle_value_to_json(&row, i)); + } + data.push(serde_json::Value::Object(map)); + } + + Ok(QueryResult { + row_count: data.len() as i64, + data, + columns, + time_taken_ms: start.elapsed().as_millis() as i64, + success: true, + error: None, + }) + } else { + // DML or DDL — use Statement API to get affected-row count + let mut stmt = conn + .statement(&sql_clean) + .build() + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + stmt.execute(&[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let row_count = stmt.row_count().unwrap_or(0) as i64; + // Commit so the change is visible after the connection closes. + conn.commit().map_err(|e| format!("[QUERY_ERROR] commit failed: {e}"))?; + Ok(QueryResult { + row_count, + data: vec![], + columns: vec![], + time_taken_ms: start.elapsed().as_millis() as i64, + success: true, + error: None, + }) + } + }) + .await + } + + async fn get_schema_overview(&self, schema: Option) -> Result { + let schema_upper = schema + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()); + self.run_blocking(move |conn| { + let sql = if let Some(ref s) = schema_upper { + format!( + "SELECT OWNER, TABLE_NAME, COLUMN_NAME, DATA_TYPE \ + FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '{}' \ + ORDER BY OWNER, TABLE_NAME, COLUMN_ID", + escape_literal(s), + ) + } else { + "SELECT OWNER, TABLE_NAME, COLUMN_NAME, DATA_TYPE \ + FROM ALL_TAB_COLUMNS \ + ORDER BY OWNER, TABLE_NAME, COLUMN_ID" + .to_string() + }; + + let rows = conn + .query(&sql, &[] as &[&dyn oracle::sql_type::ToSql]) + .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut table_map: HashMap<(String, String), Vec> = HashMap::new(); + for row_result in rows { + let row = row_result.map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let schema_name: Option = row.get(0).ok().flatten(); + let table_name: Option = row.get(1).ok().flatten(); + let col_name: Option = row.get(2).ok().flatten(); + let col_type: Option = row.get(3).ok().flatten(); + if let (Some(sn), Some(tn), Some(cn), Some(ct)) = + (schema_name, table_name, col_name, col_type) + { + table_map + .entry((sn, tn)) + .or_default() + .push(ColumnSchema { name: cn, r#type: ct }); + } + } + let mut tables: Vec = table_map + .into_iter() + .map(|((s, n), cols)| TableSchema { + schema: s, + name: n, + columns: cols, + }) + .collect(); + tables.sort_by(|a, b| a.schema.cmp(&b.schema).then(a.name.cmp(&b.name))); + Ok(SchemaOverview { tables }) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::{escape_literal, first_sql_keyword, quote_ident}; + + #[test] + fn quote_ident_wraps_in_double_quotes() { + assert_eq!(quote_ident("MY_TABLE"), "\"MY_TABLE\""); + } + + #[test] + fn quote_ident_escapes_embedded_double_quote() { + assert_eq!(quote_ident("a\"b"), "\"a\"\"b\""); + } + + #[test] + fn escape_literal_escapes_single_quote() { + assert_eq!(escape_literal("O'Brien"), "O''Brien"); + } + + #[test] + fn first_sql_keyword_extracts_select() { + assert_eq!( + first_sql_keyword(" SELECT id FROM t"), + Some("select".to_string()) + ); + } + + #[test] + fn first_sql_keyword_skips_comments() { + assert_eq!( + first_sql_keyword("-- comment\nINSERT INTO t VALUES(1)"), + Some("insert".to_string()) + ); + } + + #[test] + fn first_sql_keyword_identifies_with() { + assert_eq!( + first_sql_keyword("WITH cte AS (SELECT 1) SELECT * FROM cte"), + Some("with".to_string()) + ); + } +} diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index 3c91e17a..75f7bcb8 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -48,6 +48,7 @@ pub fn start_ssh_tunnel(config: &ConnectionForm) -> Result { let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { "mysql" => 3306, "mssql" => 1433, + "oracle" => 1521, "clickhouse" => 9000, "sqlite" => 0, _ => 5432, // postgres and unknown drivers diff --git a/src/lib/driver-registry.tsx b/src/lib/driver-registry.tsx index 37612be2..71044fd5 100644 --- a/src/lib/driver-registry.tsx +++ b/src/lib/driver-registry.tsx @@ -22,6 +22,7 @@ const DRIVER_IDS = [ "duckdb", "clickhouse", "mssql", + "oracle", ] as const; export type Driver = (typeof DRIVER_IDS)[number]; @@ -149,6 +150,18 @@ export const DRIVER_REGISTRY: DriverConfig[] = [ importCapability: "supported", icon: () => , }, + { + id: "oracle", + label: "Oracle", + defaultPort: 1521, + isFileBased: false, + isMysqlFamily: false, + supportsSSLCA: false, + supportsSchemaBrowsing: true, + supportsCreateDatabase: false, + importCapability: "supported", + icon: () => , + }, ]; export const getDriverConfig = (driver: Driver): DriverConfig => diff --git a/src/lib/driver-registry.unit.test.ts b/src/lib/driver-registry.unit.test.ts index b0394ec4..8b13331a 100644 --- a/src/lib/driver-registry.unit.test.ts +++ b/src/lib/driver-registry.unit.test.ts @@ -15,7 +15,7 @@ import { // ─── Registry completeness ──────────────────────────────────────────────────── describe("DRIVER_REGISTRY", () => { - test("contains all 8 supported drivers", () => { + test("contains all 9 supported drivers", () => { const ids = DRIVER_REGISTRY.map((d) => d.id); expect(ids).toContain("postgres"); expect(ids).toContain("mysql"); @@ -25,7 +25,8 @@ describe("DRIVER_REGISTRY", () => { expect(ids).toContain("duckdb"); expect(ids).toContain("clickhouse"); expect(ids).toContain("mssql"); - expect(DRIVER_REGISTRY).toHaveLength(8); + expect(ids).toContain("oracle"); + expect(DRIVER_REGISTRY).toHaveLength(9); }); test("has no duplicate IDs", () => { @@ -225,6 +226,10 @@ describe("supportsSchemaBrowsing", () => { expect(supportsSchemaBrowsing(d)).toBe(false); } }); + + test("returns true for oracle", () => { + expect(supportsSchemaBrowsing("oracle")).toBe(true); + }); }); // ─── importCapability ───────────────────────────────────────────────────────── @@ -245,6 +250,7 @@ describe("importCapability", () => { "sqlite", "duckdb", "mssql", + "oracle", ]; for (const d of supported) { expect(getDriverConfig(d).importCapability).toBe("supported"); diff --git a/src/services/api.unit.test.ts b/src/services/api.unit.test.ts index 8d63dee1..258fd4dd 100644 --- a/src/services/api.unit.test.ts +++ b/src/services/api.unit.test.ts @@ -104,6 +104,7 @@ describe("getImportDriverCapability", () => { "sqlite", "duckdb", "mssql", + "oracle", ]; for (const driver of supported) { expect(getImportDriverCapability(driver)).toBe("supported"); @@ -111,7 +112,6 @@ describe("getImportDriverCapability", () => { }); test("unknown drivers are unsupported", () => { - expect(getImportDriverCapability("oracle")).toBe("unsupported"); expect(getImportDriverCapability("")).toBe("unsupported"); expect(getImportDriverCapability("mongodb")).toBe("unsupported"); }); From 5ebe1f2a46535308bd396964a6661ced0d1b4f37 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 3 Apr 2026 19:22:37 +0800 Subject: [PATCH 06/15] test(oracle): add integration tests for Oracle driver Add driver integration tests (oracle_integration.rs), command integration tests (oracle_command_integration.rs), and shared test context helper (oracle_context.rs). Update test-integration.sh to support IT_DB=oracle and include Oracle tests in the `all` target. Co-Authored-By: Claude Sonnet 4.6 --- scripts/test-integration.sh | 8 +- src-tauri/tests/common/oracle_context.rs | 39 +++ src-tauri/tests/oracle_command_integration.rs | 276 ++++++++++++++++++ src-tauri/tests/oracle_integration.rs | 254 ++++++++++++++++ 4 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 src-tauri/tests/common/oracle_context.rs create mode 100644 src-tauri/tests/oracle_command_integration.rs create mode 100644 src-tauri/tests/oracle_integration.rs diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index bc644e28..b6079307 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -64,6 +64,10 @@ case "${it_db}" in run_integration_test "sqlite_integration" run_integration_test "sqlite_command_integration" ;; + oracle) + run_integration_test "oracle_integration" + run_integration_test "oracle_command_integration" + ;; all) run_integration_test "mysql_integration" run_integration_test "mysql_command_integration" @@ -81,9 +85,11 @@ case "${it_db}" in run_integration_test "duckdb_command_integration" run_integration_test "sqlite_integration" run_integration_test "sqlite_command_integration" + run_integration_test "oracle_integration" + run_integration_test "oracle_command_integration" ;; *) - echo "[error] Invalid IT_DB='${it_db}'. Expected one of: mysql|mariadb|postgres|clickhouse|mssql|duckdb|sqlite|all" + echo "[error] Invalid IT_DB='${it_db}'. Expected one of: mysql|mariadb|postgres|clickhouse|mssql|duckdb|sqlite|oracle|all" exit 1 ;; esac diff --git a/src-tauri/tests/common/oracle_context.rs b/src-tauri/tests/common/oracle_context.rs new file mode 100644 index 00000000..0b0ccd9d --- /dev/null +++ b/src-tauri/tests/common/oracle_context.rs @@ -0,0 +1,39 @@ +mod shared; + +use dbpaw_lib::models::ConnectionForm; + +pub use shared::{connect_with_retry, should_reuse_local_db}; + +/// Oracle has no freely-distributable Docker image (the Oracle Database Free image +/// at container-registry.oracle.com requires an Oracle account and terms acceptance). +/// Integration tests therefore only support IT_REUSE_LOCAL_DB=1 mode. +/// +/// Required environment variables (all have defaults): +/// ORACLE_HOST – defaults to "localhost" +/// ORACLE_PORT – defaults to 1521 +/// ORACLE_USER – defaults to "system" +/// ORACLE_PASSWORD – no default (required) +/// ORACLE_SERVICE – defaults to "FREE" (service name / SID) +/// ORACLE_SCHEMA – defaults to "SYSTEM" (schema to use for test tables) +pub fn oracle_form_from_test_context() -> ConnectionForm { + if !should_reuse_local_db() { + panic!( + "Oracle integration tests require a local Oracle instance. \ + Set IT_REUSE_LOCAL_DB=1 and provide ORACLE_HOST/PORT/USER/PASSWORD/SERVICE env vars." + ); + } + oracle_form_from_local_env() +} + +fn oracle_form_from_local_env() -> ConnectionForm { + ConnectionForm { + driver: "oracle".to_string(), + host: Some(shared::env_or("ORACLE_HOST", "localhost")), + port: Some(shared::env_i64("ORACLE_PORT", 1521)), + username: Some(shared::env_or("ORACLE_USER", "system")), + password: Some(shared::env_or("ORACLE_PASSWORD", "")), + database: Some(shared::env_or("ORACLE_SERVICE", "FREE")), + schema: Some(shared::env_or("ORACLE_SCHEMA", "SYSTEM")), + ..Default::default() + } +} diff --git a/src-tauri/tests/oracle_command_integration.rs b/src-tauri/tests/oracle_command_integration.rs new file mode 100644 index 00000000..8ac73961 --- /dev/null +++ b/src-tauri/tests/oracle_command_integration.rs @@ -0,0 +1,276 @@ +#[path = "common/oracle_context.rs"] +mod oracle_context; + +use dbpaw_lib::commands::{connection, metadata, query}; +use dbpaw_lib::db::drivers::oracle::OracleDriver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_table_name(prefix: &str) -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_millis(); + format!("{prefix}_{ms}") +} + +async fn prepare_test_table(schema: &str, table: &str, form: &dbpaw_lib::models::ConnectionForm) { + let driver = OracleDriver::connect(form) + .await + .expect("connect for setup should succeed"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" \ + (id NUMBER(10) PRIMARY KEY, name VARCHAR2(64))" + )) + .await + .expect("CREATE TABLE should succeed"); + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name) VALUES (1, 'DbPaw')" + )) + .await + .expect("INSERT should succeed"); + driver.close().await; +} + +async fn cleanup_table(schema: &str, table: &str, form: &dbpaw_lib::models::ConnectionForm) { + let driver = OracleDriver::connect(form) + .await + .expect("connect for cleanup should succeed"); + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; + driver.close().await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_test_connection_success() { + let form = oracle_context::oracle_form_from_test_context(); + let result = connection::test_connection_ephemeral(form) + .await + .expect("test_connection_ephemeral should succeed"); + assert!(result.success); + assert!(result.latency_ms.is_some()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_test_connection_invalid_password_returns_error() { + let mut form = oracle_context::oracle_form_from_test_context(); + form.password = Some("dbpaw_wrong_password_xyz".to_string()); + let result = connection::test_connection_ephemeral(form).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(!err.trim().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_list_databases_returns_schemas() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + + let databases = connection::list_databases(form) + .await + .expect("list_databases should succeed"); + assert!(!databases.is_empty()); + assert!( + databases.iter().any(|d| d == &schema), + "schemas should include {schema}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_list_tables_by_conn_contains_created_table() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_TABLES").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let tables = metadata::list_tables_by_conn(form.clone()) + .await + .expect("list_tables_by_conn should succeed"); + assert!( + tables.iter().any(|t| t.name == table), + "tables should contain {table}" + ); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_select_returns_rows() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_SEL").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let sql = format!("SELECT id, name FROM \"{schema}\".\"{table}\" ORDER BY id"); + let result = query::execute_by_conn_direct(form.clone(), sql) + .await + .expect("execute SELECT should succeed"); + + assert!(result.success); + assert_eq!(result.row_count, 1); + assert!(!result.data.is_empty()); + let row = result.data.first().unwrap(); + let name = row.get("NAME").and_then(|v| v.as_str()); + assert_eq!(name, Some("DbPaw")); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_invalid_sql_returns_error() { + let form = oracle_context::oracle_form_from_test_context(); + let result = + query::execute_by_conn_direct(form, "SELECT * FROM __dbpaw_no_such_table".to_string()) + .await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(!err.trim().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_execute_insert_affects_rows() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_INS").to_uppercase(); + + let driver = OracleDriver::connect(&form) + .await + .expect("connect for setup"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" \ + (id NUMBER(10) PRIMARY KEY, name VARCHAR2(64))" + )) + .await + .expect("CREATE TABLE"); + driver.close().await; + + let sql = format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name) VALUES (1, 'alpha')" + ); + let result = query::execute_by_conn_direct(form.clone(), sql) + .await + .expect("INSERT should succeed"); + assert!(result.success); + assert_eq!(result.row_count, 1); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_get_table_data_pagination_works() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_PAGE").to_uppercase(); + + let driver = OracleDriver::connect(&form) + .await + .expect("connect for setup"); + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" (id NUMBER(10) PRIMARY KEY)" + )) + .await + .expect("CREATE TABLE"); + for i in 1..=3 { + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id) VALUES ({i})" + )) + .await + .expect("INSERT"); + } + driver.close().await; + + let page1 = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 1, 2) + .await + .expect("page 1 should succeed"); + let page2 = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 2, 2) + .await + .expect("page 2 should succeed"); + + assert_eq!(page1.total, 3); + assert_eq!(page1.limit, 2); + assert_eq!(page1.data.len(), 2); + assert_eq!(page2.data.len(), 1); + + cleanup_table(&schema, &table, &form).await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_command_get_table_data_invalid_pagination_returns_error() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let table = unique_table_name("DBPAW_CMD_INVP").to_uppercase(); + prepare_test_table(&schema, &table, &form).await; + + let result = + query::get_table_data_by_conn(form.clone(), schema.clone(), table.clone(), 0, 10).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("[VALIDATION_ERROR]")); + + cleanup_table(&schema, &table, &form).await; +} diff --git a/src-tauri/tests/oracle_integration.rs b/src-tauri/tests/oracle_integration.rs new file mode 100644 index 00000000..02c216e4 --- /dev/null +++ b/src-tauri/tests/oracle_integration.rs @@ -0,0 +1,254 @@ +#[path = "common/oracle_context.rs"] +mod oracle_context; + +use dbpaw_lib::db::drivers::oracle::OracleDriver; +use dbpaw_lib::db::drivers::DatabaseDriver; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_table_name() -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_millis(); + format!("DBPAW_ORA_IT_{}", ms) +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_flow() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + + let driver = oracle_context::connect_with_retry(|| OracleDriver::connect(&form)).await; + + // test_connection + driver + .test_connection() + .await + .expect("test_connection should succeed"); + + // list_databases returns schema names + let schemas = driver + .list_databases() + .await + .expect("list_databases should succeed"); + assert!(!schemas.is_empty(), "list_databases returned empty list"); + assert!( + schemas.iter().any(|s| s == &schema), + "list_databases should include {schema}" + ); + + let table = unique_table_name(); + + // Clean up any leftovers from previous runs + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + + // Create test table + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" ( \ + id NUMBER(10) PRIMARY KEY, \ + name VARCHAR2(50), \ + amount NUMBER(10,2), \ + ts DATE \ + )" + )) + .await + .expect("CREATE TABLE should succeed"); + + // Insert a row + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id, name, amount, ts) \ + VALUES (1, 'hello', 12.34, SYSDATE)" + )) + .await + .expect("INSERT should succeed"); + + // list_tables + let tables = driver + .list_tables(Some(schema.clone())) + .await + .expect("list_tables should succeed"); + assert!( + tables.iter().any(|t| t.name == table), + "list_tables should contain {table}" + ); + + // get_table_structure + let structure = driver + .get_table_structure(schema.clone(), table.clone()) + .await + .expect("get_table_structure should succeed"); + assert!( + !structure.columns.is_empty(), + "structure should have columns" + ); + assert!( + structure.columns.iter().any(|c| c.name == "ID" && c.primary_key), + "ID column should be marked as primary key" + ); + assert!( + structure.columns.iter().any(|c| c.name == "NAME"), + "NAME column should be present" + ); + + // get_table_metadata + let metadata = driver + .get_table_metadata(schema.clone(), table.clone()) + .await + .expect("get_table_metadata should succeed"); + assert!( + metadata.columns.iter().any(|c| c.primary_key), + "metadata should have a primary key column" + ); + + // get_table_ddl + let ddl = driver + .get_table_ddl(schema.clone(), table.clone()) + .await + .expect("get_table_ddl should succeed"); + assert!( + ddl.to_uppercase().contains("CREATE TABLE"), + "DDL should contain CREATE TABLE" + ); + + // get_table_data + let result = driver + .get_table_data( + schema.clone(), + table.clone(), + 1, + 10, + None, + None, + None, + None, + ) + .await + .expect("get_table_data should succeed"); + assert_eq!(result.total, 1, "total should be 1"); + assert_eq!(result.data.len(), 1, "data should have 1 row"); + let row = result.data.first().unwrap(); + assert!( + row.get("ID").is_some() || row.get("id").is_some(), + "row should have ID column" + ); + + // execute_query SELECT + let qr = driver + .execute_query(format!( + "SELECT id, name FROM \"{schema}\".\"{table}\"" + )) + .await + .expect("execute_query SELECT should succeed"); + assert!(qr.success); + assert_eq!(qr.row_count, 1); + assert!( + qr.columns.iter().any(|c| c.name == "ID"), + "columns should include ID" + ); + + // execute_query DML affected rows + let upd = driver + .execute_query(format!( + "UPDATE \"{schema}\".\"{table}\" SET amount = 99.99 WHERE id = 1" + )) + .await + .expect("execute_query UPDATE should succeed"); + assert!(upd.success); + assert_eq!(upd.row_count, 1, "UPDATE should affect 1 row"); + + // get_schema_overview + let overview = driver + .get_schema_overview(Some(schema.clone())) + .await + .expect("get_schema_overview should succeed"); + assert!( + overview.tables.iter().any(|t| t.name == table), + "schema_overview should include {table}" + ); + + // Cleanup + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_pagination() { + let form = oracle_context::oracle_form_from_test_context(); + let schema = form + .schema + .clone() + .expect("ORACLE_SCHEMA must be set") + .to_uppercase(); + let driver = oracle_context::connect_with_retry(|| OracleDriver::connect(&form)).await; + let table = unique_table_name(); + + let _ = driver + .execute_query(format!( + "BEGIN \ + EXECUTE IMMEDIATE 'DROP TABLE \"{schema}\".\"{table}\"'; \ + EXCEPTION WHEN OTHERS THEN NULL; \ + END;" + )) + .await; + + driver + .execute_query(format!( + "CREATE TABLE \"{schema}\".\"{table}\" (id NUMBER(10) PRIMARY KEY)" + )) + .await + .expect("CREATE TABLE should succeed"); + + // Insert 5 rows + for i in 1..=5 { + driver + .execute_query(format!( + "INSERT INTO \"{schema}\".\"{table}\" (id) VALUES ({i})" + )) + .await + .expect("INSERT should succeed"); + } + + let page1 = driver + .get_table_data(schema.clone(), table.clone(), 1, 3, None, None, None, None) + .await + .expect("page 1 should succeed"); + assert_eq!(page1.total, 5); + assert_eq!(page1.data.len(), 3); + + let page2 = driver + .get_table_data(schema.clone(), table.clone(), 2, 3, None, None, None, None) + .await + .expect("page 2 should succeed"); + assert_eq!(page2.data.len(), 2); + + let _ = driver + .execute_query(format!("DROP TABLE \"{schema}\".\"{table}\"")) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_oracle_integration_connection_failure() { + let mut form = oracle_context::oracle_form_from_test_context(); + form.password = Some("dbpaw_wrong_password_xyz".to_string()); + let result = OracleDriver::connect(&form).await; + assert!(result.is_err(), "wrong password should fail"); + let err = result.err().expect("should have an error"); + assert!(err.contains("[CONN_FAILED]"), "error should be tagged CONN_FAILED"); +} From 0a2c4775f834ee15b8d2085e7364fc629592ff95 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 3 Apr 2026 19:22:40 +0800 Subject: [PATCH 07/15] docs: add table-selection-optimization design notes Co-Authored-By: Claude Sonnet 4.6 --- docs/table-selection-optimization.md | 365 +++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 docs/table-selection-optimization.md diff --git a/docs/table-selection-optimization.md b/docs/table-selection-optimization.md new file mode 100644 index 00000000..b6af5914 --- /dev/null +++ b/docs/table-selection-optimization.md @@ -0,0 +1,365 @@ +# 表格拖动选中效果优化方案 + +## 问题分析 + +经过代码分析,当前表格的拖动选中效果存在以下问题: + +### 1. 选中状态切换逻辑问题 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 543-561 行 + +在 `handleCellClick` 中,每次点击单元格都会**清空所有行选中状态**: +```tsx +const nextSelectedRows = new Set(); +selectedRowsRef.current = nextSelectedRows; +setSelectedRows(nextSelectedRows); +``` + +这导致用户无法通过拖动行号列来多选行后,再点击某个单元格保留行选中状态。 + +### 2. 不支持单元格区域拖动选择 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 543-561 行 + +当前的 `handleCellClick` 只支持单单元格点击选中,不支持类似 Excel 的拖动选择矩形区域。 + +### 3. 拖动选择体验不佳 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 489-519 行 + +- 行号列的拖动选择 (`handleIndexMouseDown` / `handleIndexMouseEnter`) 只支持**行选择**,不支持**单元格区域拖动选择** +- 没有视觉反馈指示当前正在拖动选择中 + +### 4. 选中样式过渡生硬 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2009-2029 行 + +单元格的选中样式只有简单的背景色变化,缺少平滑过渡: +```tsx +selected && !editing + ? "bg-accent text-accent-foreground" + : "", +``` + +### 5. 混合选中状态不清晰 +- 单元格选中 (`selectedCell`) 和行选中 (`selectedRows`) 互斥,容易让用户困惑 +- 没有明确区分「单选单元格」和「多选行」的操作模式 + +### 6. 缺少键盘多选支持 +无法通过 `Shift+Click` 或 `Ctrl/Cmd+Click` 进行多选。 + +--- + +## 优化方案:单元格区域拖动选择 + +### 方案概述 +实现类似 Excel 的单元格区域拖动选择功能,允许用户通过鼠标拖动选择一个矩形区域的单元格。 + +### 需要修改的地方 + +#### 1. 新增状态管理 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 218-227 行之后 + +新增以下状态: +```tsx +// Cell range selection state (Excel-like drag selection) +const [selectedRange, setSelectedRange] = useState<{ + startRow: number; + endRow: number; + startColIndex: number; + endColIndex: number; +} | null>(null); +const [isRangeSelecting, setIsRangeSelecting] = useState(false); +const [rangeSelectionAnchor, setRangeSelectionAnchor] = useState<{ + row: number; + colIndex: number; +} | null>(null); +``` + +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 260-261 行之后 + +新增 ref: +```tsx +const selectedRangeRef = useRef<{ + startRow: number; + endRow: number; + startColIndex: number; + endColIndex: number; +} | null>(null); +``` + +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 267-270 行之后 + +添加 useEffect 同步: +```tsx +useEffect(() => { + selectedRangeRef.current = selectedRange; +}, [selectedRange]); +``` + +--- + +#### 2. 修改单元格交互逻辑 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 542-562 行 + +将 `handleCellClick` 替换为三个新函数: + +```tsx +// --- Cell interaction handlers --- +const handleCellMouseDown = useCallback( + (e: React.MouseEvent, rowIndex: number, colIndex: number, col: string) => { + if (e.button !== 0) return; // Only handle left click + + // If editing a different cell, commit first + if ( + editingCell && + (editingCell.row !== rowIndex || editingCell.col !== col) + ) { + commitEdit(); + } + + // Clear row selection when starting cell selection + const nextSelectedRows = new Set(); + selectedRowsRef.current = nextSelectedRows; + setSelectedRows(nextSelectedRows); + setRowSelectionAnchor(null); + setIsRowSelecting(false); + + // Start range selection + setIsRangeSelecting(true); + setRangeSelectionAnchor({ row: rowIndex, colIndex }); + + // Initialize range as single cell + const range = { + startRow: rowIndex, + endRow: rowIndex, + startColIndex: colIndex, + endColIndex: colIndex, + }; + setSelectedRange(range); + selectedRangeRef.current = range; + + // Also set selected cell for compatibility + const nextSelectedCell = { row: rowIndex, col }; + selectedCellRef.current = nextSelectedCell; + setSelectedCell(nextSelectedCell); + }, + [editingCell, commitEdit], +); + +const handleCellMouseEnter = useCallback( + (rowIndex: number, colIndex: number) => { + if (!isRangeSelecting || !rangeSelectionAnchor) return; + + // Calculate the normalized range (start <= end) + const startRow = Math.min(rangeSelectionAnchor.row, rowIndex); + const endRow = Math.max(rangeSelectionAnchor.row, rowIndex); + const startColIndex = Math.min(rangeSelectionAnchor.colIndex, colIndex); + const endColIndex = Math.max(rangeSelectionAnchor.colIndex, colIndex); + + const range = { startRow, endRow, startColIndex, endColIndex }; + setSelectedRange(range); + selectedRangeRef.current = range; + }, + [isRangeSelecting, rangeSelectionAnchor], +); + +const handleCellClick = useCallback( + (rowIndex: number, col: string) => { + // This is now called on mouseup, just ensure state is clean + // The actual selection logic is in handleCellMouseDown + }, + [], +); +``` + +--- + +#### 3. 添加鼠标释放事件处理 +**位置**: `src/components/business/DataGrid/TableView.tsx` 在 useEffect 中添加全局 mouseup 监听 + +在组件中添加以下 useEffect: +```tsx +// Handle mouse up to end range selection +useEffect(() => { + const handleMouseUp = () => { + setIsRangeSelecting(false); + }; + + if (isRangeSelecting) { + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mouseup', handleMouseUp); + } +}, [isRangeSelecting]); +``` + +--- + +#### 4. 修改单元格渲染逻辑 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2004-2083 行 + +修改 `` 的渲染: + +```tsx += selectedRange.startRow && + rowIndex <= selectedRange.endRow && + colIndex >= selectedRange.startColIndex && + colIndex <= selectedRange.endColIndex + ? "bg-primary/10 ring-1 ring-inset ring-primary/30" + : "", + // Single cell selected (when no range or range is single cell) + selected && !editing && !selectedRange + ? "bg-accent text-accent-foreground" + : "", + isRowSelected && !selected && !editing + ? "bg-accent/60" + : "", + matched && !editing + ? "bg-amber-100/60 dark:bg-amber-900/20" + : "", + activeSearchMatch && !editing + ? "border-b-2 border-b-amber-500/70" + : "", + modified && !editing + ? "border-l-2 border-l-orange-400" + : "", + isEditableForUpdates ? "cursor-pointer" : "", + ] + .filter(Boolean) + .join(" ")} + style={{ + width: getColWidth(column), + minWidth: 50, + }} + onMouseDown={(e) => handleCellMouseDown(e, rowIndex, colIndex, column)} + onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} + onClick={() => handleCellClick(rowIndex, column)} + onContextMenu={() => { + if (selectedRows.size > 1 && selectedRows.has(rowIndex)) { + return; + } + handleCellClick(rowIndex, column); + }} + onDoubleClick={() => + handleCellDoubleClick(rowIndex, column, row[column]) + } +> +``` + +--- + +#### 5. 优化选中区域的视觉效果 +**位置**: `src/components/business/DataGrid/TableView.tsx` 样式部分 + +建议添加以下 CSS 样式增强(可以在 tailwind.config.js 或全局 CSS 中): + +```css +/* 选中区域的单元格效果 */ +.cell-in-range { + @apply bg-primary/10 ring-1 ring-inset ring-primary/30; + transition: all 0.1s ease-out; +} + +/* 拖动选择时的视觉反馈 */ +.cell-selecting { + @apply bg-primary/15 ring-2 ring-inset ring-primary/50; +} + +/* 选中区域的活动单元格 */ +.cell-active-in-range { + @apply bg-accent text-accent-foreground font-medium; +} +``` + +--- + +#### 6. 更新复制逻辑以支持选中区域 +**位置**: `src/components/business/DataGrid/TableView.tsx` 复制相关的函数 + +需要添加一个辅助函数来获取选中范围内的数据: + +```tsx +const getSelectedRangeCopyText = useCallback(() => { + if (!selectedRange) return null; + + const { startRow, endRow, startColIndex, endColIndex } = selectedRange; + const rangeData: string[][] = []; + + for (let r = startRow; r <= endRow; r++) { + const rowData: string[] = []; + for (let c = startColIndex; c <= endColIndex; c++) { + const col = columns[c]; + const value = currentData[r]?.[col]; + const displayValue = getCellDisplayValue(r, col, value); + rowData.push( + displayValue === null || displayValue === undefined + ? "" + : String(displayValue) + ); + } + rangeData.push(rowData); + } + + return rangeData.map((row) => row.join("\t")).join("\n"); +}, [selectedRange, columns, currentData, getCellDisplayValue]); +``` + +--- + +#### 7. 更新右键菜单 +**位置**: `src/components/business/DataGrid/TableView.tsx` 第 2088-2191 行 + +在右键菜单中添加对选中范围的复制支持: + +```tsx + { + if (selectedRange) { + const text = getSelectedRangeCopyText(); + if (text) { + handleCopy(text); + } + } else if (selectedCell && selectedCell.row === rowIndex) { + const text = getSelectedCellCopyText(); + if (text !== null) { + handleCopy(text); + } + } + }} +> + + {selectedRange ? "Copy Range" : "Copy Cell"} + +``` + +--- + +## 修改文件清单 + +| 文件路径 | 修改内容 | +|---------|---------| +| `src/components/business/DataGrid/TableView.tsx` | 新增状态、修改交互逻辑、更新渲染逻辑 | + +--- + +## 预期效果 + +1. **拖动选择**: 用户可以在表格上按住鼠标左键拖动,选择一个矩形区域的单元格 +2. **视觉反馈**: 选中的区域会有半透明背景和边框高亮 +3. **平滑过渡**: 选中状态的切换有平滑的过渡动画 +4. **兼容性**: 保持原有的行选择功能(通过行号列)和单单元格选择功能 + +--- + +## 可选增强功能 + +如果需要进一步优化,可以考虑: + +1. **键盘辅助选择**: 支持 `Shift+Click` 范围选择、`Ctrl/Cmd+Click` 多选 +2. **拖动方向指示**: 在拖动过程中显示选择方向的箭头 +3. **选中区域统计**: 在状态栏显示选中区域的行数、列数 +4. **跨页选择**: 支持跨分页的单元格选择(需要更复杂的实现) From 377e8f87d3c4f4843459d6d2a731586910989f92 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 3 Apr 2026 20:51:07 +0800 Subject: [PATCH 08/15] feat(postgres): decode array column types as JSON arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL array columns (_INT2/4/8, _FLOAT4/8, _BOOL, _TEXT, _VARCHAR, _BPCHAR, _NAME, _UUID, _NUMERIC, _JSON, _JSONB) were previously falling through to the String fallback and rendering as "{1,2,3}" in the grid. Now decoded as proper serde_json::Value::Array with correct element types and NULL element support. Also adds ComplexValueViewer dialog (JSON / Tree / Table tabs) for inspecting complex cell values, fixes cell editing to show full JSON instead of [object Object], and centralises all cell→string conversions into a single cellValueToString() utility to eliminate duplicate logic. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/db/drivers/postgres.rs | 132 ++++++++ src-tauri/tests/postgres_integration.rs | 147 +++++++++ .../business/DataGrid/ComplexValueViewer.tsx | 281 ++++++++++++++++ .../business/DataGrid/TableView.tsx | 61 ++-- .../business/DataGrid/tableView/utils.ts | 33 +- .../DataGrid/tableView/utils.unit.test.ts | 223 +++++++++++++ src/services/mocks.ts | 301 +++++++++++++++++- 7 files changed, 1151 insertions(+), 27 deletions(-) create mode 100644 src/components/business/DataGrid/ComplexValueViewer.tsx diff --git a/src-tauri/src/db/drivers/postgres.rs b/src-tauri/src/db/drivers/postgres.rs index e1d1bad9..ad351af4 100644 --- a/src-tauri/src/db/drivers/postgres.rs +++ b/src-tauri/src/db/drivers/postgres.rs @@ -1066,6 +1066,138 @@ impl DatabaseDriver for PostgresDriver { .ok() .map(|v| v.0) .unwrap_or(serde_json::Value::Null), + // PostgreSQL array types (element type prefixed with _) + "_BOOL" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(b) => serde_json::Value::Bool(b), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT2" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT4" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_INT8" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(n) => serde_json::Value::Number(n.into()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_FLOAT4" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(f) => serde_json::Number::from_f64(f as f64) + .map(serde_json::Value::Number) + .unwrap_or_else(|| { + serde_json::Value::String(f.to_string()) + }), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_FLOAT8" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(f) => serde_json::Number::from_f64(f) + .map(serde_json::Value::Number) + .unwrap_or_else(|| { + serde_json::Value::String(f.to_string()) + }), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_NUMERIC" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(d) => serde_json::Value::String(d.to_string()), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_TEXT" | "_VARCHAR" | "_BPCHAR" | "_NAME" | "_UUID" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| match o { + Some(s) => serde_json::Value::String(s), + None => serde_json::Value::Null, + }) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), + "_JSON" | "_JSONB" => row + .try_get::>, _>(name) + .ok() + .map(|v| { + serde_json::Value::Array( + v.into_iter() + .map(|o| o.unwrap_or(serde_json::Value::Null)) + .collect(), + ) + }) + .unwrap_or(serde_json::Value::Null), _ => { if let Ok(v) = row.try_get::(name) { serde_json::Value::String(v) diff --git a/src-tauri/tests/postgres_integration.rs b/src-tauri/tests/postgres_integration.rs index 9c5992be..025581bf 100644 --- a/src-tauri/tests/postgres_integration.rs +++ b/src-tauri/tests/postgres_integration.rs @@ -776,6 +776,153 @@ async fn test_postgres_view_can_be_listed_and_queried() { .await; } +#[tokio::test] +#[ignore] +async fn test_postgres_array_types_decoded_as_json_arrays() { + let docker = (!postgres_context::should_reuse_local_db()).then(Cli::default); + let (_container, form) = postgres_context::postgres_form_from_test_context(docker.as_ref()); + let driver = postgres_context::connect_with_retry(|| PostgresDriver::connect(&form)).await; + + let table_name = "dbpaw_pg_array_type_probe"; + let qualified = format!("public.{}", table_name); + + let _ = driver + .execute_query(format!("DROP TABLE IF EXISTS {}", qualified)) + .await; + + driver + .execute_query(format!( + "CREATE TABLE {} (\ + id INT PRIMARY KEY,\ + ints2 SMALLINT[],\ + ints4 INT[],\ + ints8 BIGINT[],\ + floats4 FLOAT4[],\ + floats8 FLOAT8[],\ + texts TEXT[],\ + bools BOOLEAN[],\ + jsonbs JSONB[]\ + )", + qualified + )) + .await + .expect("create array probe table failed"); + + // row 1: fully populated arrays + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (1, ARRAY[1::smallint,2::smallint], ARRAY[10,20,30], ARRAY[100::bigint,200::bigint], \ + ARRAY[1.5::float4,2.5::float4], ARRAY[3.14::float8,6.28::float8], \ + ARRAY['hello','world'], ARRAY[true,false,true], \ + ARRAY['{{\"a\":1}}'::jsonb,'{{\"b\":2}}'::jsonb])", + qualified + )) + .await + .expect("insert full-array row failed"); + + // row 2: arrays containing NULL elements + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (2, ARRAY[NULL::smallint,5::smallint], ARRAY[NULL::int,42], NULL, \ + NULL, NULL, \ + ARRAY['x',NULL::text,'z'], ARRAY[NULL::boolean], \ + NULL)", + qualified + )) + .await + .expect("insert null-element row failed"); + + // row 3: empty arrays + driver + .execute_query(format!( + "INSERT INTO {} VALUES \ + (3, ARRAY[]::smallint[], ARRAY[]::int[], ARRAY[]::bigint[], \ + ARRAY[]::float4[], ARRAY[]::float8[], \ + ARRAY[]::text[], ARRAY[]::boolean[], \ + ARRAY[]::jsonb[])", + qualified + )) + .await + .expect("insert empty-array row failed"); + + let result = driver + .execute_query(format!("SELECT * FROM {} ORDER BY id", qualified)) + .await + .expect("select array probe rows failed"); + + assert_eq!(result.row_count, 3, "expected 3 rows"); + + // ---- row 1: full arrays ---- + let r1 = &result.data[0]; + + let ints2 = r1["ints2"].as_array().expect("ints2 should be array"); + assert_eq!(ints2.len(), 2); + assert_eq!(ints2[0].as_i64().unwrap_or(-1), 1); + assert_eq!(ints2[1].as_i64().unwrap_or(-1), 2); + + let ints4 = r1["ints4"].as_array().expect("ints4 should be array"); + assert_eq!(ints4.len(), 3); + assert_eq!(ints4[2].as_i64().unwrap_or(-1), 30); + + let ints8 = r1["ints8"].as_array().expect("ints8 should be array"); + assert_eq!(ints8.len(), 2); + assert_eq!(ints8[1].as_i64().unwrap_or(-1), 200); + + let floats8 = r1["floats8"].as_array().expect("floats8 should be array"); + assert_eq!(floats8.len(), 2); + assert!(floats8[0].as_f64().map(|v| (v - 3.14).abs() < 0.01).unwrap_or(false), + "floats8[0] should be ~3.14, got {:?}", floats8[0]); + + let texts = r1["texts"].as_array().expect("texts should be array"); + assert_eq!(texts.len(), 2); + assert_eq!(texts[0].as_str().unwrap_or(""), "hello"); + assert_eq!(texts[1].as_str().unwrap_or(""), "world"); + + let bools = r1["bools"].as_array().expect("bools should be array"); + assert_eq!(bools.len(), 3); + assert_eq!(bools[0], serde_json::Value::Bool(true)); + assert_eq!(bools[1], serde_json::Value::Bool(false)); + + let jsonbs = r1["jsonbs"].as_array().expect("jsonbs should be array"); + assert_eq!(jsonbs.len(), 2); + assert_eq!(jsonbs[0]["a"], serde_json::Value::Number(1.into())); + assert_eq!(jsonbs[1]["b"], serde_json::Value::Number(2.into())); + + // ---- row 2: null elements inside arrays ---- + let r2 = &result.data[1]; + + let ints2_null = r2["ints2"].as_array().expect("ints2 row2 should be array"); + assert_eq!(ints2_null[0], serde_json::Value::Null, "first element should be NULL"); + assert_eq!(ints2_null[1].as_i64().unwrap_or(-1), 5); + + let ints4_null = r2["ints4"].as_array().expect("ints4 row2 should be array"); + assert_eq!(ints4_null[0], serde_json::Value::Null, "first int4 element should be NULL"); + + let texts_null = r2["texts"].as_array().expect("texts row2 should be array"); + assert_eq!(texts_null[0].as_str().unwrap_or(""), "x"); + assert_eq!(texts_null[1], serde_json::Value::Null, "middle text element should be NULL"); + assert_eq!(texts_null[2].as_str().unwrap_or(""), "z"); + + let bools_null = r2["bools"].as_array().expect("bools row2 should be array"); + assert_eq!(bools_null[0], serde_json::Value::Null, "bool element should be NULL"); + + // column-level NULL (entire array is NULL) + assert_eq!(r2["ints8"], serde_json::Value::Null, "whole ints8 column should be NULL"); + + // ---- row 3: empty arrays ---- + let r3 = &result.data[2]; + assert_eq!(r3["ints4"].as_array().expect("ints4 row3").len(), 0); + assert_eq!(r3["texts"].as_array().expect("texts row3").len(), 0); + assert_eq!(r3["bools"].as_array().expect("bools row3").len(), 0); + assert_eq!(r3["jsonbs"].as_array().expect("jsonbs row3").len(), 0); + + let _ = driver + .execute_query(format!("DROP TABLE IF EXISTS {}", qualified)) + .await; +} + #[tokio::test] #[ignore] async fn test_postgres_connection_failure_with_wrong_password() { diff --git a/src/components/business/DataGrid/ComplexValueViewer.tsx b/src/components/business/DataGrid/ComplexValueViewer.tsx new file mode 100644 index 00000000..5ec298c4 --- /dev/null +++ b/src/components/business/DataGrid/ComplexValueViewer.tsx @@ -0,0 +1,281 @@ +import { useState } from "react"; +import { Copy, Check, ChevronRight, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface ComplexValueViewerProps { + value: unknown; + columnName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type TabId = "json" | "tree" | "table"; + +// --- Tree View --- + +function TreeNode({ + label, + value, + depth = 0, +}: { + label: string; + value: unknown; + depth?: number; +}) { + const [expanded, setExpanded] = useState(depth < 2); + const isComplex = value !== null && value !== undefined && typeof value === "object"; + const isArr = Array.isArray(value); + + if (!isComplex) { + const isNull = value === null; + const isStr = typeof value === "string"; + const isBool = typeof value === "boolean"; + const isNum = typeof value === "number"; + return ( +
+ {label} + : + + {isNull ? "null" : isStr ? `"${value}"` : String(value)} + +
+ ); + } + + const entries = isArr + ? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown]) + : Object.entries(value as Record); + + return ( +
+
setExpanded((v) => !v)} + > + + {expanded ? : } + + {label} + : + + {isArr ? `[ ${entries.length} ]` : `{ ${entries.length} }`} + +
+ {expanded && + entries.map(([k, v]) => ( + + ))} +
+ ); +} + +// --- Table View --- + +function cellText(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function TableView({ value }: { value: unknown }) { + if (Array.isArray(value)) { + const arr = value as unknown[]; + const allObjects = + arr.length > 0 && + arr.every((item) => item !== null && typeof item === "object" && !Array.isArray(item)); + + const keys = allObjects + ? Array.from(new Set(arr.flatMap((item) => Object.keys(item as object)))) + : null; + + return ( + + + + {keys ? ( + keys.map((k) => ( + + )) + ) : ( + <> + + + + )} + + + + {arr.map((row, i) => ( + + {keys ? ( + keys.map((k) => { + const v = (row as Record)[k]; + return ( + + ); + }) + ) : ( + <> + + + + )} + + ))} + +
+ {k} + #value
+ {cellText(v)} + {i} + {cellText(row)} +
+ ); + } + + if (value !== null && typeof value === "object") { + return ( + + + + + + + + + {Object.entries(value as Record).map(([k, v]) => ( + + + + + ))} + +
keyvalue
{k} + {cellText(v)} +
+ ); + } + + return null; +} + +// --- Main Component --- + +const TABS: { id: TabId; label: string }[] = [ + { id: "json", label: "JSON" }, + { id: "tree", label: "Tree" }, + { id: "table", label: "Table" }, +]; + +export function ComplexValueViewer({ + value, + columnName, + open, + onOpenChange, +}: ComplexValueViewerProps) { + const [activeTab, setActiveTab] = useState("json"); + const [copied, setCopied] = useState(false); + const formatted = JSON.stringify(value, null, 2); + const typeLabel = Array.isArray(value) ? "array" : "object"; + + const handleCopy = () => { + navigator.clipboard.writeText(formatted).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + + {columnName} + + {/* Header */} +
+ {columnName} + + {typeLabel} + + +
+ + {/* Custom Tab Bar */} +
+ {TABS.map((tab) => { + const active = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab Content */} +
+ {activeTab === "json" && ( + +
+                {formatted}
+              
+
+ )} + {activeTab === "tree" && ( + +
+ +
+
+ )} + {activeTab === "table" && ( + + + + )} +
+
+
+ ); +} diff --git a/src/components/business/DataGrid/TableView.tsx b/src/components/business/DataGrid/TableView.tsx index 77396916..e1a75c61 100644 --- a/src/components/business/DataGrid/TableView.tsx +++ b/src/components/business/DataGrid/TableView.tsx @@ -76,14 +76,18 @@ import { canMutateClickHouseTable, collectSearchMatches, escapeSQL, + cellValueToString, + formatCellValue, formatInsertSQLValue, formatSQLValue, getQualifiedTableName, isClickHouseMergeTreeEngine, + isComplexValue, isInsertColumnRequired, quoteIdent, sortRows, } from "./tableView/utils"; +import { ComplexValueViewer } from "./ComplexValueViewer"; import { toast } from "sonner"; interface PendingChange { @@ -253,6 +257,10 @@ export function TableView({ const [pendingFocusDraftId, setPendingFocusDraftId] = useState( null, ); + const [complexViewer, setComplexViewer] = useState<{ + value: unknown; + columnName: string; + } | null>(null); const editInputRef = useRef(null); const searchInputRef = useRef(null); const saveButtonRef = useRef(null); @@ -545,11 +553,7 @@ export function TableView({ // Check if there's a pending change for this cell const key = `${rowIndex}_${col}`; const pending = pendingChanges.get(key); - const value = pending - ? pending.newValue - : currentValue !== null && currentValue !== undefined - ? String(currentValue) - : ""; + const value = pending ? pending.newValue : cellValueToString(currentValue); setEditingCell({ row: rowIndex, col }); setEditValue(value); setSelectedCell({ row: rowIndex, col }); @@ -569,10 +573,7 @@ export function TableView({ } const sourceRowIndex = data.indexOf(originalRow); const originalValue = originalRow[col]; - const originalStr = - originalValue !== null && originalValue !== undefined - ? String(originalValue) - : ""; + const originalStr = cellValueToString(originalValue); const key = `${row}_${col}`; if (editValue !== originalStr) { @@ -1065,7 +1066,8 @@ export function TableView({ return columns .map((col) => { const value = getCellDisplayValue(rowIndex, col, row[col]); - return value === null || value === undefined ? "" : String(value); + if (value === null || value === undefined) return ""; + return cellValueToString(value); }) .join("\t"); }) @@ -1085,7 +1087,8 @@ export function TableView({ selectedCell.col, row[selectedCell.col], ); - return value === null || value === undefined ? "" : String(value); + if (value === null || value === undefined) return ""; + return cellValueToString(value); }, [currentData, getCellDisplayValue]); const buildRowsCSV = useCallback( @@ -1099,7 +1102,7 @@ export function TableView({ .map((col) => { const value = getCellDisplayValue(rowIndex, col, row[col]); if (value === null || value === undefined) return ""; - const str = String(value); + const str = cellValueToString(value); if ( str.includes(",") || str.includes('"') || @@ -2007,7 +2010,7 @@ export function TableView({ data-row-index={rowIndex} data-col-index={colIndex} className={[ - "px-0 py-0 text-sm text-foreground font-mono border-r border-border relative transition-all duration-150 ease-out", + "px-0 py-0 text-sm text-foreground font-mono border-r border-border relative group transition-all duration-150 ease-out", selected && !editing ? "bg-accent text-accent-foreground" : "", @@ -2065,19 +2068,30 @@ export function TableView({ {displayValue !== null && displayValue !== undefined ? ( - {String(displayValue)} + {formatCellValue(displayValue)} ) : ( NULL )} + {isComplexValue(displayValue) && ( + + )} )} @@ -2271,6 +2285,15 @@ export function TableView({ )} + {complexViewer && ( + { if (!open) setComplexViewer(null); }} + /> + )} +
Query executed in{" "} diff --git a/src/components/business/DataGrid/tableView/utils.ts b/src/components/business/DataGrid/tableView/utils.ts index 04c4a3ad..6e781f6b 100644 --- a/src/components/business/DataGrid/tableView/utils.ts +++ b/src/components/business/DataGrid/tableView/utils.ts @@ -51,7 +51,7 @@ export function calculateAutoColumnWidths({ for (let i = 0; i < sampleSize; i++) { const val = data[i][col]; if (val !== null && val !== undefined) { - const str = String(val); + const str = formatCellValue(val); const len = str.length > 100 ? 100 : str.length; if (len > sampledMaxLen) sampledMaxLen = len; } @@ -122,7 +122,7 @@ export function collectSearchMatches( columns.forEach((column, colIndex) => { const value = getCellDisplayValue(rowIndex, column, row[column]); if (value === null || value === undefined) return; - const content = String(value).toLowerCase(); + const content = formatCellValue(value).toLowerCase(); if (content.includes(normalizedSearchKeyword)) { matches.push({ row: rowIndex, col: column, colIndex }); } @@ -264,6 +264,35 @@ export function getQualifiedTableName( return `${quoteIdent(driver, schema)}.${quoteIdent(driver, table)}`; } +export function isComplexValue(value: unknown): boolean { + return value !== null && value !== undefined && typeof value === "object"; +} + +/** + * Converts a cell value to its full-fidelity string representation. + * Used for editing, clipboard copy, and CSV/TSV export — anywhere the + * complete value is needed rather than an abbreviated display summary. + * Objects and arrays are serialized as JSON; primitives use String(). + */ +export function cellValueToString(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value !== "object") return String(value); + if (Array.isArray(value)) { + return JSON.stringify(value); + } + const keys = Object.keys(value as object); + if (keys.length === 0) return "{}"; + if (keys.length <= 2) return JSON.stringify(value); + return `{${keys.slice(0, 2).join(", ")}, ... +${keys.length - 2}}`; +} + export function isClickHouseMergeTreeEngine( engine: string | undefined | null, ): boolean { diff --git a/src/components/business/DataGrid/tableView/utils.unit.test.ts b/src/components/business/DataGrid/tableView/utils.unit.test.ts index 552c3b01..d2184a45 100644 --- a/src/components/business/DataGrid/tableView/utils.unit.test.ts +++ b/src/components/business/DataGrid/tableView/utils.unit.test.ts @@ -6,10 +6,12 @@ import { canMutateClickHouseTable, collectSearchMatches, escapeSQL, + formatCellValue, formatInsertSQLValue, formatSQLValue, getQualifiedTableName, isClickHouseMergeTreeEngine, + isComplexValue, isInsertColumnRequired, quoteIdent, sortRows, @@ -332,6 +334,227 @@ describe("calculateAutoColumnWidths", () => { }); }); +describe("isComplexValue", () => { + test("returns true for plain objects", () => { + expect(isComplexValue({ a: 1 })).toBe(true); + expect(isComplexValue({})).toBe(true); + }); + + test("returns true for arrays", () => { + expect(isComplexValue([1, 2, 3])).toBe(true); + expect(isComplexValue([])).toBe(true); + }); + + test("returns false for null", () => { + expect(isComplexValue(null)).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isComplexValue(undefined)).toBe(false); + }); + + test("returns false for primitives", () => { + expect(isComplexValue("string")).toBe(false); + expect(isComplexValue(42)).toBe(false); + expect(isComplexValue(true)).toBe(false); + }); +}); + +describe("formatCellValue", () => { + test("null → empty string", () => { + expect(formatCellValue(null)).toBe(""); + }); + + test("undefined → empty string", () => { + expect(formatCellValue(undefined)).toBe(""); + }); + + test("string passes through unchanged", () => { + expect(formatCellValue("hello")).toBe("hello"); + expect(formatCellValue("")).toBe(""); + }); + + test("number → string", () => { + expect(formatCellValue(42)).toBe("42"); + expect(formatCellValue(-3.14)).toBe("-3.14"); + }); + + test("boolean → string", () => { + expect(formatCellValue(true)).toBe("true"); + expect(formatCellValue(false)).toBe("false"); + }); + + test("empty array → []", () => { + expect(formatCellValue([])).toBe("[]"); + }); + + test("array always shows full JSON regardless of length", () => { + expect(formatCellValue(["a"])).toBe('["a"]'); + expect(formatCellValue([1, 2])).toBe("[1,2]"); + expect(formatCellValue([1, 2, 3])).toBe("[1,2,3]"); + expect(formatCellValue(["a", "b", "c", "d"])).toBe('["a","b","c","d"]'); + }); + + test("empty object → {}", () => { + expect(formatCellValue({})).toBe("{}"); + }); + + test("object with 1 key → inline JSON", () => { + expect(formatCellValue({ id: 1 })).toBe('{"id":1}'); + }); + + test("object with 2 keys → inline JSON", () => { + expect(formatCellValue({ id: 1, name: "alice" })).toBe( + '{"id":1,"name":"alice"}', + ); + }); + + test("object with 3+ keys → abbreviated summary", () => { + const result = formatCellValue({ a: 1, b: 2, c: 3 }); + expect(result).toMatch(/^\{a, b, \.\.\. \+1\}$/); + }); + + test("object with many keys → shows first 2 keys and remainder count", () => { + const result = formatCellValue({ id: 1, name: "x", role: "admin", score: 99 }); + expect(result).toMatch(/^\{id, name, \.\.\. \+2\}$/); + }); + + test("nested object with 2 keys → inline JSON (no recursion into children)", () => { + const result = formatCellValue({ user: { name: "alice" } }); + expect(result).toBe('{"user":{"name":"alice"}}'); + }); + + test("array of objects → full JSON", () => { + expect(formatCellValue([{ id: 1 }, { id: 2 }, { id: 3 }])).toBe( + '[{"id":1},{"id":2},{"id":3}]', + ); + }); +}); + +describe("formatCellValue: integration with collectSearchMatches", () => { + test("JSON object fields are searchable by key name", () => { + const data = [ + { id: 1, meta: { role: "admin", tags: ["vip"] } }, + { id: 2, meta: { role: "user", tags: [] } }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + const matches = collectSearchMatches(data, ["id", "meta"], "admin", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + expect(matches[0].col).toBe("meta"); + }); + + test("array fields are searchable by content", () => { + const data = [ + { tags: ["read", "write"] }, + { tags: ["read"] }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + const matches = collectSearchMatches(data, ["tags"], "write", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + }); +}); + +describe("calculateAutoColumnWidths: complex value handling", () => { + test("uses formatted string length for objects, not [object Object]", () => { + // A 3-key object formats to ~20 chars, not 15 ('[object Object]') + const result = calculateAutoColumnWidths({ + data: [{ meta: { id: 1, name: "alice", role: "admin" } }], + columns: ["meta"], + columnWidths: {}, + }); + // If it used String() it would give '[object Object]' = 15 chars + // formatCellValue gives '{id, name, ... +1}' = 18 chars + // Either way width is > minimum, but we verify it doesn't crash + expect(result["meta"]).toBeGreaterThan(0); + }); +}); + +describe("formatCellValue: PostgreSQL array column output", () => { + // These tests verify the display format for values that come back from the + // PostgreSQL backend after the array-type fix (actual JS arrays, not strings). + + test("int array displays as compact JSON", () => { + expect(formatCellValue([10, 20, 30])).toBe("[10,20,30]"); + }); + + test("text array displays as compact JSON string array", () => { + expect(formatCellValue(["postgres", "arrays"])).toBe('["postgres","arrays"]'); + }); + + test("bool array displays as compact JSON", () => { + expect(formatCellValue([true, false, true])).toBe("[true,false,true]"); + }); + + test("float array displays as compact JSON", () => { + expect(formatCellValue([3.14, 2.72])).toBe("[3.14,2.72]"); + }); + + test("jsonb array (array of objects) displays as full JSON", () => { + const val = [{ source: "web", valid: true }, { source: "app", valid: false }]; + expect(formatCellValue(val)).toBe(JSON.stringify(val)); + }); + + test("empty array displays as []", () => { + expect(formatCellValue([])).toBe("[]"); + }); + + test("array with null element displays null in JSON", () => { + expect(formatCellValue([1, null, 3])).toBe("[1,null,3]"); + }); + + test("null column (entire array is null) → empty string", () => { + expect(formatCellValue(null)).toBe(""); + }); +}); + +describe("isComplexValue: PostgreSQL array column output", () => { + test("JS arrays from backend are complex", () => { + expect(isComplexValue([10, 20, 30])).toBe(true); + expect(isComplexValue(["a", "b"])).toBe(true); + expect(isComplexValue([])).toBe(true); + }); + + test("null column-level value is not complex", () => { + expect(isComplexValue(null)).toBe(false); + }); + + test("primitive types are not complex", () => { + expect(isComplexValue(42)).toBe(false); + expect(isComplexValue("hello")).toBe(false); + expect(isComplexValue(true)).toBe(false); + }); +}); + +describe("collectSearchMatches: PostgreSQL array columns are searchable", () => { + const data = [ + { id: 1, tags: ["postgres", "arrays", "jsonb"] }, + { id: 2, tags: ["mysql", "innodb"] }, + { id: 3, tags: [] }, + { id: 4, tags: null }, + ]; + const identity = (_row: number, _col: string, val: any) => val; + + test("finds match inside text array content", () => { + const matches = collectSearchMatches(data, ["id", "tags"], "jsonb", identity); + expect(matches.length).toBe(1); + expect(matches[0].row).toBe(0); + expect(matches[0].col).toBe("tags"); + }); + + test("does not match empty array", () => { + const matches = collectSearchMatches(data, ["tags"], "postgres", identity); + // only row 0 should match, not row 2 (empty) or row 3 (null) + expect(matches.every((m) => m.row === 0)).toBe(true); + }); + + test("skips null array columns gracefully", () => { + const matches = collectSearchMatches(data, ["tags"], "null", identity); + expect(matches).toEqual([]); + }); +}); + describe("mutation statement builders", () => { test("builds clickhouse alter update/delete statements", () => { expect( diff --git a/src/services/mocks.ts b/src/services/mocks.ts index 7eb67752..781bec53 100644 --- a/src/services/mocks.ts +++ b/src/services/mocks.ts @@ -38,6 +38,16 @@ export const mockConnections: any[] = [ filePath: "/path/to/database.db", createdAt: new Date().toISOString(), }, + { + id: 3, + name: "PostgreSQL JSONB Test", + dbType: "postgres", + host: "localhost", + port: 5432, + database: "jsondb", + username: "postgres", + createdAt: new Date().toISOString(), + }, ]; export const mockTables: { schema: string; name: string; type: string }[] = [ @@ -61,6 +71,10 @@ export const mockTables: { schema: string; name: string; type: string }[] = [ { schema: "analytics", name: "page_views", type: "table" }, { schema: "analytics", name: "events", type: "table" }, { schema: "analytics", name: "funnels", type: "table" }, + // complex-type test table — SELECT * FROM json_test returns mockComplexTypeData + { schema: "public", name: "json_test", type: "table" }, + // array-type test table — SELECT * FROM pg_arrays returns mockArrayTypeData + { schema: "public", name: "pg_arrays", type: "table" }, ]; export const mockTableStructure = { @@ -198,6 +212,11 @@ export const mockTableData = { password_hash: "hashed_password_1", created_at: "2024-01-15 10:30:00", updated_at: "2024-01-15 10:30:00", + // object with 4 keys → abbreviated as {role, department, ... +2} + metadata: { role: "admin", department: "engineering", level: 5, active: true }, + // array with 3 items → [3 items] + tags: ["vip", "beta-tester", "early-adopter"], + settings: null, }, { id: 2, @@ -206,6 +225,12 @@ export const mockTableData = { password_hash: "hashed_password_2", created_at: "2024-01-16 11:45:00", updated_at: "2024-01-16 11:45:00", + // object with 2 keys → inline JSON + metadata: { role: "user", department: "marketing" }, + // array with 1 item → inline JSON + tags: ["newsletter"], + // nested object → tree view shows expand/collapse + settings: { theme: "dark", lang: "zh", notifications: { email: true, sms: false } }, }, { id: 3, @@ -214,6 +239,11 @@ export const mockTableData = { password_hash: "hashed_password_3", created_at: "2024-01-17 14:20:00", updated_at: "2024-01-17 14:20:00", + // empty object → {} + metadata: {}, + // empty array → [] + tags: [], + settings: { theme: "light", lang: "en" }, }, { id: 4, @@ -222,6 +252,10 @@ export const mockTableData = { password_hash: "hashed_password_4", created_at: "2024-01-18 09:15:00", updated_at: "2024-01-18 09:15:00", + // object containing a nested array + metadata: { role: "moderator", permissions: ["read", "write", "delete"], score: 88 }, + tags: ["moderator", "trusted"], + settings: null, }, { id: 5, @@ -230,14 +264,222 @@ export const mockTableData = { password_hash: "hashed_password_5", created_at: "2024-01-19 16:50:00", updated_at: "2024-01-19 16:50:00", + // array of objects → table view renders as multi-column table + metadata: [{ key: "plan", value: "pro" }, { key: "trial", value: false }], + tags: ["pro"], + // object with 4 keys → tree/table view + settings: { theme: "system", lang: "ja", timezone: "Asia/Tokyo", fontSize: 14 }, + }, + { + id: 6, + username: "frank", + email: "frank@example.com", + password_hash: "hashed_password_6", + created_at: "2024-01-20 08:00:00", + updated_at: "2024-01-20 08:00:00", + // deeply nested 3-level object → tree view shows recursive expand + metadata: { + profile: { + address: { city: "Shanghai", country: "CN", zip: "200000" }, + contact: { phone: "138-0000-0001", wechat: "frank_wx" }, + }, + billing: { plan: "enterprise", seats: 50, currency: "CNY" }, + }, + // large array (10 items) → [10 items] + tags: ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + settings: { theme: "dark", lang: "en", beta: false }, + }, + { + id: 7, + username: "grace", + email: "grace@example.com", + password_hash: "hashed_password_7", + created_at: "2024-01-21 09:30:00", + updated_at: "2024-01-21 09:30:00", + // array of objects with uniform shape → table view renders nicely + metadata: [ + { name: "cpu", value: "85%", status: "warn" }, + { name: "memory", value: "42%", status: "ok" }, + { name: "disk", value: "91%", status: "critical" }, + { name: "network", value: "12%", status: "ok" }, + ], + // mixed-type array → table view falls back to index/value layout + tags: ["ops", 42, true, null, { env: "prod" }], + settings: null, + }, + { + id: 8, + username: "henry", + email: "henry@example.com", + password_hash: "hashed_password_8", + created_at: "2024-01-22 11:00:00", + updated_at: "2024-01-22 11:00:00", + // object with null/boolean values inside + metadata: { verified: true, banned: false, reason: null, score: 0 }, + tags: ["new"], + // deeply nested settings + settings: { + ui: { sidebar: "collapsed", density: "compact", animations: true }, + editor: { fontSize: 13, tabSize: 2, wordWrap: true, minimap: false }, + shortcuts: { save: "Ctrl+S", run: "F5", format: "Shift+Alt+F" }, + }, + }, + { + id: 9, + username: "iris", + email: "iris@example.com", + password_hash: "hashed_password_9", + created_at: "2024-01-23 14:45:00", + updated_at: "2024-01-23 14:45:00", + // object with only 1 key → inline JSON {"role":"guest"} + metadata: { role: "guest" }, + // array with exactly 2 items → inline JSON + tags: ["read-only", "trial"], + settings: { theme: "light", lang: "en", timezone: "UTC" }, + }, + { + id: 10, + username: "jack", + email: "jack@example.com", + password_hash: "hashed_password_10", + created_at: "2024-01-24 16:20:00", + updated_at: "2024-01-24 16:20:00", + // all three complex fields are null → verify null rendering unchanged + metadata: null, + tags: null, + settings: null, }, ], - total: 5, + total: 10, page: 1, limit: 10, executionTimeMs: 25, }; +// Dedicated dataset for querying "SELECT * FROM json_test" in mock mode. +// Covers every complex-type edge case in a single focused table. +export const mockComplexTypeData: QueryResult = { + rowCount: 8, + timeTakenMs: 12, + success: true, + columns: [ + { name: "id", type: "integer" }, + { name: "label", type: "text" }, + { name: "payload", type: "jsonb" }, + { name: "notes", type: "text" }, + ], + data: [ + { + id: 1, + label: "flat object (2 keys)", + payload: { name: "alice", age: 30 }, + notes: "inline JSON in cell", + }, + { + id: 2, + label: "flat object (4+ keys)", + payload: { id: 42, role: "admin", active: true, score: 99 }, + notes: "abbreviated as {id, role, ... +2}", + }, + { + id: 3, + label: "nested object (3 levels)", + payload: { + user: { + profile: { city: "Beijing", country: "CN" }, + prefs: { lang: "zh", theme: "dark" }, + }, + meta: { version: 2, flags: ["a", "b"] }, + }, + notes: "tree view shows recursive expand/collapse", + }, + { + id: 4, + label: "array of primitives (10 items)", + payload: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + notes: "[10 items] in cell", + }, + { + id: 5, + label: "array of objects (uniform shape)", + payload: [ + { metric: "cpu", value: 72, unit: "%" }, + { metric: "mem", value: 48, unit: "%" }, + { metric: "disk", value: 91, unit: "%" }, + ], + notes: "table view renders as multi-column table", + }, + { + id: 6, + label: "mixed-type array", + payload: ["text", 42, true, null, { nested: "obj" }, [1, 2]], + notes: "table view falls back to index/value layout", + }, + { + id: 7, + label: "empty containers", + payload: {}, + notes: "verify {} and [] display correctly", + }, + { + id: 8, + label: "null value", + payload: null, + notes: "should display NULL (italic), no expand icon", + }, + ], +}; + +// Dedicated dataset for querying "SELECT * FROM pg_arrays" in mock mode. +// Simulates what PostgreSQL array columns look like after the backend fix. +export const mockArrayTypeData: QueryResult = { + rowCount: 4, + timeTakenMs: 8, + success: true, + columns: [ + { name: "id", type: "integer" }, + { name: "tags", type: "text[]" }, + { name: "scores", type: "int4[]" }, + { name: "flags", type: "bool[]" }, + { name: "readings", type: "float8[]" }, + { name: "metadata_list", type: "jsonb[]" }, + ], + data: [ + { + id: 1, + tags: ["postgres", "arrays", "jsonb"], + scores: [95, 87, 72], + flags: [true, false, true], + readings: [3.14, 2.72, 1.41], + metadata_list: [{ source: "web", valid: true }, { source: "app", valid: false }], + }, + { + id: 2, + tags: ["empty-arrays-test"], + scores: [], + flags: [], + readings: [], + metadata_list: [], + }, + { + id: 3, + tags: ["null-elements", null, "after-null"], + scores: [1, null, 3], + flags: [null, true], + readings: [null, 9.99], + metadata_list: [null, { key: "value" }], + }, + { + id: 4, + tags: null, + scores: null, + flags: null, + readings: null, + metadata_list: null, + }, + ], +}; + export const mockDatabases = [ "postgres", "template1", @@ -271,7 +513,7 @@ export const mockSavedQueries: SavedQuery[] = [ export const mockQueryResult: QueryResult = { data: mockTableData.data, - rowCount: 5, + rowCount: 10, columns: [ { name: "id", type: "integer" }, { name: "username", type: "varchar" }, @@ -279,6 +521,9 @@ export const mockQueryResult: QueryResult = { { name: "password_hash", type: "varchar" }, { name: "created_at", type: "timestamp" }, { name: "updated_at", type: "timestamp" }, + { name: "metadata", type: "jsonb" }, + { name: "tags", type: "text[]" }, + { name: "settings", type: "jsonb" }, ], timeTakenMs: 45, success: true, @@ -515,8 +760,19 @@ export async function mockExecuteQuery( // Return different data based on query type if (lower.includes("select")) { + // Dedicated array-type dataset: SELECT * FROM pg_arrays + const isArrayQuery = + lower.includes("pg_arrays") || + lower.includes("array"); + // Dedicated complex-type dataset: SELECT * FROM json_test + const isComplexQuery = + !isArrayQuery && + (lower.includes("json_test") || + lower.includes("json") || + lower.includes("jsonb") || + lower.includes("complex")); const result = { - ...mockQueryResult, + ...(isArrayQuery ? mockArrayTypeData : isComplexQuery ? mockComplexTypeData : mockQueryResult), timeTakenMs: Math.floor(Math.random() * 100) + 20, }; appendSqlExecutionLog({ @@ -622,6 +878,30 @@ export async function mockGetTableDDL( /** * Mock get table metadata */ +const mockJsonTestTableMetadata: TableMetadata = { + columns: [ + { name: "id", type: "integer", nullable: false, primaryKey: true }, + { name: "label", type: "text", nullable: false, primaryKey: false }, + { name: "payload", type: "jsonb", nullable: true, primaryKey: false }, + { name: "notes", type: "text", nullable: true, primaryKey: false }, + ], + indexes: [], + foreignKeys: [], +}; + +const mockArrayTestTableMetadata: TableMetadata = { + columns: [ + { name: "id", type: "integer", nullable: false, primaryKey: true }, + { name: "tags", type: "text[]", nullable: true, primaryKey: false }, + { name: "scores", type: "int4[]", nullable: true, primaryKey: false }, + { name: "flags", type: "bool[]", nullable: true, primaryKey: false }, + { name: "readings", type: "float8[]", nullable: true, primaryKey: false }, + { name: "metadata_list", type: "jsonb[]", nullable: true, primaryKey: false }, + ], + indexes: [], + foreignKeys: [], +}; + export async function mockGetTableMetadata( _id: number, _database: string | undefined, @@ -629,6 +909,8 @@ export async function mockGetTableMetadata( _table: string, ): Promise { await new Promise((resolve) => setTimeout(resolve, 50)); + if (_table === "json_test") return mockJsonTestTableMetadata; + if (_table === "pg_arrays") return mockArrayTestTableMetadata; return mockTableMetadata; } @@ -694,13 +976,20 @@ export async function mockGetTableData(params: { }> { await new Promise((resolve) => setTimeout(resolve, 100)); - const { page = 1, limit = 10 } = params; + const { page = 1, limit = 10, table } = params; const start = (page - 1) * limit; const end = start + limit; + const source = + table === "json_test" + ? { data: mockComplexTypeData.data, total: mockComplexTypeData.rowCount } + : table === "pg_arrays" + ? { data: mockArrayTypeData.data, total: mockArrayTypeData.rowCount } + : mockTableData; + return { - data: mockTableData.data.slice(start, end), - total: mockTableData.total, + data: source.data.slice(start, end), + total: source.total, page, limit, executionTimeMs: Math.floor(Math.random() * 50) + 20, From a45cf271f7cd65376fddbd80f572c543ec8aadc8 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 3 Apr 2026 20:51:15 +0800 Subject: [PATCH 09/15] fix(oracle): add Instant Client missing hint to conn_failed_error Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/db/drivers/mod.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/db/drivers/mod.rs b/src-tauri/src/db/drivers/mod.rs index 3aa03945..803d6841 100644 --- a/src-tauri/src/db/drivers/mod.rs +++ b/src-tauri/src/db/drivers/mod.rs @@ -26,7 +26,12 @@ pub(crate) fn conn_failed_error(e: &dyn std::fmt::Display) -> String { let raw = e.to_string(); let lower = raw.to_ascii_lowercase(); - let hint = if lower.contains("handshake") + let hint = if lower.contains("dpi-1047") || lower.contains("cannot locate a 64-bit oracle client") { + "hint: Oracle Instant Client is not installed — download it from \ + https://www.oracle.com/database/technologies/instant-client/downloads.html \ + and add the directory containing libclntsh to your library path \ + (macOS: DYLD_LIBRARY_PATH; Linux: LD_LIBRARY_PATH)" + } else if lower.contains("handshake") || lower.contains("fatal alert") || lower.contains("tls") || lower.contains("ssl") @@ -173,6 +178,17 @@ pub async fn connect(form: &ConnectionForm) -> Result, S mod tests { use super::{conn_failed_error, strip_trailing_statement_terminator}; + #[test] + fn conn_failed_error_oracle_client_hint() { + let msg = conn_failed_error( + &"DPI-1047: Cannot locate a 64-bit Oracle Client library: \"dlopen(libclntsh.dylib, 0x0001): tried: '/usr/local/lib/libclntsh.dylib' (no such file)\"", + ); + assert!(msg.starts_with("[CONN_FAILED]")); + assert!(msg.contains("Oracle Instant Client is not installed")); + assert!(msg.contains("DYLD_LIBRARY_PATH")); + assert!(!msg.contains("TLS/SSL handshake failed")); + } + #[test] fn conn_failed_error_tls_hint() { let msg = conn_failed_error( From 4841b6577b5439bc142a3168971fdc9ba0d90262 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Tue, 7 Apr 2026 20:06:32 +0800 Subject: [PATCH 10/15] chore(ci): remove Chinese changelog generation from release workflow The cliff.zh.toml config file doesn't exist, so the Chinese changelog step was not producing actual Chinese output. Simplify the workflow to only generate English changelog. --- .github/workflows/release.yml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb298fc2..167de31a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,30 +31,18 @@ jobs: env: OUTPUT: CHANGELOG.md - - name: Generate Changelog (Chinese) - uses: orhun/git-cliff-action@v4 - id: git_cliff_zh - with: - config: cliff.zh.toml - args: --verbose --latest --strip header - env: - OUTPUT: CHANGELOG_CN.md - - name: Create or Update Draft Release uses: actions/github-script@v8 env: TAG_NAME: ${{ inputs.tag || github.ref_name }} - RELEASE_BODY_EN: ${{ steps.git_cliff_en.outputs.content || 'See the assets to download this version and install.' }} - RELEASE_BODY_ZH: ${{ steps.git_cliff_zh.outputs.content || '请下载附件安装此版本。' }} + RELEASE_BODY: ${{ steps.git_cliff_en.outputs.content || 'See the assets to download this version and install.' }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const tag = process.env.TAG_NAME; const owner = context.repo.owner; const repo = context.repo.repo; - const bodyEn = process.env.RELEASE_BODY_EN; - const bodyZh = process.env.RELEASE_BODY_ZH; - const body = bodyEn + "\n\n---\n\n**中文版**\n\n" + bodyZh; + const body = process.env.RELEASE_BODY; const releaseName = `DbPaw ${tag}`; const manualTag = context.payload?.inputs?.tag; From fd6e8dc5d849abbd78a7d7e12aba5b55e96bf83b Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Tue, 7 Apr 2026 20:24:52 +0800 Subject: [PATCH 11/15] Add column autocomplete to table filters --- package-lock.json | 4 +- .../business/DataGrid/TableView.tsx | 36 ++-- .../tableView/ColumnAutocompleteInput.tsx | 167 ++++++++++++++++++ .../DataGrid/tableView/columnAutocomplete.ts | 47 +++++ .../tableView/columnAutocomplete.unit.test.ts | 66 +++++++ src/lockfile.unit.test.ts | 123 ++----------- src/types/bun-test.d.ts | 6 + 7 files changed, 326 insertions(+), 123 deletions(-) create mode 100644 src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx create mode 100644 src/components/business/DataGrid/tableView/columnAutocomplete.ts create mode 100644 src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 390da769..6501ed68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dbpaw", - "version": "0.2.9", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dbpaw", - "version": "0.2.9", + "version": "0.3.1", "dependencies": { "@codemirror/lang-sql": "^6.10.0", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/src/components/business/DataGrid/TableView.tsx b/src/components/business/DataGrid/TableView.tsx index e1a75c61..ed98aba5 100644 --- a/src/components/business/DataGrid/TableView.tsx +++ b/src/components/business/DataGrid/TableView.tsx @@ -88,6 +88,8 @@ import { sortRows, } from "./tableView/utils"; import { ComplexValueViewer } from "./ComplexValueViewer"; +import { ColumnAutocompleteInput } from "./tableView/ColumnAutocompleteInput"; +import type { ColumnAutocompleteOption } from "./tableView/columnAutocomplete"; import { toast } from "sonner"; interface PendingChange { @@ -244,6 +246,16 @@ export function TableView({ const [columnComments, setColumnComments] = useState>( {}, ); + const columnAutocompleteOptions = useMemo(() => { + if (tableColumns.length > 0) { + return tableColumns.map((column) => ({ + name: column.name, + type: column.type, + })); + } + + return columns.map((column) => ({ name: column })); + }, [columns, tableColumns]); const [isSaving, setIsSaving] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -1833,32 +1845,24 @@ export function TableView({
- setWhereInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onFilterChange(whereInput, orderByInput); - } - }} + onValueChange={setWhereInput} + onSubmit={() => onFilterChange(whereInput, orderByInput)} + options={columnAutocompleteOptions} />
- setOrderByInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onFilterChange(whereInput, orderByInput); - } - }} + onValueChange={setOrderByInput} + onSubmit={() => onFilterChange(whereInput, orderByInput)} + options={columnAutocompleteOptions} />
{tableContext && mutabilityHint && ( diff --git a/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx b/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx new file mode 100644 index 00000000..e06f46ec --- /dev/null +++ b/src/components/business/DataGrid/tableView/ColumnAutocompleteInput.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/components/ui/utils"; +import { + getAutocompleteToken, + getColumnAutocompleteOptions, + replaceAutocompleteToken, + type ColumnAutocompleteOption, +} from "./columnAutocomplete"; + +interface ColumnAutocompleteInputProps { + value: string; + onValueChange: (value: string) => void; + onSubmit: () => void; + options: ColumnAutocompleteOption[]; + placeholder: string; + className?: string; +} + +export function ColumnAutocompleteInput({ + value, + onValueChange, + onSubmit, + options, + placeholder, + className, +}: ColumnAutocompleteInputProps) { + const inputRef = useRef(null); + const [cursorIndex, setCursorIndex] = useState(value.length); + const [activeIndex, setActiveIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + const token = useMemo( + () => getAutocompleteToken(value, cursorIndex), + [value, cursorIndex], + ); + + const filteredOptions = useMemo( + () => getColumnAutocompleteOptions(options, token), + [options, token], + ); + + const hasSuggestions = filteredOptions.length > 0; + + useEffect(() => { + setActiveIndex(0); + setIsOpen(hasSuggestions); + }, [hasSuggestions, token?.text]); + + const syncCursor = useCallback(() => { + const nextCursor = inputRef.current?.selectionStart ?? value.length; + setCursorIndex(nextCursor); + }, [value.length]); + + const acceptSuggestion = useCallback( + (option: ColumnAutocompleteOption) => { + if (!token) return; + + const nextValue = replaceAutocompleteToken(value, token, option.name); + const nextCursor = token.from + option.name.length; + onValueChange(nextValue); + setCursorIndex(nextCursor); + setIsOpen(false); + + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(nextCursor, nextCursor); + }); + }, + [onValueChange, token, value], + ); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (isOpen && hasSuggestions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((idx) => (idx + 1) % filteredOptions.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex( + (idx) => (idx - 1 + filteredOptions.length) % filteredOptions.length, + ); + return; + } + + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + acceptSuggestion(filteredOptions[activeIndex]); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setIsOpen(false); + return; + } + } + + if (event.key === "Enter") { + onSubmit(); + } + }; + + return ( + + + { + onValueChange(event.target.value); + setCursorIndex( + event.target.selectionStart ?? event.target.value.length, + ); + }} + onClick={syncCursor} + onKeyUp={syncCursor} + onKeyDown={handleKeyDown} + /> + + event.preventDefault()} + className="w-[260px] p-1 shadow-lg" + > +
+ {filteredOptions.map((option, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/business/DataGrid/tableView/columnAutocomplete.ts b/src/components/business/DataGrid/tableView/columnAutocomplete.ts new file mode 100644 index 00000000..2992b75a --- /dev/null +++ b/src/components/business/DataGrid/tableView/columnAutocomplete.ts @@ -0,0 +1,47 @@ +export interface ColumnAutocompleteOption { + name: string; + type?: string; +} + +export interface AutocompleteToken { + from: number; + to: number; + text: string; +} + +export const MAX_COLUMN_AUTOCOMPLETE_OPTIONS = 8; + +export function getAutocompleteToken( + value: string, + cursorIndex: number, +): AutocompleteToken | null { + const beforeCursor = value.slice(0, cursorIndex); + const match = beforeCursor.match(/[A-Za-z_][A-Za-z0-9_$]*$/); + if (!match || match.index === undefined) return null; + + return { + from: match.index, + to: cursorIndex, + text: match[0], + }; +} + +export function replaceAutocompleteToken( + value: string, + token: AutocompleteToken, + replacement: string, +) { + return `${value.slice(0, token.from)}${replacement}${value.slice(token.to)}`; +} + +export function getColumnAutocompleteOptions( + options: ColumnAutocompleteOption[], + token: AutocompleteToken | null, +) { + const text = token?.text.toLowerCase(); + if (!text) return []; + + return options + .filter((option) => option.name.toLowerCase().startsWith(text)) + .slice(0, MAX_COLUMN_AUTOCOMPLETE_OPTIONS); +} diff --git a/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts b/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts new file mode 100644 index 00000000..5b2e1a50 --- /dev/null +++ b/src/components/business/DataGrid/tableView/columnAutocomplete.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + getAutocompleteToken, + getColumnAutocompleteOptions, + replaceAutocompleteToken, + type ColumnAutocompleteOption, +} from "./columnAutocomplete"; + +describe("column autocomplete", () => { + test("finds the token immediately before the cursor", () => { + expect(getAutocompleteToken("status = 1 AND ord", 18)).toEqual({ + from: 15, + to: 18, + text: "ord", + }); + }); + + test("returns null when the cursor is not after an identifier token", () => { + expect(getAutocompleteToken("status = ", 9)).toBeNull(); + }); + + test("does not attempt SQL string parsing", () => { + expect(getAutocompleteToken("'ord", 4)).toEqual({ + from: 1, + to: 4, + text: "ord", + }); + }); + + test("replaces only the active token", () => { + const token = getAutocompleteToken("status = 1 AND ord", 18); + expect(token).not.toBeNull(); + expect( + replaceAutocompleteToken("status = 1 AND ord", token!, "order"), + ).toBe("status = 1 AND order"); + }); + + test("filters options by case-insensitive prefix and caps results", () => { + const options: ColumnAutocompleteOption[] = [ + "order", + "order_id", + "owner", + "created_at", + "other_1", + "other_2", + "other_3", + "other_4", + "other_5", + "other_6", + ].map((name) => ({ name })); + + const token = getAutocompleteToken("O", 1); + expect( + getColumnAutocompleteOptions(options, token).map((o) => o.name), + ).toEqual([ + "order", + "order_id", + "owner", + "other_1", + "other_2", + "other_3", + "other_4", + "other_5", + ]); + }); +}); diff --git a/src/lockfile.unit.test.ts b/src/lockfile.unit.test.ts index 176829bd..30711d38 100644 --- a/src/lockfile.unit.test.ts +++ b/src/lockfile.unit.test.ts @@ -5,113 +5,26 @@ import { resolve } from "path"; // Load and parse the package-lock.json relative to the project root const lockfilePath = resolve(import.meta.dir, "../package-lock.json"); const lockfile = JSON.parse(readFileSync(lockfilePath, "utf-8")); - -describe("package-lock.json version constraints (PR #changes)", () => { - describe("root package version", () => { - test("top-level version is 0.2.6", () => { - expect(lockfile.version).toBe("0.2.6"); - }); - - test("packages[''] version matches top-level version", () => { - expect(lockfile.packages[""].version).toBe(lockfile.version); - }); - }); - - describe("@tauri-apps/plugin-process dependency constraint", () => { - test("root package declares constraint ^2.3.1", () => { - const deps = lockfile.packages[""].dependencies; - expect(deps["@tauri-apps/plugin-process"]).toBe("^2.3.1"); - }); - - test("resolved module version is 2.3.1", () => { - const resolved = - lockfile.packages["node_modules/@tauri-apps/plugin-process"]; - expect(resolved.version).toBe("2.3.1"); - }); - - test("constraint uses caret range (not tilde)", () => { - const constraint = - lockfile.packages[""].dependencies["@tauri-apps/plugin-process"]; - expect(constraint.startsWith("^")).toBe(true); - expect(constraint.startsWith("~")).toBe(false); - }); - }); - - describe("@tauri-apps/plugin-updater dependency constraint", () => { - test("root package declares constraint ^2.10.0", () => { - const deps = lockfile.packages[""].dependencies; - expect(deps["@tauri-apps/plugin-updater"]).toBe("^2.10.0"); - }); - - test("resolved module version is 2.10.0", () => { - const resolved = - lockfile.packages["node_modules/@tauri-apps/plugin-updater"]; - expect(resolved.version).toBe("2.10.0"); - }); - - test("constraint uses caret range (not tilde)", () => { - const constraint = - lockfile.packages[""].dependencies["@tauri-apps/plugin-updater"]; - expect(constraint.startsWith("^")).toBe(true); - expect(constraint.startsWith("~")).toBe(false); - }); +const packageJsonPath = resolve(import.meta.dir, "../package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + +describe("package-lock.json integrity", () => { + test("top-level metadata matches package.json", () => { + expect(lockfile.name).toBe(packageJson.name); + expect(lockfile.version).toBe(packageJson.version); + expect(lockfile.packages[""].name).toBe(packageJson.name); + expect(lockfile.packages[""].version).toBe(packageJson.version); }); - describe("cosmiconfig bundled yaml version", () => { - test("cosmiconfig/node_modules/yaml is pinned to 1.10.2", () => { - const yaml = - lockfile.packages["node_modules/cosmiconfig/node_modules/yaml"]; - expect(yaml).toBeDefined(); - expect(yaml.version).toBe("1.10.2"); - }); + test("root runtime dependencies match package.json", () => { + expect(lockfile.packages[""].dependencies).toEqual( + packageJson.dependencies, + ); }); - describe("picomatch version", () => { - test("picomatch resolved version is 4.0.3", () => { - const picomatch = lockfile.packages["node_modules/picomatch"]; - expect(picomatch).toBeDefined(); - expect(picomatch.version).toBe("4.0.3"); - }); - - test("picomatch entry does not have a resolved field", () => { - // The PR diff removed the 'resolved' field from picomatch - const picomatch = lockfile.packages["node_modules/picomatch"]; - expect(picomatch.resolved).toBeUndefined(); - }); - - test("picomatch entry does not have an integrity field", () => { - // The PR diff removed the 'integrity' field from picomatch - const picomatch = lockfile.packages["node_modules/picomatch"]; - expect(picomatch.integrity).toBeUndefined(); - }); - }); - - describe("lockfile structural integrity", () => { - test("lockfile version is 3", () => { - expect(lockfile.lockfileVersion).toBe(3); - }); - - test("requires is true", () => { - expect(lockfile.requires).toBe(true); - }); - - test("name is dbpaw", () => { - expect(lockfile.name).toBe("dbpaw"); - }); - - test("both tauri plugin constraints are more specific than their previous tilde ranges", () => { - // ~2 would allow 2.x.x; ^2.3.1 pins to >=2.3.1 <3.0.0 and is more specific - const deps = lockfile.packages[""].dependencies; - const processConstraint = deps["@tauri-apps/plugin-process"]; - const updaterConstraint = deps["@tauri-apps/plugin-updater"]; - - // Verify they are not loose tilde-2 constraints - expect(processConstraint).not.toBe("~2"); - expect(updaterConstraint).not.toBe("~2"); - - // Verify they contain explicit patch version info - expect(processConstraint).toMatch(/\^2\.\d+\.\d+/); - expect(updaterConstraint).toMatch(/\^2\.\d+\.\d+/); - }); + test("uses a modern npm lockfile structure", () => { + expect(lockfile.lockfileVersion).toBe(3); + expect(lockfile.requires).toBe(true); + expect(lockfile.packages[""]).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/src/types/bun-test.d.ts b/src/types/bun-test.d.ts index 857ef4d7..146be261 100644 --- a/src/types/bun-test.d.ts +++ b/src/types/bun-test.d.ts @@ -2,8 +2,14 @@ declare module "bun:test" { export const describe: (...args: any[]) => any; export const test: (...args: any[]) => any; export const it: typeof test; + export const beforeEach: (...args: any[]) => any; + export const afterEach: (...args: any[]) => any; export const expect: (...args: any[]) => any; export const mock: { module: (id: string, factory: () => any) => void; }; } + +interface ImportMeta { + dir: string; +} From b42512453d61c8e2eccea9b729c19535893534fd Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Tue, 7 Apr 2026 20:40:38 +0800 Subject: [PATCH 12/15] feat(ux): add loading indicators for async operations - SQL editor Run button shows spinner and disables during query execution, preventing duplicate submissions (derives state from existing activeQueryId) - Clicking a table in the sidebar now creates a placeholder tab immediately with a skeleton screen, replacing blank wait time with instant feedback - Sidebar database/table tree nodes show a spinner while lazily loading children (tables or columns), tracked per-node via Set state Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 60 +++++++++++++------ .../business/DataGrid/TableView.tsx | 15 +++++ src/components/business/Editor/SqlEditor.tsx | 10 +++- .../business/Sidebar/ConnectionList.tsx | 47 ++++++++++++++- 4 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d6a35ba3..a1b036a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,6 +100,7 @@ interface TabItem { savedQueryId?: number; savedQueryDescription?: string; availableDatabases?: string[]; + isLoading?: boolean; } type TableRefreshOverrides = { @@ -685,6 +686,23 @@ export default function App() { setActiveTab(tabId); return; } + + // Immediately create a placeholder tab and switch to it for instant feedback + setTabs((prev) => [ + ...prev, + { + id: tabId, + type: "table", + title: table, + connection, + database, + connectionId, + driver, + isLoading: true, + }, + ]); + setActiveTab(tabId); + try { const { schema, dbParam } = resolveTableScope( driver, @@ -719,28 +737,30 @@ export default function App() { columns = resp.data.length > 0 ? Object.keys(resp.data[0]) : []; } - const newTab: TabItem = { - id: tabId, - type: "table", - title: table, - connection, - database, - schema, - tableName: table, - data: resp.data, - columns, - total: resp.total, - page: resp.page, - pageSize: resp.limit, - executionTimeMs: resp.executionTimeMs, - connectionId, - driver, - }; - setTabs([...tabs, newTab]); - setActiveTab(tabId); + setTabs((prev) => + prev.map((t) => + t.id === tabId + ? { + ...t, + isLoading: false, + schema, + tableName: table, + data: resp.data, + columns, + total: resp.total, + page: resp.page, + pageSize: resp.limit, + executionTimeMs: resp.executionTimeMs, + } + : t, + ), + ); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("get_table_data failed", errorMessage); + setTabs((prev) => + prev.map((t) => (t.id === tabId ? { ...t, isLoading: false } : t)), + ); toast.error(t("app.error.loadTableData"), { description: errorMessage, }); @@ -1572,6 +1592,7 @@ export default function App() { ) : Promise.resolve(false) } + isExecuting={!!tab.activeQueryId} queryResults={tab.queryResults} value={tab.sqlContent} onChange={(sql) => handleSqlChange(tab.id, sql)} @@ -1610,6 +1631,7 @@ export default function App() { ) : tab.type === "table" ? ( + + + + + +
+ ); + } + return (
{!hideHeader && ( diff --git a/src/components/business/Editor/SqlEditor.tsx b/src/components/business/Editor/SqlEditor.tsx index b2a34fa6..d7c06a2b 100644 --- a/src/components/business/Editor/SqlEditor.tsx +++ b/src/components/business/Editor/SqlEditor.tsx @@ -35,6 +35,7 @@ import { Download, CheckCircle2, XCircle, + Loader2, } from "lucide-react"; import { TableView } from "@/components/business/DataGrid/TableView"; import { useTheme } from "@/components/theme-provider"; @@ -237,6 +238,7 @@ interface SqlEditorProps { initialName?: string; initialDescription?: string; onSaveSuccess?: (savedQuery: SavedQuery) => void; + isExecuting?: boolean; } export function SqlEditor({ @@ -255,6 +257,7 @@ export function SqlEditor({ initialName, initialDescription, onSaveSuccess, + isExecuting, }: SqlEditorProps) { const { t } = useTranslation(); const [internalSql, setInternalSql] = useState(""); @@ -755,8 +758,13 @@ export function SqlEditor({ size="icon" variant="outline" className="h-8 w-8" + disabled={isExecuting} > - + {isExecuting ? ( + + ) : ( + + )} diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index fbf469e1..1dbec929 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -281,6 +281,15 @@ export function ConnectionList({ const [editingConnectionId, setEditingConnectionId] = useState( null, ); + const [loadingDatabaseKeys, setLoadingDatabaseKeys] = useState>( + new Set(), + ); + const [loadingTableKeys, setLoadingTableKeys] = useState>( + new Set(), + ); + const loadingSpinner = ( + + ); const [isTesting, setIsTesting] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false); @@ -1053,7 +1062,14 @@ export function ConnectionList({ ? db.schemas.length === 0 : db.tables.length === 0) ) { - fetchAndSetTables(connId, dbName); + setLoadingDatabaseKeys((prev) => new Set(prev).add(key)); + fetchAndSetTables(connId, dbName).finally(() => { + setLoadingDatabaseKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }); } } } @@ -1172,12 +1188,19 @@ export function ConnectionList({ newExpanded.add(tableKey); // Load column info on first expand if (table.columns.length === 0) { + setLoadingTableKeys((prev) => new Set(prev).add(tableKey)); fetchAndSetTableColumns( connectionId, databaseName, table.schema, table.name, - ); + ).finally(() => { + setLoadingTableKeys((prev) => { + const next = new Set(prev); + next.delete(tableKey); + return next; + }); + }); } } setExpandedTables(newExpanded); @@ -2419,6 +2442,11 @@ export function ConnectionList({ table, ); }} + statusIndicator={ + loadingTableKeys.has(tableKey) + ? loadingSpinner + : undefined + } actions={
e.stopPropagation()}>