From 942c6dc117c34f44bfef8aa4de595f8733d76312 Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:50:18 +0800 Subject: [PATCH 1/3] feat(saved-queries): import and export JSON backups --- README.md | 1 + src/components/SavedQueries.tsx | 64 ++++++++++- src/lib/saved-query-import.ts | 19 ++++ src/lib/storage/storage-facade.ts | 21 ++++ tests/components/SavedQueries.test.tsx | 133 +++++++++++++++++++++- tests/unit/lib/saved-query-import.test.ts | 44 +++++++ tests/unit/lib/storage.test.ts | 54 +++++++++ 7 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 src/lib/saved-query-import.ts create mode 100644 tests/unit/lib/saved-query-import.test.ts diff --git a/README.md b/README.md index 1e2740e90..8f098b257 100644 --- a/README.md +++ b/README.md @@ -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. - **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. - **Schema Diff & Migration**: Compare schema snapshots or cross-connection schemas side-by-side. Color-coded diff view (added/removed/modified) with automatic migration SQL generation for PostgreSQL, MySQL, SQLite, Oracle, and SQL Server, plus ClickHouse column modifications. diff --git a/src/components/SavedQueries.tsx b/src/components/SavedQueries.tsx index c50845def..cb271e75b 100644 --- a/src/components/SavedQueries.tsx +++ b/src/components/SavedQueries.tsx @@ -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; @@ -17,6 +21,8 @@ interface SavedQueriesProps { export function SavedQueries({ onSelectQuery, connectionType, refreshTrigger }: SavedQueriesProps) { const [queries, setQueries] = useState(() => storage.getSavedQueries()); const [search, setSearch] = useState(""); + const [isImporting, setIsImporting] = useState(false); + const fileInput = useRef(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` @@ -42,6 +48,29 @@ export function SavedQueries({ onSelectQuery, connectionType, refreshTrigger }: } }; + const handleImport = async (event: React.ChangeEvent) => { + 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 (
@@ -49,6 +78,37 @@ export function SavedQueries({ onSelectQuery, connectionType, refreshTrigger }: Saved Queries +
+ + + +
+
{ + 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 // ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/components/SavedQueries.test.tsx b/tests/components/SavedQueries.test.tsx index 0eb7ae5b2..d03efa089 100644 --- a/tests/components/SavedQueries.test.tsx +++ b/tests/components/SavedQueries.test.tsx @@ -4,9 +4,11 @@ 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", @@ -14,18 +16,22 @@ const mockSavedQueries = [ 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, }, })); @@ -44,11 +50,126 @@ 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(); + 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(); + 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; + 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(input.value).toBe(""); + 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( {})} />); + 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( {})} />); + 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( {})} />); + 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( {})} />); + let rejectRead!: (error: Error) => void; + const file = new File([], "queries.json"); + Object.defineProperty(file, "text", { + value: () => + new Promise((_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(); + 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( {})} />); expect(queryByText("Active Users")).not.toBeNull(); @@ -141,8 +262,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"), }, ]); diff --git a/tests/unit/lib/saved-query-import.test.ts b/tests/unit/lib/saved-query-import.test.ts new file mode 100644 index 000000000..5f232004b --- /dev/null +++ b/tests/unit/lib/saved-query-import.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { parseSavedQueries } from "@/lib/saved-query-import"; +import type { SavedQuery } from "@/lib/types"; + +const query: SavedQuery = { + id: "saved-1", + name: "Team report", + query: "SELECT '你好';\n-- report", + description: "A saved report", + connectionType: "postgres", + tags: ["monthly", "shared"], + createdAt: new Date("2025-04-03T01:02:03Z"), + updatedAt: new Date("2026-04-03T01:02:03Z"), +}; + +describe("parseSavedQueries", () => { + test("round-trips every saved field and revives both timestamps", () => { + expect(parseSavedQueries(JSON.stringify([query], null, 2))).toEqual([query]); + expect(parseSavedQueries("[]")).toEqual([]); + }); + + test.each([ + "{", + JSON.stringify({ queries: [] }), + JSON.stringify([null]), + JSON.stringify([{ ...query, id: "" }]), + JSON.stringify([{ ...query, name: "" }]), + JSON.stringify([{ ...query, query: 1 }]), + JSON.stringify([{ ...query, tags: [1] }]), + JSON.stringify([{ ...query, description: null }]), + JSON.stringify([{ ...query, connectionType: "unknown" }]), + JSON.stringify([{ ...query, createdAt: null }]), + JSON.stringify([query, { ...query, id: "bad", updatedAt: "not a date" }]), + ])("rejects the entire invalid backup %s", (text) => { + expect(() => parseSavedQueries(text)).toThrow(); + }); + + test("accepts missing optional fields and strips unrecognized fields", () => { + const { description: _description, tags: _tags, ...minimal } = query; + expect(parseSavedQueries(JSON.stringify([{ ...minimal, connectionType: "mysql", extra: "ignored" }]))).toEqual([ + { ...minimal, connectionType: "mysql" }, + ]); + }); +}); diff --git a/tests/unit/lib/storage.test.ts b/tests/unit/lib/storage.test.ts index 8e361e4f9..f038cf0e7 100644 --- a/tests/unit/lib/storage.test.ts +++ b/tests/unit/lib/storage.test.ts @@ -174,6 +174,60 @@ describe("storage: saved queries", () => { expect(result.length).toBe(1); expect(result[0].id).toBe("b"); }); + + test("importSavedQueries preserves metadata and reports existing and within-file ID collisions", () => { + storage.saveQuery(makeSavedQuery({ id: "existing" })); + const original = storage.getSavedQueries()[0]; + const imported = makeSavedQuery({ + id: "new", + createdAt: new Date("2020-01-01"), + updatedAt: new Date("2021-01-01"), + tags: ["backup"], + }); + const input = [ + makeSavedQuery({ id: "existing", query: "replacement" }), + imported, + { ...imported, query: "duplicate" }, + ]; + expect(storage.importSavedQueries(input)).toEqual({ imported: 1, collisions: ["existing", "new"] }); + expect(storage.getSavedQueries()).toEqual([original, imported]); + expect(input[1]).toEqual(imported); + }); + + test("importSavedQueries writes and emits one complete merged collection", () => { + const events: CustomEvent[] = []; + const listener = (event: Event) => events.push(event as CustomEvent); + window.addEventListener("libredb-storage-change", listener); + const incoming = [makeSavedQuery({ id: "a" }), makeSavedQuery({ id: "b" })]; + try { + expect(storage.importSavedQueries(incoming)).toEqual({ imported: 2, collisions: [] }); + expect(events).toHaveLength(1); + expect(events[0].detail).toEqual({ collection: "saved_queries", data: incoming }); + expect(storage.getSavedQueries()).toEqual(incoming); + expect(storage.importSavedQueries([])).toEqual({ imported: 0, collisions: [] }); + expect(storage.importSavedQueries(incoming)).toEqual({ imported: 0, collisions: ["a", "b"] }); + expect(events).toHaveLength(1); + } finally { + window.removeEventListener("libredb-storage-change", listener); + } + }); + + test("importSavedQueries leaves the old library intact when storage rejects the write", () => { + storage.saveQuery(makeSavedQuery({ id: "existing" })); + const original = localStorage.getItem("libredb_saved_queries"); + const setItem = localStorage.setItem; + localStorage.setItem = () => { + throw new Error("Storage full"); + }; + try { + expect(() => storage.importSavedQueries([makeSavedQuery({ id: "new" })])).toThrow( + "Could not save imported queries.", + ); + expect(localStorage.getItem("libredb_saved_queries")).toBe(original); + } finally { + localStorage.setItem = setItem; + } + }); }); // ============================================================================ From 8733479931105bc766504f1031a34ee4e151635b Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:11:28 +0800 Subject: [PATCH 2/3] fix(saved-queries): avoid dynamic evaluation in browser validation --- src/lib/saved-query-import.ts | 4 ++++ tests/unit/lib/saved-query-import.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/lib/saved-query-import.ts b/src/lib/saved-query-import.ts index a629f3947..c0c03d29b 100644 --- a/src/lib/saved-query-import.ts +++ b/src/lib/saved-query-import.ts @@ -2,6 +2,10 @@ 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), diff --git a/tests/unit/lib/saved-query-import.test.ts b/tests/unit/lib/saved-query-import.test.ts index 5f232004b..d0df47b06 100644 --- a/tests/unit/lib/saved-query-import.test.ts +++ b/tests/unit/lib/saved-query-import.test.ts @@ -14,6 +14,28 @@ const query: SavedQuery = { }; describe("parseSavedQueries", () => { + test("loading and using the browser importer never probes dynamic code evaluation", () => { + // A CSP violation is reported even when a library catches the EvalError. + // A fresh process also observes the schema's import-time capability probe. + const script = ` + let evaluations = 0; + globalThis.Function = new Proxy(Function, { + construct() { evaluations++; throw new EvalError("CSP blocks eval"); }, + }); + const { parseSavedQueries } = await import(${JSON.stringify(import.meta.dir + "/../../../src/lib/saved-query-import.ts")}); + const rows = parseSavedQueries(${JSON.stringify(JSON.stringify([query]))}); + process.stdout.write(JSON.stringify({ evaluations, name: rows[0].name, date: rows[0].createdAt instanceof Date })); + `; + const processResult = Bun.spawnSync([process.execPath, "-e", script], { stdout: "pipe", stderr: "pipe" }); + expect(new TextDecoder().decode(processResult.stderr)).toBe(""); + expect(processResult.exitCode).toBe(0); + expect(JSON.parse(new TextDecoder().decode(processResult.stdout))).toEqual({ + evaluations: 0, + name: query.name, + date: true, + }); + }); + test("round-trips every saved field and revives both timestamps", () => { expect(parseSavedQueries(JSON.stringify([query], null, 2))).toEqual([query]); expect(parseSavedQueries("[]")).toEqual([]); From 96b264b11714684d6a74dcdf453bee3c5e510656 Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:48:39 +0800 Subject: [PATCH 3/3] test(saved-queries): observe file input reset assignment --- tests/components/SavedQueries.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/SavedQueries.test.tsx b/tests/components/SavedQueries.test.tsx index d03efa089..3f252790d 100644 --- a/tests/components/SavedQueries.test.tsx +++ b/tests/components/SavedQueries.test.tsx @@ -90,12 +90,14 @@ describe("SavedQueries", () => { 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(input.value).toBe(""); + expect(setInputValue).toHaveBeenCalledWith(""); expect(onSelectQuery).not.toHaveBeenCalled(); });