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
@@ -64,4 +64,41 @@ describe("RestorePage DB-aware", () => {
expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
it("renders the MySQL restore form even when no MySQL client is installed", async () => {
// Restore is in-process since Task 7, so a missing mariadb/mysql client must
// no longer hide the form.
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
});
render(<RestorePage connectionId="c4" />);
// The tools check is async; wait until the configuration card is actually up.
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/no MySQL client installation required/i)).toBeTruthy();
});
it("explains what Clean does for MySQL", async () => {
mockConnections.push({ id: "c5", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c5" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/only the objects this file defines/i)).toBeTruthy();
});
it("warns that a failed MySQL restore cannot be rolled back", async () => {
mockConnections.push({ id: "c6", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c6" />);
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
expect(screen.getByText(/can't be rolled back/i)).toBeTruthy();
});
});
@@ -10,11 +10,10 @@ import {
detectPgTools,
pgRestore,
getSchemas,
detectMysqlTools,
mysqlRestore,
sqliteRestore,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
import type { PgToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
@@ -60,7 +59,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -93,7 +91,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
setCheckingTools(true);
setConfirmed(false);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
@@ -115,19 +112,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
// MySQL restore runs in-process over the sqlx driver connection, so
// there is no client to probe for or wait on here (Backup/Sync
// still probe via detectMysqlTools for mysqldump).
setCheckingTools(false);
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
@@ -214,16 +202,15 @@ export function RestorePage({ connectionId }: RestorePageProps) {
runWithProgress,
]);
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false;
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false;
: false;
const canStart = filePath && confirmed && !isRunning;
const checkingMessage = isPg
@@ -282,6 +269,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
{!checkingTools && !toolsMissing && (
<>
{isMysql && (
<p className="text-[11px] text-text-muted/70 -mb-2">
Restores run in-process over the driver connection — no MySQL client
installation required.
</p>
)}
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format (PostgreSQL only) */}
@@ -379,12 +373,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
{isMysql ? (
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
drops only the objects this file defines
</code>
</span>
) : (
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
)}
</label>
)}
{isPg && format === "plain" && (
@@ -414,6 +417,12 @@ export function RestorePage({ connectionId }: RestorePageProps) {
be undone.
</span>
</label>
{isMysql && (
<p className="mt-2 text-[11px] text-red-400/80">
MySQL DDL commits as it runs and can't be rolled back — a failed
restore may leave this database partially applied.
</p>
)}
</div>
{/* Progress */}
+1 -1
View File
@@ -251,7 +251,7 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
<button
type="button"
onClick={openQueryTab}
onClick={() => openQueryTab()}
aria-label="New query tab"
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
>
@@ -0,0 +1,24 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { PendingSqlFileBanner } from "./PendingSqlFileBanner";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => {
useUiStore.setState({ pendingSqlFile: null });
});
describe("PendingSqlFileBanner", () => {
it("renders nothing when there is no pending file", () => {
const { container } = render(<PendingSqlFileBanner />);
expect(container).toBeEmptyDOMElement();
});
it("names the file and tells the user to open a connection", () => {
useUiStore.setState({
pendingSqlFile: { path: "/tmp/a.sql", name: "student_db.sql", content: "SELECT 1;" },
});
render(<PendingSqlFileBanner />);
expect(screen.getByText(/student_db\.sql/)).toBeInTheDocument();
expect(screen.getByText(/open a connection/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,34 @@
import { FileCode2, X } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
/**
* Shown when a `.sql` file was opened from the OS but no database connection is
* active. The file is already loaded in memory; picking a connection in the
* Home screen delivers it (see `useSqlFileOpen`).
*/
export function PendingSqlFileBanner() {
const pending = useUiStore((s) => s.pendingSqlFile);
const clear = useUiStore((s) => s.clearPendingSqlFile);
if (!pending) return null;
return (
<div
role="status"
className="flex items-center gap-2 border-b border-border bg-surface-raised px-3 py-1.5 text-xs text-text-muted"
>
<FileCode2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<span className="font-medium text-text">{pending.name}</span> is ready — open a connection
to use it.
</span>
<button
type="button"
onClick={clear}
aria-label="Dismiss pending SQL file"
className="ml-auto flex items-center rounded px-1 py-0.5 hover:bg-surface hover:text-text transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}