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