Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ And nothing is held back. Single sign-on, ER diagrams, the AI features and the N
- **Smart Autocomplete**: Schema-aware suggestions for tables, columns, and SQL keywords.
- **Command Palette**: Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`.
- **Multi-Tab Workspace**: Handle parallel tasks with independent execution states.
- **Saved Query Backups**: Export the complete saved-query library as JSON. Import validates the file, preserves query metadata and merges new entries, reporting duplicate IDs while keeping existing queries intact.
- **Duplicate Connections**: Open an independent `(copy)` of an editable saved connection in the connection editor, adjust its settings and save. Cancelling leaves the saved connections unchanged; administrator-managed connections cannot be duplicated.
- **Visual EXPLAIN**: Graphical execution plans to identify performance bottlenecks.
- **Interactive ER Diagrams**: Visual schema graph with real foreign key edges, cardinality labels, MiniMap navigation, table search/filter, compact mode, and PNG/SVG export. Automatic hierarchical layout powered by ELK.js.
Expand Down
64 changes: 62 additions & 2 deletions src/components/SavedQueries.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"use client";

import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { storage } from "@/lib/storage";
import { SavedQuery } from "@/lib/types";
import { Bookmark, Search, Trash2, PenLine, Tag, Calendar } from "lucide-react";
import { Bookmark, Search, Trash2, PenLine, Tag, Calendar, Download, Upload } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { format } from "date-fns";
import { downloadText } from "@/lib/export/download";
import { jsonText } from "@/lib/export/json";
import { parseSavedQueries } from "@/lib/saved-query-import";
import { toast } from "sonner";

interface SavedQueriesProps {
onSelectQuery: (query: string) => void;
Expand All @@ -17,6 +21,8 @@ interface SavedQueriesProps {
export function SavedQueries({ onSelectQuery, connectionType, refreshTrigger }: SavedQueriesProps) {
const [queries, setQueries] = useState<SavedQuery[]>(() => storage.getSavedQueries());
const [search, setSearch] = useState("");
const [isImporting, setIsImporting] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);

// `refreshTrigger` is bumped by whoever writes a saved query. Adjusting state during
// render is React's prescribed replacement for a setState-in-effect, and unlike a `key`
Expand All @@ -42,13 +48,67 @@ export function SavedQueries({ onSelectQuery, connectionType, refreshTrigger }:
}
};

const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setIsImporting(true);
try {
const incoming = parseSavedQueries(await file.text());
const result = storage.importSavedQueries(incoming);
setQueries(storage.getSavedQueries());
if (result.collisions.length > 0) {
toast("Import finished with ID conflicts", {
description: `Added ${result.imported}; skipped duplicate IDs: ${result.collisions.join(", ")}.`,
});
} else {
toast.success("Saved queries import finished", { description: `Added ${result.imported}.` });
}
} catch {
toast.error("Could not import saved queries. Check the JSON file and available browser storage.");
} finally {
setIsImporting(false);
}
};

return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 border-b border-hairline flex flex-col gap-4">
<h3 className="text-xs font-medium text-fg-tertiary flex items-center gap-2">
<Bookmark strokeWidth={1.5} className="w-3.5 h-3.5" /> Saved Queries
</h3>

<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-2"
aria-label="Export all saved queries as JSON"
disabled={queries.length === 0}
onClick={() => downloadText(jsonText(queries, 2), "application/json", `saved_queries_${Date.now()}.json`)}
>
<Download className="w-3 h-3" /> Export all
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-2"
disabled={isImporting}
onClick={() => fileInput.current?.click()}
>
<Upload className="w-3 h-3" /> Import JSON
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
aria-label="Import saved queries JSON"
className="hidden"
disabled={isImporting}
onChange={handleImport}
/>
</div>

<div className="relative">
<Search strokeWidth={1.5} className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted" />
<Input
Expand Down
23 changes: 23 additions & 0 deletions src/lib/saved-query-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from "zod";
import { SHIPPED_DATABASE_TYPES } from "@/lib/db/compatibility";
import type { SavedQuery } from "@/lib/types";

// This schema loads in the browser. Even Zod's caught eval-capability probe
// violates the app's CSP, so disable JIT before constructing the object schema.
z.config({ jitless: true });

const savedQuerySchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
query: z.string(),
description: z.string().optional(),
connectionType: z.enum(SHIPPED_DATABASE_TYPES),
createdAt: z.string().pipe(z.coerce.date()),
updatedAt: z.string().pipe(z.coerce.date()),
tags: z.array(z.string()).optional(),
});

/** Validate the complete backup before any query is persisted. */
export function parseSavedQueries(text: string): SavedQuery[] {
return z.array(savedQuerySchema).parse(JSON.parse(text));
}
21 changes: 21 additions & 0 deletions src/lib/storage/storage-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ export const storage = {
dispatchChange("saved_queries", filtered);
},

importSavedQueries: (incoming: readonly SavedQuery[]) => {
const existing = storage.getSavedQueries();
const ids = new Set(existing.map((query) => query.id));
const added: SavedQuery[] = [];
const collisions: string[] = [];
for (const query of incoming) {
if (ids.has(query.id)) {
collisions.push(query.id);
} else {
ids.add(query.id);
added.push(query);
}
}
if (added.length > 0) {
const merged = [...existing, ...added];
if (!writeJSON("saved_queries", merged)) throw new Error("Could not save imported queries.");
dispatchChange("saved_queries", merged);
}
return { imported: added.length, collisions };
},

// ═══════════════════════════════════════════════════════════════════════════
// Schema Snapshots
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
135 changes: 129 additions & 6 deletions tests/components/SavedQueries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,34 @@ import "../helpers/mock-navigation";

import React from "react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import type { SavedQuery } from "@/lib/types";
import { mockToastDefault, mockToastError, mockToastSuccess } from "../helpers/mock-sonner";

const mockSavedQueries = [
const mockSavedQueries: SavedQuery[] = [
{
id: "q1",
name: "Active Users",
description: "Get active users",
query: "SELECT * FROM users WHERE active = true",
connectionType: "postgres",
tags: ["report"],
createdAt: "2026-01-15T10:00:00Z",
updatedAt: "2026-01-15T10:00:00Z",
createdAt: new Date("2026-01-15T10:00:00Z"),
updatedAt: new Date("2026-01-15T10:00:00Z"),
},
];

const mockGetSavedQueries = mock(() => [...mockSavedQueries]);
const mockDeleteSavedQuery = mock(() => {});
const mockImportSavedQueries = mock((_queries: SavedQuery[]) => ({ imported: 1, collisions: [] as string[] }));
const mockDownloadText = mock((_text: string, _type: string, _name: string) => {});
mock.module("@/lib/export/download", () => ({ downloadText: mockDownloadText }));

mock.module("@/lib/storage", () => ({
storage: {
getSavedQueries: mockGetSavedQueries,
deleteSavedQuery: mockDeleteSavedQuery,
importSavedQueries: mockImportSavedQueries,
},
}));

Expand All @@ -44,11 +50,128 @@ describe("SavedQueries", () => {
});

beforeEach(() => {
mockImportSavedQueries.mockClear();
mockImportSavedQueries.mockImplementation(() => ({ imported: 1, collisions: [] }));
mockDownloadText.mockClear();
mockToastDefault.mockClear();
mockToastError.mockClear();
mockToastSuccess.mockClear();
mockGetSavedQueries.mockClear();
mockDeleteSavedQuery.mockClear();
mockGetSavedQueries.mockImplementation(() => [...mockSavedQueries]);
});

function importFile(input: HTMLElement, text: string) {
const file = new File([text], "saved_queries.json", { type: "application/json" });
Object.defineProperty(file, "text", { value: async () => text });
fireEvent.change(input, { target: { files: [file] } });
}

test("exports the complete library as JSON even while search and connection filters hide it", () => {
const library = [...mockSavedQueries, { ...mockSavedQueries[0], id: "mysql", connectionType: "mysql" as const }];
mockGetSavedQueries.mockReturnValue(library);
const onSelectQuery = mock(() => {});
const view = render(<SavedQueries onSelectQuery={onSelectQuery} connectionType="postgres" />);
fireEvent.change(view.getByPlaceholderText("Search saved queries..."), { target: { value: "missing" } });
fireEvent.click(view.getByRole("button", { name: "Export all saved queries as JSON" }));
expect(mockDownloadText).toHaveBeenCalledWith(
JSON.stringify(library, null, 2),
"application/json",
expect.stringMatching(/^saved_queries_\d+\.json$/),
);
expect(onSelectQuery).not.toHaveBeenCalled();
});

test("imports valid saved queries, reloads the library and retains search without executing", async () => {
const imported = { ...mockSavedQueries[0], id: "new", name: "Imported Query", query: "SELECT '你好';" };
const onSelectQuery = mock(() => {});
const view = render(<SavedQueries onSelectQuery={onSelectQuery} />);
const search = view.getByPlaceholderText("Search saved queries...") as HTMLInputElement;
fireEvent.change(search, { target: { value: "Imported" } });
mockGetSavedQueries.mockReturnValue([...mockSavedQueries, imported]);
const input = view.getByLabelText("Import saved queries JSON") as HTMLInputElement;
const setInputValue = mock((_value: string) => {});
Object.defineProperty(input, "value", { configurable: true, set: setInputValue });
importFile(input, JSON.stringify([imported]));
await waitFor(() => expect(mockImportSavedQueries).toHaveBeenCalledWith([imported]));
expect(view.getByRole("button", { name: "Imported Query" }) !== null).toBe(true);
expect(mockToastSuccess).toHaveBeenCalledWith("Saved queries import finished", { description: "Added 1." });
expect(search.value).toBe("Imported");
expect(setInputValue).toHaveBeenCalledWith("");
expect(onSelectQuery).not.toHaveBeenCalled();
});

test("reports colliding IDs instead of announcing an unconditional success", async () => {
mockImportSavedQueries.mockReturnValue({ imported: 1, collisions: ["q1", "duplicate"] });
const view = render(<SavedQueries onSelectQuery={mock(() => {})} />);
importFile(view.getByLabelText("Import saved queries JSON"), JSON.stringify(mockSavedQueries));
await waitFor(() =>
expect(mockToastDefault).toHaveBeenCalledWith("Import finished with ID conflicts", {
description: "Added 1; skipped duplicate IDs: q1, duplicate.",
}),
);
expect(mockToastSuccess).not.toHaveBeenCalled();
});

test.each(["{", JSON.stringify({ queries: [] }), JSON.stringify([{ ...mockSavedQueries[0], updatedAt: "invalid" }])])(
"rejects an invalid import without writing any queries",
async (text) => {
const view = render(<SavedQueries onSelectQuery={mock(() => {})} />);
importFile(view.getByLabelText("Import saved queries JSON"), text);
await waitFor(() => expect(mockToastError).toHaveBeenCalled());
expect(mockImportSavedQueries).not.toHaveBeenCalled();
expect((view.getByRole("button", { name: "Import JSON" }) as HTMLButtonElement).disabled).toBe(false);
},
);

test("opens the file picker, handles cancellation and disables export for an empty library", () => {
mockGetSavedQueries.mockReturnValue([]);
const view = render(<SavedQueries onSelectQuery={mock(() => {})} />);
const input = view.getByLabelText("Import saved queries JSON") as HTMLInputElement;
input.click = mock(() => {});
fireEvent.click(view.getByRole("button", { name: "Import JSON" }));
expect(input.click).toHaveBeenCalledTimes(1);
fireEvent.change(input, { target: { files: [] } });
expect(mockImportSavedQueries).not.toHaveBeenCalled();
expect(mockToastError).not.toHaveBeenCalled();
expect((view.getByRole("button", { name: "Export all saved queries as JSON" }) as HTMLButtonElement).disabled).toBe(
true,
);
});

test("keeps the import control disabled until the file read settles and reports read failures", async () => {
const view = render(<SavedQueries onSelectQuery={mock(() => {})} />);
let rejectRead!: (error: Error) => void;
const file = new File([], "queries.json");
Object.defineProperty(file, "text", {
value: () =>
new Promise<string>((_resolve, reject) => {
rejectRead = reject;
}),
});
fireEvent.change(view.getByLabelText("Import saved queries JSON"), { target: { files: [file] } });
const button = view.getByRole("button", { name: "Import JSON" }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
rejectRead(new Error("Unreadable file"));
await waitFor(() => expect(button.disabled).toBe(false));
expect(mockImportSavedQueries).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalledWith(
"Could not import saved queries. Check the JSON file and available browser storage.",
);
});

test("reports a storage failure without success or executing a query", async () => {
mockImportSavedQueries.mockImplementation(() => {
throw new Error("Storage full");
});
const onSelectQuery = mock(() => {});
const view = render(<SavedQueries onSelectQuery={onSelectQuery} />);
importFile(view.getByLabelText("Import saved queries JSON"), JSON.stringify(mockSavedQueries));
await waitFor(() => expect(mockToastError).toHaveBeenCalled());
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(onSelectQuery).not.toHaveBeenCalled();
});

test("renders saved query items", () => {
const { queryByText } = render(<SavedQueries onSelectQuery={mock(() => {})} />);
expect(queryByText("Active Users")).not.toBeNull();
Expand Down Expand Up @@ -141,8 +264,8 @@ describe("SavedQueries", () => {
query: "SELECT * FROM users WHERE active = false",
connectionType: "postgres",
tags: ["report"],
createdAt: "2026-01-16T10:00:00Z",
updatedAt: "2026-01-16T10:00:00Z",
createdAt: new Date("2026-01-16T10:00:00Z"),
updatedAt: new Date("2026-01-16T10:00:00Z"),
},
]);

Expand Down
Loading
Loading