feat(mysql): native SQL execution — fix #41 type decoding, in-process restore, .sql open-with (#42)

Fixes #41.

MySQL columns now decode by their declared type instead of a blind try_get chain,
so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP)
renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver
(no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle
is implemented. .sql files can be opened with Gridline from the OS file manager.

Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests
exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
This commit is contained in:
2026-09-29 08:52:47 +08:00
committed by GitHub
parent ce4a73b0bb
commit 88e7e45818
36 changed files with 2265 additions and 141 deletions
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
const listeners: Record<string, (e: unknown) => void> = {};
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: unknown) => void) => {
listeners[name] = cb;
return () => {};
}),
}));
const takePendingSqlFiles = vi.hoisted(() => vi.fn());
vi.mock("../lib/commands", () => ({
takePendingSqlFiles: () => takePendingSqlFiles(),
}));
import { useSqlFileOpen } from "./useSqlFileOpen";
import { useUiStore } from "../stores/uiStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
const file = { path: "/tmp/student_db.sql", name: "student_db.sql", content: "SELECT 1;" };
beforeEach(() => {
takePendingSqlFiles.mockReset();
useUiStore.setState({ activeConnectionId: null, pendingSqlFile: null, activeView: "home" });
useDbViewerStore.setState({ tabs: [], activeTabId: null });
});
describe("useSqlFileOpen", () => {
it("opens a tab when a connection is active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => {
const tab = useDbViewerStore.getState().tabs.slice(-1)[0];
expect(tab?.query).toBe("SELECT 1;");
});
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.table).toBe("student_db.sql");
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
it("stashes the file and prompts when no connection is active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
renderHook(() => useSqlFileOpen());
await waitFor(() => {
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
});
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
expect(useUiStore.getState().activeView).toBe("home");
});
it("drains the buffer again when the nudge event fires", async () => {
takePendingSqlFiles.mockResolvedValue([]);
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => expect(takePendingSqlFiles).toHaveBeenCalledTimes(1));
takePendingSqlFiles.mockResolvedValue([file]);
listeners["sql-file-opened"]?.({});
await waitFor(() => {
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
});
});
it("opens the stashed file once a connection becomes active", async () => {
takePendingSqlFiles.mockResolvedValue([file]);
renderHook(() => useSqlFileOpen());
await waitFor(() => expect(useUiStore.getState().pendingSqlFile).toEqual(file));
useUiStore.setState({ activeConnectionId: "conn-2" });
await waitFor(() => {
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
});
expect(useUiStore.getState().pendingSqlFile).toBeNull();
});
it("surfaces a read failure as a notification instead of throwing", async () => {
takePendingSqlFiles.mockRejectedValue(new Error("a.sql is not valid UTF-8"));
useUiStore.setState({ activeConnectionId: "conn-1" });
renderHook(() => useSqlFileOpen());
await waitFor(() => {
expect(useNotificationStore.getState().notifications.slice(-1)[0]?.message).toContain("UTF-8");
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { takePendingSqlFiles } from "../lib/commands";
import { useUiStore } from "../stores/uiStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
import type { PendingSqlFile } from "../lib/types";
/**
* Turn OS "open this .sql file with Gridline" requests into editor tabs.
*
* Every platform path (macOS RunEvent::Opened, Windows/Linux argv, the
* single-instance forward) ends up in a Rust buffer; this hook only ever
* *drains* that buffer. That is deliberate: draining is idempotent, so a cold
* start, a warm start, and a webview reload all converge on the same code path
* and there is no cold-start race to handle.
*
* Opening never executes: the script lands in a query tab and runs only when
* the user presses Run, which passes through the destructive-query guard.
*/
export function useSqlFileOpen(): void {
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | undefined;
const deliver = async () => {
let files: PendingSqlFile[];
try {
files = await takePendingSqlFiles();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useNotificationStore.getState().notify(`Could not open SQL file: ${msg}`, "error");
return;
}
if (disposed) return;
for (const file of files) openOrStash(file);
};
void deliver();
void listen("sql-file-opened", () => {
void deliver();
})
.then((un) => {
unlisten = un;
})
.catch(() => {
// No Tauri event bridge (browser / tests): `listen` rejects there. The
// mount-time drain already ran, and the Rust buffer survives until a
// real webview asks for it, so there is nothing to recover here. The
// drain is idempotent, so a missed nudge is not a correctness problem —
// a warm webview still drains on mount and on the next event.
});
return () => {
disposed = true;
unlisten?.();
};
}, []);
// A stashed file has no target until the user picks a connection, so
// deliver it as soon as one becomes active.
const pending = useUiStore((s) => s.pendingSqlFile);
const activeConnectionId = useUiStore((s) => s.activeConnectionId);
useEffect(() => {
if (pending && activeConnectionId) {
openQueryTabFor(pending);
useUiStore.getState().clearPendingSqlFile();
}
}, [pending, activeConnectionId]);
}
function openOrStash(file: PendingSqlFile): void {
const { activeConnectionId } = useUiStore.getState();
if (!activeConnectionId) {
// Guide rather than error: a Finder double-click should not dead-end.
useUiStore.getState().setPendingSqlFile(file);
useUiStore.getState().setActiveView("home");
useNotificationStore
.getState()
.notify(`${file.name} is ready — open a connection to use it.`, "info");
return;
}
openQueryTabFor(file);
}
function openQueryTabFor(file: PendingSqlFile): void {
useDbViewerStore.getState().openQueryTab({
sql: file.content,
label: file.name,
filePath: file.path,
});
useUiStore.getState().setActiveView("db-viewer");
}