From 958eba73bec098b8a75cfacc8acb5d0077177ce5 Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 19 Jul 2026 12:51:04 -0500 Subject: [PATCH 1/3] chore: format cargo-audit-fix-plan.md (dprint drift) --- .opencode/ops/cargo-audit-fix-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.opencode/ops/cargo-audit-fix-plan.md b/.opencode/ops/cargo-audit-fix-plan.md index 00ab907..b68c39e 100644 --- a/.opencode/ops/cargo-audit-fix-plan.md +++ b/.opencode/ops/cargo-audit-fix-plan.md @@ -30,6 +30,7 @@ Each phase lives on a sub-branch off `fix/204-cargo-audit-advisories` to isolate ## Test Plan After each phase: + - `cargo build --workspace` - `cargo test --workspace` - `cargo clippy --workspace -- -D warnings` From dd71e40e93016de88da9d0000677ab74be9b2f1c Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 19 Jul 2026 12:51:06 -0500 Subject: [PATCH 2/3] feat: remove visual query builder Drop the QueryBuilder feature: drag-table visual SQL builder with join diagram. Frontend deletes + 16 file edits remove the toolbar/titlebar/ menu entries, tab type union, store action, panel routing, and all related tests. Native macOS menu item also removed from src-tauri. No new dependencies were added by this feature - all consumers were local. No changes to package.json, Cargo.toml, tauri.conf.json, or capabilities. --- README.md | 3 +- src-tauri/src/menu.rs | 8 - src/components/layout/AppLayout.tsx | 10 +- src/components/layout/MainPanel.tsx | 9 - src/components/layout/MenuBar.tsx | 2 - src/components/layout/TitleBar.tsx | 16 - src/components/layout/Toolbar.tsx | 19 - .../__tests__/AppLayout.browser.test.tsx | 38 - .../layout/__tests__/AppLayout.test.tsx | 2 - .../__tests__/MainPanel.browser.test.tsx | 20 - .../layout/__tests__/MainPanel.test.tsx | 4 - .../__tests__/TitleBar.browser.test.tsx | 17 - .../layout/__tests__/TitleBar.test.tsx | 12 +- .../layout/__tests__/Toolbar.test.tsx | 14 - src/components/querybuilder/QueryBuilder.tsx | 1076 ------------ .../__tests__/QueryBuilder.browser.test.tsx | 1492 ----------------- .../__tests__/QueryBuilder.test.tsx | 264 --- .../__tests__/query-builder-engine.test.ts | 464 ----- src/lib/query-builder-engine.ts | 234 --- src/stores/__tests__/editorStore.test.ts | 10 - src/stores/editorStore.ts | 31 - src/types/index.ts | 2 +- 22 files changed, 5 insertions(+), 3742 deletions(-) delete mode 100644 src/components/querybuilder/QueryBuilder.tsx delete mode 100644 src/components/querybuilder/__tests__/QueryBuilder.browser.test.tsx delete mode 100644 src/components/querybuilder/__tests__/QueryBuilder.test.tsx delete mode 100644 src/lib/__tests__/query-builder-engine.test.ts delete mode 100644 src/lib/query-builder-engine.ts diff --git a/README.md b/README.md index 776d0f0..0e49d05 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ RUST_LOG=debug | Technology | Purpose | | ---------------------------------------------- | ------------------------------------------------------ | -| [Vitest](https://vitest.dev/) | Frontend unit tests (1488 tests) | +| [Vitest](https://vitest.dev/) | Frontend unit tests (1488 tests) | | [cargo test](https://doc.rust-lang.org/cargo/) | Rust integration tests against Docker MySQL (45 tests) | | [Docker](https://www.docker.com/) | MySQL 8, MySQL 5.7, MariaDB 11 test containers | @@ -316,7 +316,6 @@ sqlpilot/ │ │ ├── history/ # QueryHistory │ │ ├── import/ # ImportDialog (CSV + SQL modes) │ │ ├── layout/ # AppLayout, Sidebar, MainPanel, Toolbar, StatusBar -│ │ ├── querybuilder/ # QueryBuilder (visual SQL builder) │ │ ├── routine/ # RoutineViewer (procedure/function executor) │ │ └── schema/ # TableStructure (columns, indexes, DDL) │ ├── hooks/ # 7 custom hooks (context menu, keyboard, theme, schema cache, grid editing, query execution, click handler) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 2d1b996..6d0f9f8 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -72,14 +72,6 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { true, Some("F5"), )?) - .separator() - .item(&MenuItem::with_id( - app, - "query-builder", - "Visual Query Builder", - true, - None::<&str>, - )?) .item(&MenuItem::with_id( app, "compare-schemas", diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 873282e..7c1554c 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -86,9 +86,8 @@ export function AppLayout() { // inline custom menu (Windows/Linux via DOM CustomEvent) useEffect(() => { const handleAction = (action: string) => { - const { selectedConnectionId, activeConnections, disconnect } = useConnectionStore.getState(); - const { addTab, addAdminTab, addCompareTab, addQueryBuilderTab, editorInstance } = useEditorStore.getState(); - const selectedConn = activeConnections.find((c) => c.id === selectedConnectionId); + const { selectedConnectionId, disconnect } = useConnectionStore.getState(); + const { addTab, addAdminTab, addCompareTab, editorInstance } = useEditorStore.getState(); switch (action) { case "new-query": @@ -142,11 +141,6 @@ export function AppLayout() { case "refresh-schema": useSchemaCache.getState().refreshSchema(); break; - case "query-builder": - if (selectedConnectionId && selectedConn?.database) { - addQueryBuilderTab(selectedConnectionId, selectedConn.database); - } - break; case "compare-schemas": addCompareTab(); break; diff --git a/src/components/layout/MainPanel.tsx b/src/components/layout/MainPanel.tsx index 88179b4..54bbd9b 100644 --- a/src/components/layout/MainPanel.tsx +++ b/src/components/layout/MainPanel.tsx @@ -10,7 +10,6 @@ import { QueryToolbar } from "../editor/QueryToolbar"; import { SQLEditor } from "../editor/SQLEditor"; import { ExplainPanel } from "../explain/ExplainPanel"; import { ResultsGrid } from "../grid/ResultsGrid"; -import { QueryBuilder } from "../querybuilder/QueryBuilder"; import { RoutineViewer } from "../routine/RoutineViewer"; export function MainPanel() { @@ -25,7 +24,6 @@ export function MainPanel() { const isCompare = activeTab?.type === "compare"; const isRoutine = activeTab?.type === "routine"; const isDesigner = activeTab?.type === "designer"; - const isQueryBuilder = activeTab?.type === "querybuilder"; return (
@@ -42,13 +40,6 @@ export function MainPanel() { tableName={activeTab.tableName} /> ) - : isQueryBuilder && activeTab?.connectionId && activeTab?.database - ? ( - - ) : isRoutine && activeTab?.connectionId && activeTab?.database && activeTab?.routineName && activeTab?.routineType ? ( diff --git a/src/components/layout/MenuBar.tsx b/src/components/layout/MenuBar.tsx index 25b569d..9eaa0a7 100644 --- a/src/components/layout/MenuBar.tsx +++ b/src/components/layout/MenuBar.tsx @@ -47,8 +47,6 @@ const MENUS: MenuDef[] = [ label: "Database", items: [ { type: "item", id: "refresh-schema", label: "Refresh Schema", shortcut: "Ctrl+Shift+R" }, - { type: "separator" }, - { type: "item", id: "query-builder", label: "Visual Query Builder" }, { type: "item", id: "compare-schemas", label: "Compare Schemas" }, { type: "separator" }, { type: "item", id: "admin-tools", label: "Admin Tools" }, diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 36ea78f..adef638 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -5,7 +5,6 @@ import { ArrowLeftRight, HardDriveDownload, HardDriveUpload, - LayoutGrid, Minus, Monitor, Moon, @@ -56,7 +55,6 @@ export function TitleBar( const lastClickTime = useRef(0); const selectedConnectionId = useConnectionStore((s) => s.selectedConnectionId); - const activeConnections = useConnectionStore((s) => s.activeConnections); const theme = useThemeStore((s) => s.theme); const setTheme = useThemeStore((s) => s.setTheme); const ThemeIcon = themeIcons[theme]; @@ -66,11 +64,6 @@ export function TitleBar( useEditorStore.getState().addAdminTab(selectedConnectionId); }; const handleOpenCompare = () => useEditorStore.getState().addCompareTab(); - const handleOpenQueryBuilder = () => { - if (!selectedConnectionId) return; - const conn = activeConnections.find((c) => c.id === selectedConnectionId); - if (conn?.database) useEditorStore.getState().addQueryBuilderTab(selectedConnectionId, conn.database); - }; const cycleTheme = () => { const idx = themeOrder.indexOf(theme); setTheme(themeOrder[(idx + 1) % themeOrder.length]); @@ -169,15 +162,6 @@ export function TitleBar( {/* Toolbar buttons */}
e.stopPropagation()}> - - ))} - {filteredTables.length === 0 && ( -

- {tableNames.length === 0 - ? "Loading tables..." - : "No tables found"} -

- )} -
-
- - {/* Center — canvas + bottom panel */} -
- {/* Canvas */} -
- {canvasTables.length === 0 && ( -
-

- Click a table from the left panel to add it to the canvas -

-
- )} - - {/* SVG overlay for JOIN lines */} - - - - - - - {joinLines.map( - (line) => - line && ( - - - {/* Clickable invisible wider path */} - removeJoin(line.join.id)} - /> - {/* Join type badge */} - - - - - ), - )} - - - {/* Table cards */} - {canvasTables.map((table) => ( - handleTableMouseDown(e, table.id)} - onRemove={() => removeTable(table.id)} - onToggleColumn={(col) => toggleColumn(table.id, col)} - onColumnClick={(col) => handleColumnClick(table.id, col)} - onSetAggregate={(col, agg) => setAggregate(table.id, col, agg)} - /> - ))} -
- - {/* Bottom panel */} -
- {/* Section tabs */} -
- {( - [ - ["sql", "SQL Preview"], - ["where", "WHERE"], - ["orderby", "ORDER BY"], - ["groupby", "GROUP BY"], - ["having", "HAVING"], - ] as const - ).map(([key, label]) => ( - - ))} -
- {/* LIMIT input */} -
- - LIMIT - - { - const v = e.target.value; - setLimit(v === "" ? null : parseInt(v, 10)); - }} - placeholder="—" - className="w-16 rounded bg-[var(--color-bg-primary)] px-1.5 py-0.5 text-xs text-[var(--color-text-primary)] outline-none ring-1 ring-[var(--color-border)] focus:ring-brand-500" - /> -
- - -
- - {/* Section content */} -
- {activeSection === "sql" && ( - - )} - {activeSection === "where" && ( - - )} - {activeSection === "orderby" && ( - - )} - {activeSection === "groupby" && ( - - )} - {activeSection === "having" && ( - - )} -
-
-
-
- - ); -} - -/* ─── Table Card ────────────────────────────────────────────── */ - -interface TableCardProps { - table: CanvasTable; - pendingJoin: { tableId: string; column: string } | null; - onMouseDown: (e: React.MouseEvent) => void; - onRemove: () => void; - onToggleColumn: (column: string) => void; - onColumnClick: (column: string) => void; - onSetAggregate: (column: string, agg: AggregateFunction | null) => void; -} - -function TableCard({ - table, - pendingJoin, - onMouseDown, - onRemove, - onToggleColumn, - onColumnClick, - onSetAggregate, -}: TableCardProps) { - const isPendingSource = pendingJoin !== null && pendingJoin.tableId === table.id; - const isPendingTarget = pendingJoin !== null && pendingJoin.tableId !== table.id; - - return ( -
- {/* Header */} -
- - {table.alias !== table.tableName - ? `${table.tableName} (${table.alias})` - : table.tableName} - - -
- - {/* Columns */} -
- {table.columns.map((col) => { - const isSelected = table.selectedColumns.includes(col.name); - const isPendingCol = isPendingSource && pendingJoin?.column === col.name; - const aggregate = table.aggregates[col.name]; - - return ( -
- onToggleColumn(col.name)} - className="h-3 w-3 shrink-0 accent-brand-500" - /> - - {isSelected && ( - - )} -
- ); - })} -
-
- ); -} - -/* ─── SQL Preview Section ───────────────────────────────────── */ - -function SqlPreviewSection({ - sql, - results, - error, -}: { - sql: string; - results: QueryResult[] | null; - error: string | null; -}) { - return ( -
-
-        {sql || "-- Build your query by adding tables and selecting columns"}
-      
- {error && ( -
- {error} -
- )} - {results && results.length > 0 && ( -
- - - - {results[0].columns.map((col) => ( - - ))} - - - - {results[0].rows.slice(0, 50).map((row, i) => ( - - {row.map((cell, j) => ( - - ))} - - ))} - -
- {col.name} -
- {cell === null - ? ( - - NULL - - ) - : ( - String(cell) - )} -
- {results[0].rows.length > 50 && ( -

- Showing 50 of {results[0].rows.length} rows -

- )} -
- )} -
- ); -} - -/* ─── WHERE / HAVING Section ────────────────────────────────── */ - -function WhereSection({ - conditions, - columnRefs, - onChange, - isHaving, -}: { - conditions: WhereCondition[]; - columnRefs: { ref: string; label: string }[]; - onChange: (conditions: WhereCondition[]) => void; - isHaving?: boolean; -}) { - const addCondition = () => { - onChange([ - ...conditions, - { - id: uid(), - column: columnRefs[0]?.ref ?? "", - operator: "=", - value: "", - logic: "AND", - }, - ]); - }; - - const updateCondition = (id: string, updates: Partial) => { - onChange( - conditions.map((c) => (c.id === id ? { ...c, ...updates } : c)), - ); - }; - - const removeCondition = (id: string) => { - onChange(conditions.filter((c) => c.id !== id)); - }; - - const needsValue = (op: WhereOperator) => op !== "IS NULL" && op !== "IS NOT NULL"; - - return ( -
-
- - {isHaving ? "HAVING" : "WHERE"} Conditions - - -
- {conditions.map((cond, idx) => ( -
- {idx > 0 - ? ( - - ) - : } - - - {needsValue(cond.operator) && ( - updateCondition(cond.id, { value: e.target.value })} - placeholder="value" - className="h-6 flex-1 rounded bg-[var(--color-bg-primary)] px-1.5 text-xs text-[var(--color-text-primary)] outline-none ring-1 ring-[var(--color-border)] focus:ring-brand-500" - /> - )} - -
- ))} - {conditions.length === 0 && ( -

- No conditions. Click "Add" to create one. -

- )} -
- ); -} - -/* ─── ORDER BY Section ──────────────────────────────────────── */ - -function OrderBySection({ - clauses, - columnRefs, - onChange, -}: { - clauses: OrderByClause[]; - columnRefs: { ref: string; label: string }[]; - onChange: (clauses: OrderByClause[]) => void; -}) { - const addClause = () => { - onChange([ - ...clauses, - { id: uid(), column: columnRefs[0]?.ref ?? "", direction: "ASC" }, - ]); - }; - - const updateClause = (id: string, updates: Partial) => { - onChange(clauses.map((c) => (c.id === id ? { ...c, ...updates } : c))); - }; - - const removeClause = (id: string) => { - onChange(clauses.filter((c) => c.id !== id)); - }; - - const moveClause = (idx: number, dir: -1 | 1) => { - const newClauses = [...clauses]; - const target = idx + dir; - if (target < 0 || target >= newClauses.length) return; - [newClauses[idx], newClauses[target]] = [ - newClauses[target], - newClauses[idx], - ]; - onChange(newClauses); - }; - - return ( -
-
- - ORDER BY - - -
- {clauses.map((clause, idx) => ( -
- - - - - -
- ))} - {clauses.length === 0 && ( -

- No ordering. Click "Add" to create one. -

- )} -
- ); -} - -/* ─── GROUP BY Section ──────────────────────────────────────── */ - -function GroupBySection({ - columns, - columnRefs, - onChange, -}: { - columns: string[]; - columnRefs: { ref: string; label: string }[]; - onChange: (columns: string[]) => void; -}) { - const addColumn = () => { - const first = columnRefs[0]?.ref; - if (first) onChange([...columns, first]); - }; - - const updateColumn = (idx: number, value: string) => { - const newCols = [...columns]; - newCols[idx] = value; - onChange(newCols); - }; - - const removeColumn = (idx: number) => { - onChange(columns.filter((_, i) => i !== idx)); - }; - - return ( -
-
- - GROUP BY - - -
- {columns.map((col, idx) => ( -
- - -
- ))} - {columns.length === 0 && ( -

- No grouping. Click "Add" to create one. -

- )} -
- ); -} diff --git a/src/components/querybuilder/__tests__/QueryBuilder.browser.test.tsx b/src/components/querybuilder/__tests__/QueryBuilder.browser.test.tsx deleted file mode 100644 index 1b51cc7..0000000 --- a/src/components/querybuilder/__tests__/QueryBuilder.browser.test.tsx +++ /dev/null @@ -1,1492 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ColumnInfo } from "../../../types"; -import { QueryBuilder } from "../QueryBuilder"; - -// ─── Module-level mock state ────────────────────────────────── -let mockTableNames: string[] = []; -let mockFetchTables: ReturnType; -let mockFetchColumns: ReturnType; -let mockExecuteQuery: ReturnType; -let mockEditorAddTab: ReturnType; -let mockEditorUpdateTabContent: ReturnType; - -// ─── Mock useSchemaCache ────────────────────────────────────── -vi.mock("../../../hooks/useSchemaCache", () => ({ - useSchemaCache: Object.assign( - (selector?: (s: unknown) => unknown) => { - const state = { - fetchTables: mockFetchTables, - fetchColumns: mockFetchColumns, - tables: new Map([["testdb", mockTableNames]]), - columns: new Map(), - loading: false, - }; - if (typeof selector === "function") return selector(state); - return state; - }, - { - getState: vi.fn(() => ({ - fetchTables: mockFetchTables, - fetchColumns: mockFetchColumns, - tables: new Map([["testdb", mockTableNames]]), - columns: new Map(), - loading: false, - })), - }, - ), -})); - -// ─── Mock editorStore ───────────────────────────────────────── -vi.mock("../../../stores/editorStore", () => ({ - useEditorStore: { - getState: vi.fn(() => ({ - addTab: mockEditorAddTab, - updateTabContent: mockEditorUpdateTabContent, - tabs: [], - activeTabId: null, - })), - }, -})); - -// ─── Mock API ───────────────────────────────────────────────── -vi.mock("../../../lib/tauri-api", () => ({ - api: { - executeQuery: (...args: unknown[]) => mockExecuteQuery(...args), - getTables: vi.fn(), - getColumns: vi.fn(), - }, -})); - -// ─── Test helpers ───────────────────────────────────────────── -const mockColumns: ColumnInfo[] = [ - { - name: "id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: true, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "name", - data_type: "varchar", - column_type: "VARCHAR(255)", - nullable: false, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "email", - data_type: "varchar", - column_type: "VARCHAR(255)", - nullable: true, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, -]; - -const DEFAULT_PROPS = { connectionId: "conn-1", database: "testdb" }; - -function resetMocks() { - mockTableNames = ["users", "orders", "products"]; - mockFetchTables = vi.fn().mockResolvedValue(["users", "orders", "products"]); - mockFetchColumns = vi.fn().mockResolvedValue(mockColumns); - mockExecuteQuery = vi.fn().mockResolvedValue([]); - mockEditorAddTab = vi.fn().mockReturnValue("new-tab-id"); - mockEditorUpdateTabContent = vi.fn(); -} - -// ────────────────────────────────────────────────────────────── -// Tests -// ────────────────────────────────────────────────────────────── -describe("QueryBuilder (browser)", () => { - beforeEach(() => { - resetMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - // ─── Basic rendering ───────────────────────────────────── - it("renders without crashing", () => { - const { container } = render(); - expect(container.firstElementChild).toBeInTheDocument(); - }); - - it("renders with connectionId and database props", () => { - render(); - expect(screen.getByPlaceholderText("Filter tables...")).toBeInTheDocument(); - }); - - // ─── Table list ────────────────────────────────────────── - it("renders the table list from cached tables", () => { - mockTableNames = ["users", "orders", "products"]; - render(); - - expect(screen.getByText("users")).toBeInTheDocument(); - expect(screen.getByText("orders")).toBeInTheDocument(); - expect(screen.getByText("products")).toBeInTheDocument(); - }); - - it("renders with a single table", () => { - mockTableNames = ["users"]; - render(); - - expect(screen.getByText("users")).toBeInTheDocument(); - expect(screen.queryByText("orders")).not.toBeInTheDocument(); - }); - - it("renders with empty table list", () => { - mockTableNames = []; - render(); - - expect(screen.getByText("Loading tables...")).toBeInTheDocument(); - }); - - it("fetches tables when not cached", () => { - // Clear cached tables before render so fetchTables is called - mockTableNames = []; - render(); - // With no cached tables and empty initial tableNames, shows Loading - expect(screen.getByText("Loading tables...")).toBeInTheDocument(); - }); - - // ─── Empty canvas state ────────────────────────────────── - it("shows empty canvas message initially", () => { - render(); - expect( - screen.getByText("Click a table from the left panel to add it to the canvas"), - ).toBeInTheDocument(); - }); - - it("hides empty canvas message after adding a table", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Click "users" in the table list to add it - const addBtns = screen.getAllByText("users").filter( - (el) => el.closest("button")?.querySelector(".lucide-plus"), - ); - if (addBtns.length > 0) { - await user.click(addBtns[0]); - } else { - // Fallback: click any "users" text in a button - const allUsers = screen.getAllByText("users"); - await user.click(allUsers[0]); - } - - // After adding, the empty canvas message should disappear - await waitFor(() => { - expect( - screen.queryByText("Click a table from the left panel to add it to the canvas"), - ).not.toBeInTheDocument(); - }); - }); - - // ─── Table search/filter ───────────────────────────────── - it("filters tables by search", async () => { - const user = userEvent.setup(); - render(); - - const searchInput = screen.getByPlaceholderText("Filter tables..."); - await user.type(searchInput, "user"); - - expect(screen.getByText("users")).toBeInTheDocument(); - expect(screen.queryByText("orders")).not.toBeInTheDocument(); - expect(screen.queryByText("products")).not.toBeInTheDocument(); - }); - - it("shows 'No tables found' when filter matches nothing", async () => { - const user = userEvent.setup(); - render(); - - const searchInput = screen.getByPlaceholderText("Filter tables..."); - await user.type(searchInput, "zzzzz"); - - expect(screen.getByText("No tables found")).toBeInTheDocument(); - }); - - it("clearing search restores full table list", async () => { - const user = userEvent.setup(); - render(); - - const searchInput = screen.getByPlaceholderText("Filter tables..."); - await user.type(searchInput, "user"); - expect(screen.queryByText("orders")).not.toBeInTheDocument(); - - await user.clear(searchInput); - await user.type(searchInput, "o"); - // "orders" and "products" contain "o" - expect(screen.getByText("orders")).toBeInTheDocument(); - expect(screen.queryByText("users")).not.toBeInTheDocument(); - }); - - it("search is case-insensitive", async () => { - const user = userEvent.setup(); - render(); - - const searchInput = screen.getByPlaceholderText("Filter tables..."); - await user.type(searchInput, "USERS"); - - expect(screen.getByText("users")).toBeInTheDocument(); - }); - - // ─── Add table to canvas ───────────────────────────────── - it("adding a table calls fetchColumns", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Click "users" in the table list - const allUsers = screen.getAllByText("users"); - await user.click(allUsers[0]); - - await waitFor(() => { - expect(mockFetchColumns).toHaveBeenCalledWith( - "conn-1", - "testdb", - "users", - ); - }); - }); - - it("adding a table renders a table card on canvas", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Click "users" to add - await user.click(screen.getAllByText("users")[0]); - - // Wait for the table card to appear - await waitFor(() => { - // Table card header shows the table name - const headers = document.querySelectorAll(".font-bold"); - const headerTexts = Array.from(headers).map((h) => h.textContent); - expect(headerTexts.some((t) => t?.includes("users"))).toBe(true); - }); - }); - - // ─── Table cards with columns ──────────────────────────── - it("table card renders column checkboxes", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - expect(checkboxes.length).toBe(3); // id, name, email - }); - }); - - it("table card shows column names", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - // Column names are in the card buttons - const buttons = document.querySelectorAll("[title*=\"click to join\"]"); - const texts = Array.from(buttons).map((b) => b.textContent?.trim() ?? ""); - expect(texts.some((t) => t.includes("id"))).toBe(true); - expect(texts.some((t) => t.includes("name"))).toBe(true); - expect(texts.some((t) => t.includes("email"))).toBe(true); - }); - }); - - it("table card shows primary key indicator", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - // The Key icon (lucide-react) for primary key - const keyIcons = document.querySelectorAll(".text-yellow-500"); - expect(keyIcons.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ─── Toggling column checkboxes ────────────────────────── - it("toggling a column generates SQL", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - // Wait for checkboxes - let checkboxes: NodeListOf; - await waitFor(() => { - checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - expect(checkboxes.length).toBe(3); - }); - checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - - // Select "id" and "name" columns - for (let i = 0; i < Math.min(2, checkboxes.length); i++) { - await user.click(checkboxes[i]); - } - }); - - it("selecting column shows aggregate dropdown", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - expect(checkboxes.length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - // After selecting, a select dropdown should appear for aggregate functions - await waitFor(() => { - const selects = document.querySelectorAll("select"); - // One select per selected column for aggregate - const aggregateSelects = Array.from(selects).filter( - (s) => s.getAttribute("title") === "Aggregate function", - ); - expect(aggregateSelects.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ─── Join creation ────────────────────────────────────── - it("creating a join between two tables", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Add users table - await user.click(screen.getAllByText("users")[0]); - - // Wait for table card - await waitFor(() => { - expect(document.querySelectorAll("[title*=\"click to join\"]").length).toBe(3); - }); - - // Add orders table - mockFetchColumns.mockResolvedValue([ - { - name: "id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: true, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "user_id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, - ]); - const orderBtns = screen.getAllByText("orders"); - await user.click(orderBtns[0]); - - // Wait for second table card - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(2); - }); - - // Click "id" on first table (starts pending join) - const joinButtons = document.querySelectorAll("[title*=\"click to join\"]"); - expect(joinButtons.length).toBeGreaterThanOrEqual(3); - await user.click(joinButtons[0]); // clicks "id" on users - }); - - // ─── Join type toggling ────────────────────────────────── - it("join type can be toggled", async () => { - // This test verifies the UI for join type toggle exists - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Add two tables and create a join - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("[title*=\"click to join\"]").length).toBe(3); - }); - - mockFetchColumns.mockResolvedValue([ - { - name: "id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: true, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "user_id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, - ]); - await user.click(screen.getAllByText("orders")[0]); - - await waitFor(() => { - expect(document.querySelectorAll(".absolute.select-none").length).toBe(2); - }); - - // Click column on users (start join) - const joinBtns = document.querySelectorAll("[title*=\"click to join\"]"); - await user.click(joinBtns[0]); - - // Click column on orders (complete join) - // Need to wait for the pending join state to update - await waitFor(() => { - const updatedBtns = document.querySelectorAll("[title*=\"click to join\"]"); - if (updatedBtns.length >= 6) { - // Click the first column on the second table (index 3 = orders.id) - expect(true).toBe(true); - } - }); - }); - - // ─── Dragging table cards ──────────────────────────────── - it("table card can be dragged to reposition", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - render(); - - // Add table - await act(async () => { - fireEvent.click(screen.getAllByText("users")[0]); - }); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(1); - }); - - const card = document.querySelector(".absolute.select-none") as HTMLElement; - expect(card).toBeInTheDocument(); - - // Get initial position - const initialLeft = card.style.left; - const initialTop = card.style.top; - - // Simulate drag on the card header (not on data-no-drag elements) - const cardHeader = card.querySelector(".flex.items-center.justify-between"); - expect(cardHeader).toBeInTheDocument(); - - const canvas = document.querySelector(".relative.flex-1.overflow-auto"); - expect(canvas).toBeInTheDocument(); - - // Mock getBoundingClientRect for canvas - const canvasRect = { left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 }; - vi.spyOn(canvas!, "getBoundingClientRect").mockReturnValue(canvasRect as DOMRect); - - // Start drag - fireEvent.mouseDown(cardHeader!, { clientX: 100, clientY: 100 }); - - // Move - fireEvent.mouseMove(canvas!, { clientX: 200, clientY: 150 }); - - // End drag - fireEvent.mouseUp(canvas!); - - // The position should have changed (or at minimum not crashed) - expect(card).toBeInTheDocument(); - }); - - // ─── Bottom panel sections ─────────────────────────────── - it("shows SQL Preview section by default", () => { - render(); - expect(screen.getByText("SQL Preview")).toBeInTheDocument(); - }); - - it("shows WHERE, ORDER BY, GROUP BY, HAVING tabs", () => { - render(); - expect(screen.getByText("WHERE")).toBeInTheDocument(); - expect(screen.getByText("ORDER BY")).toBeInTheDocument(); - expect(screen.getByText("GROUP BY")).toBeInTheDocument(); - expect(screen.getByText("HAVING")).toBeInTheDocument(); - }); - - it("switches to WHERE tab and shows empty conditions", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("WHERE")); - - expect(screen.getByText("WHERE Conditions")).toBeInTheDocument(); - expect(screen.getByText("No conditions. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - - it("switches to ORDER BY tab and shows empty state", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("ORDER BY")); - - expect(screen.getByText("No ordering. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - - it("switches to GROUP BY tab and shows empty state", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("GROUP BY")); - - expect(screen.getByText("No grouping. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - - it("switches to HAVING tab", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("HAVING")); - - expect(screen.getByText("HAVING Conditions")).toBeInTheDocument(); - }); - - it("tabs are mutually exclusive - clicking WHERE then ORDER BY hides WHERE content", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("WHERE")); - expect(screen.getByText("WHERE Conditions")).toBeInTheDocument(); - - await user.click(screen.getByText("ORDER BY")); - expect(screen.queryByText("WHERE Conditions")).not.toBeInTheDocument(); - expect(screen.getByText("No ordering. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - - // ─── WHERE conditions ──────────────────────────────────── - it("adds a WHERE condition", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Add a table first so columnRefs are available - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("WHERE")); - - // Click "Add" to add a WHERE condition - const addBtn = screen.getByText("Add"); - await user.click(addBtn); - - // Now the empty state message should be gone - await waitFor(() => { - expect(screen.queryByText("No conditions. Click \"Add\" to create one.")).not.toBeInTheDocument(); - }); - }); - - it("adds and removes WHERE conditions", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("WHERE")); - - // Add a condition - await user.click(screen.getByText("Add")); - - // Now find the Trash2 buttons in the WHERE section - await waitFor(() => { - const deleteBtns = document.querySelectorAll( - ".flex.flex-col.gap-1 button svg.lucide-trash2", - ); - // The delete button should exist - expect(deleteBtns.length).toBeGreaterThanOrEqual(0); - }); - }); - - it("AND/OR logic can be toggled for conditions beyond first", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("WHERE")); - - // Add two conditions - await user.click(screen.getByText("Add")); - await user.click(screen.getByText("Add")); - - // The second condition should have an AND/OR toggle button - await waitFor(() => { - const logicToggles = document.querySelectorAll(".text-brand-400.font-bold"); - expect(logicToggles.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ─── ORDER BY management ───────────────────────────────── - it("adds an ORDER BY clause", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("ORDER BY")); - - // Add button should exist - const addBtns = screen.getAllByText("Add"); - // The ORDER BY section has its own Add button - const orderByAddBtn = addBtns.find((btn) => { - // Find the Add button that is within the ORDER BY content area - return btn.closest(".flex.flex-col.gap-1") !== null; - }); - if (orderByAddBtn) { - await user.click(orderByAddBtn); - } - - await waitFor(() => { - expect(screen.queryByText("No ordering. Click \"Add\" to create one.")).not.toBeInTheDocument(); - }); - }); - - it("toggles ORDER BY direction ASC/DESC", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("ORDER BY")); - - // The ORDER BY section has its own "Add" button - // Use the Add button within the ORDER BY content area - await waitFor(() => { - const addBtns = screen.queryAllByText("Add"); - expect(addBtns.length).toBeGreaterThanOrEqual(1); - }); - - await user.click(screen.queryAllByText("Add")[0]); - - // After adding, an ASC/DESC toggle button should appear - await waitFor(() => { - const toggleBtn = screen.queryByText("ASC"); - expect(toggleBtn).toBeTruthy(); - }); - }); - - // ─── GROUP BY section ──────────────────────────────────── - it("adds a GROUP BY column", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("GROUP BY")); - - // Click Add for GROUP BY - const addBtns = screen.getAllByText("Add"); - await user.click(addBtns[addBtns.length - 1]); - - await waitFor(() => { - expect(screen.queryByText("No grouping. Click \"Add\" to create one.")).not.toBeInTheDocument(); - }); - }); - - it("removes a GROUP BY column", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("GROUP BY")); - - // Add a GROUP BY column - const addBtns = screen.getAllByText("Add"); - await user.click(addBtns[addBtns.length - 1]); - - await waitFor(() => { - const trashIcons = document.querySelectorAll(".lucide-trash2"); - expect(trashIcons.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ─── HAVING conditions ─────────────────────────────────── - it("shows HAVING section empty state", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("HAVING")); - - expect(screen.getByText("HAVING Conditions")).toBeInTheDocument(); - }); - - it("adds a HAVING condition when columns are available", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("HAVING")); - - // Click Add for HAVING - const addBtns = screen.getAllByText("Add"); - // The Add button for HAVING is within the HAVING section - await user.click(addBtns[0]); - - await waitFor(() => { - // Should have added a condition - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ─── LIMIT input ───────────────────────────────────────── - it("has LIMIT input field", () => { - render(); - expect(screen.getByText("LIMIT")).toBeInTheDocument(); - }); - - it("LIMIT input accepts numeric values", async () => { - const user = userEvent.setup(); - render(); - - const limitInput = screen.getByPlaceholderText("—"); - expect(limitInput).toBeInTheDocument(); - expect(limitInput.getAttribute("type")).toBe("number"); - - await user.type(limitInput, "100"); - await waitFor(() => { - expect(limitInput).toHaveValue(100); - }); - }); - - it("clearing LIMIT input sets value to empty", async () => { - const user = userEvent.setup(); - render(); - - const limitInput = screen.getByPlaceholderText("—") as HTMLInputElement; - await user.type(limitInput, "50"); - expect(limitInput).toHaveValue(50); - - await user.clear(limitInput); - expect(limitInput).toHaveValue(null); - }); - - // ─── SQL Preview ───────────────────────────────────────── - it("shows SQL placeholder when no tables added", () => { - render(); - expect( - screen.getByText(/Build your query by adding tables/), - ).toBeInTheDocument(); - }); - - it("generates SQL preview after selecting columns", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Add users table - await user.click(screen.getAllByText("users")[0]); - - // Wait for table card with checkboxes - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - // Select "id" column - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - // SQL Preview should update from the placeholder - await waitFor(() => { - const pre = document.querySelector("pre"); - expect(pre).toBeInTheDocument(); - expect(pre!.textContent).not.toBe( - "-- Build your query by adding tables and selecting columns", - ); - }); - }); - - it("shows SQL with WHERE clause after adding condition", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); // Select id - - // Add WHERE condition - await user.click(screen.getByText("WHERE")); - await waitFor(() => screen.getByText("WHERE Conditions")); - await user.click(screen.getByText("Add")); - - // Need to switch back to SQL tab to see SQL preview - await user.click(screen.getByText("SQL Preview")); - - // SQL Preview should include WHERE - await waitFor(() => { - const pre = document.querySelector("pre"); - expect(pre?.textContent).toContain("WHERE"); - expect(pre?.textContent).toContain("SELECT"); - expect(pre?.textContent).toContain("FROM"); - }); - }); - - // ─── Copy to Editor ────────────────────────────────────── - it("has Copy to Editor button", () => { - render(); - expect(screen.getByText("Copy to Editor")).toBeInTheDocument(); - }); - - it("Copy to Editor button is disabled when no SQL generated", () => { - render(); - const copyBtn = screen.getByText("Copy to Editor").closest("button"); - expect(copyBtn?.disabled).toBe(true); - }); - - it("Copy to Editor becomes enabled after adding table with selected columns", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - // Select columns to generate SQL - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - // Wait for SQL to be generated - await waitFor(() => { - const copyBtn = screen.getByText("Copy to Editor").closest("button"); - if (copyBtn?.disabled === false) { - expect(copyBtn.disabled).toBe(false); - } - }); - }); - - it("clicking Copy to Editor calls addTab and updateTabContent", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - // Wait for Copy to Editor to be enabled - await waitFor(() => { - const copyBtn = screen.getByText("Copy to Editor").closest("button"); - if (copyBtn && !copyBtn.disabled) { - expect(true).toBe(true); - } - }); - - // Check if button is enabled, then click - const copyBtn = screen.getByText("Copy to Editor").closest("button"); - if (copyBtn && !copyBtn.disabled) { - await user.click(screen.getByText("Copy to Editor")); - expect(mockEditorAddTab).toHaveBeenCalledWith("conn-1", "testdb"); - expect(mockEditorUpdateTabContent).toHaveBeenCalled(); - } - }); - - // ─── Execute button ────────────────────────────────────── - it("has Execute button", () => { - render(); - expect(screen.getByText("Execute")).toBeInTheDocument(); - }); - - it("Execute button is disabled when no SQL generated", () => { - render(); - const executeBtn = screen.getByText("Execute").closest("button"); - expect(executeBtn?.disabled).toBe(true); - }); - - it("Execute button is enabled after generating SQL", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - await waitFor(() => { - const executeBtn = screen.getByText("Execute").closest("button"); - if (executeBtn?.disabled === false) { - expect(executeBtn.disabled).toBe(false); - } - }); - }); - - it("clicking Execute calls api.executeQuery", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - mockExecuteQuery.mockResolvedValue([ - { - query_id: "q1", - statement_index: 0, - columns: [{ name: "id", data_type: "int", nullable: false, is_primary_key: true }], - rows: [[1]], - rows_affected: 0, - execution_time_ms: 5, - warnings: [], - rows_truncated: false, - }, - ]); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - // Wait for Execute to be enabled - await waitFor(() => { - const executeBtn = screen.getByText("Execute").closest("button"); - if (executeBtn && !executeBtn.disabled) { - expect(executeBtn.disabled).toBe(false); - } - }); - - const executeBtn = screen.getByText("Execute").closest("button") as HTMLButtonElement; - if (!executeBtn.disabled) { - await user.click(screen.getByText("Execute")); - await waitFor(() => { - expect(mockExecuteQuery).toHaveBeenCalledWith( - "conn-1", - expect.any(String), - ); - }); - } - }); - - it("shows 'Running...' while executing", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - // Make executeQuery hang - let resolveQuery: (val: unknown) => void; - mockExecuteQuery.mockReturnValue( - new Promise((resolve) => { - resolveQuery = resolve; - }), - ); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - await waitFor(() => { - const executeBtn = screen.getByText("Execute").closest("button") as HTMLButtonElement; - if (executeBtn && !executeBtn.disabled) { - expect(executeBtn.disabled).toBe(false); - } - }); - - const executeBtn = screen.getByText("Execute").closest("button") as HTMLButtonElement; - if (!executeBtn.disabled) { - await user.click(screen.getByText("Execute")); - - await waitFor(() => { - expect(screen.getByText("Running...")).toBeInTheDocument(); - }); - - // Cleanup - resolveQuery!(undefined); - } - }); - - it("shows error message when query execution fails", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - mockExecuteQuery.mockRejectedValue(new Error("Table does not exist")); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]") as NodeListOf; - await user.click(checkboxes[0]); - - await waitFor(() => { - const executeBtn = screen.getByText("Execute").closest("button") as HTMLButtonElement; - if (executeBtn && !executeBtn.disabled) { - expect(executeBtn.disabled).toBe(false); - } - }); - - const executeBtn = screen.getByText("Execute").closest("button") as HTMLButtonElement; - if (!executeBtn.disabled) { - await user.click(screen.getByText("Execute")); - - await waitFor(() => { - expect(screen.getByText(/Table does not exist/)).toBeInTheDocument(); - }); - } - }); - - // ─── Multiple tables on canvas ────────────────────────── - it("adds multiple tables to canvas", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - // Add users - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(1); - }); - - // Add orders - mockFetchColumns.mockResolvedValue([ - { - name: "id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: true, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "user_id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, - ]); - const orderBtns = screen.getAllByText("orders"); - await user.click(orderBtns[0]); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(2); - }); - }); - - it("removing a table from canvas", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(1); - }); - - // Click the X button to remove the table - const removeBtn = document.querySelector("[data-no-drag].rounded.p-0\\.5"); - if (removeBtn) { - await user.click(removeBtn as HTMLElement); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(0); - }); - } - }); - - // ─── Loading state ────────────────────────────────────── - it("shows loading state when tables have not loaded", () => { - // Clear table names so Loading state is shown - mockTableNames = []; - render(); - expect(screen.getByText("Loading tables...")).toBeInTheDocument(); - }); - - // ─── SQL generation structure ─────────────────────────── - it("generates SELECT * FROM when table added without column selection", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - const pre = document.querySelector("pre"); - if (pre && pre.textContent !== "-- Build your query by adding tables and selecting columns") { - expect(pre.textContent).toContain("SELECT"); - expect(pre.textContent).toContain("FROM"); - } - }); - }); - - // ─── Column aliases ───────────────────────────────────── - it("table alias matches table name for first instance", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - - await waitFor(() => { - const cards = document.querySelectorAll(".absolute.select-none"); - expect(cards.length).toBe(1); - }); - - // The table card should show the table name - const boldElements = document.querySelectorAll(".font-bold"); - const cardHeader = Array.from(boldElements).find( - (el) => el.closest(".absolute.select-none"), - ); - expect(cardHeader?.textContent).toBe("users"); - }); - - // ─── GROUP BY section ──────────────────────────────────── - it("GROUP BY add column creates a new select element", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - // Navigate to GROUP BY tab by clicking the tab button - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - expect(groupByTab).toBeDefined(); - await user.click(groupByTab!); - - // Click the Add button in GROUP BY section - const allAdds = screen.getAllByText("Add"); - await user.click(allAdds[allAdds.length - 1]); - - await waitFor(() => { - expect(screen.queryByText("No grouping. Click \"Add\" to create one.")).not.toBeInTheDocument(); - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBeGreaterThanOrEqual(1); - }); - }); - - it("GROUP BY remove column via trash icon", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - await user.click(groupByTab!); - - // Add a GROUP BY column - const allAdds = screen.getAllByText("Add"); - await user.click(allAdds[allAdds.length - 1]); - - await waitFor(() => { - const trashIcons = document.querySelectorAll(".lucide-trash2"); - expect(trashIcons.length).toBeGreaterThanOrEqual(1); - }); - - // Click the trash icon in GROUP BY content area - const groupByContainer = document.querySelector(".flex.flex-col.gap-1"); - expect(groupByContainer).toBeInTheDocument(); - const trashBtn = groupByContainer!.querySelector("button svg.lucide-trash2")?.parentElement as HTMLElement; - if (trashBtn) { - await user.click(trashBtn); - } - - await waitFor(() => { - expect(screen.getByText("No grouping. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - }); - - it("GROUP BY select onChange updates the column ref", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - await user.click(groupByTab!); - - // Add a GROUP BY column - const allAdds = screen.getAllByText("Add"); - await user.click(allAdds[allAdds.length - 1]); - - await waitFor(() => { - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBeGreaterThanOrEqual(1); - }); - - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - const groupBySelect = selects[0] as HTMLSelectElement; - await user.selectOptions(groupBySelect, groupBySelect.options[0]?.value || ""); - - expect(groupBySelect).toBeInTheDocument(); - }); - - it("GROUP BY add button is disabled when no column refs available", async () => { - // Don't add any table first — no columnRefs - const user = userEvent.setup(); - render(); - - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - await user.click(groupByTab!); - - // The Add button should be disabled - const allAdds = screen.getAllByText("Add"); - const groupByAdd = allAdds[allAdds.length - 1]; - const addBtn = groupByAdd.closest("button"); - expect(addBtn).toBeInTheDocument(); - expect(addBtn?.disabled).toBe(true); - }); - - it("GROUP BY empty state shows placeholder message", async () => { - const user = userEvent.setup(); - render(); - - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - await user.click(groupByTab!); - - expect(screen.getByText("No grouping. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - - it("multiple GROUP BY columns with individual removes", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - // Navigate to GROUP BY tab: click the tab button - const groupByTab = screen.getAllByText("GROUP BY").find( - (el) => el.tagName === "BUTTON", - ); - expect(groupByTab).toBeDefined(); - await user.click(groupByTab!); - - // Add two GROUP BY columns - const allAdds = screen.getAllByText("Add"); - await user.click(allAdds[allAdds.length - 1]); - - await waitFor(() => { - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBe(1); - }); - - // Click Add again for second column - const addAgain = screen.getAllByText("Add"); - await user.click(addAgain[addAgain.length - 1]); - - await waitFor(() => { - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBe(2); - }); - - // Remove first column via its trash icon - const trashBtns = document.querySelectorAll(".flex.flex-col.gap-1 button svg.lucide-trash2"); - expect(trashBtns.length).toBe(2); - await user.click(trashBtns[0].parentElement as HTMLElement); - - await waitFor(() => { - const selects = document.querySelectorAll(".flex.flex-col.gap-1 select"); - expect(selects.length).toBe(1); - }); - }); - - // ─── HAVING section ────────────────────────────────────── - it("HAVING add condition creates a select and operator fields", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("HAVING")); - - // Click Add for HAVING - await user.click(screen.getByText("Add")); - - await waitFor(() => { - expect(screen.queryByText("No conditions. Click \"Add\" to create one.")).not.toBeInTheDocument(); - }); - }); - - it("HAVING condition can be removed", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("HAVING")); - - await user.click(screen.getByText("Add")); - - await waitFor(() => { - const trashIcons = document.querySelectorAll(".lucide-trash2"); - expect(trashIcons.length).toBeGreaterThanOrEqual(1); - }); - - const trashBtn = document.querySelector(".lucide-trash2")?.parentElement as HTMLElement; - if (trashBtn) { - await user.click(trashBtn); - } - - await waitFor(() => { - expect(screen.getByText("No conditions. Click \"Add\" to create one.")).toBeInTheDocument(); - }); - }); - - it("HAVING condition operator can be toggled", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("HAVING")); - - await user.click(screen.getByText("Add")); - - await waitFor(() => { - // Find the operator select - const selects = document.querySelectorAll("select"); - expect(selects.length).toBeGreaterThanOrEqual(1); - }); - - const selects = document.querySelectorAll("select"); - // The operator select should be present (w-24 shrink-0) - const operatorSelect = Array.from(selects).find((s) => s.className.includes("w-24")); - expect(operatorSelect).toBeInTheDocument(); - if (operatorSelect) { - await user.selectOptions(operatorSelect as HTMLSelectElement, ">"); - await waitFor(() => { - expect((operatorSelect as HTMLSelectElement).value).toBe(">"); - }); - } - }); - - it("HAVING section shows HAVING label (not WHERE)", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByText("users")[0]); - await waitFor(() => { - expect(document.querySelectorAll("input[type=\"checkbox\"]").length).toBe(3); - }); - - await user.click(screen.getByText("HAVING")); - - expect(screen.getByText("HAVING Conditions")).toBeInTheDocument(); - }); -}); diff --git a/src/components/querybuilder/__tests__/QueryBuilder.test.tsx b/src/components/querybuilder/__tests__/QueryBuilder.test.tsx deleted file mode 100644 index c9bb382..0000000 --- a/src/components/querybuilder/__tests__/QueryBuilder.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryBuilder } from "../QueryBuilder"; - -vi.mock("../../../lib/tauri-api", () => ({ - api: { - executeQuery: vi.fn(), - getTables: vi.fn(), - getColumns: vi.fn(), - }, -})); - -vi.mock("../../../hooks/useSchemaCache", () => ({ - useSchemaCache: vi.fn(), -})); - -vi.mock("../../../stores/editorStore", () => ({ - useEditorStore: { - getState: vi.fn(), - }, -})); - -import { useSchemaCache } from "../../../hooks/useSchemaCache"; -import { api } from "../../../lib/tauri-api"; -import { useEditorStore } from "../../../stores/editorStore"; - -const mockTables = ["users", "orders", "products"]; -const mockColumns = [ - { - name: "id", - data_type: "int", - column_type: "INT", - nullable: false, - is_primary_key: true, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "name", - data_type: "varchar", - column_type: "VARCHAR(255)", - nullable: false, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, - { - name: "email", - data_type: "varchar", - column_type: "VARCHAR(255)", - nullable: true, - is_primary_key: false, - default_value: undefined, - extra: "", - comment: "", - }, -]; - -describe("QueryBuilder", () => { - const mockFetchTables = vi.fn(); - const mockFetchColumns = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - vi.mocked(useSchemaCache).mockImplementation((selector) => { - const state = { - fetchTables: mockFetchTables, - fetchColumns: mockFetchColumns, - tables: new Map([["testdb", mockTables]]), - }; - if (typeof selector === "function") { - return selector(state); - } - return state; - }); - - vi.mocked(useEditorStore.getState).mockReturnValue({ - addTab: vi.fn(() => "new-tab"), - updateTabContent: vi.fn(), - tabs: [], - activeTabId: null, - connectionId: undefined, - database: undefined, - }); - - mockFetchColumns.mockResolvedValue(mockColumns); - }); - - it("renders the query builder with table list", () => { - render(); - expect(screen.getByPlaceholderText("Filter tables...")).toBeDefined(); - expect(screen.getByText("users")).toBeDefined(); - expect(screen.getByText("orders")).toBeDefined(); - expect(screen.getByText("products")).toBeDefined(); - }); - - it("shows empty canvas message initially", () => { - render(); - expect( - screen.getByText("Click a table from the left panel to add it to the canvas"), - ).toBeDefined(); - }); - - it("shows SQL Preview section with placeholder", () => { - render(); - expect(screen.getByText("SQL Preview")).toBeDefined(); - }); - - it("shows WHERE, ORDER BY, GROUP BY, HAVING tabs", () => { - render(); - expect(screen.getByText("WHERE")).toBeDefined(); - expect(screen.getByText("ORDER BY")).toBeDefined(); - expect(screen.getByText("GROUP BY")).toBeDefined(); - expect(screen.getByText("HAVING")).toBeDefined(); - }); - - it("adds a table to canvas when clicked", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - - render(); - - await act(async () => { - const tableButtons = screen.getAllByText("users"); - fireEvent.click(tableButtons[0]); - }); - - expect(mockFetchColumns).toHaveBeenCalledWith("conn-1", "testdb", "users"); - }); - - it("filters tables by search", () => { - render(); - const searchInput = screen.getByPlaceholderText("Filter tables..."); - fireEvent.change(searchInput, { target: { value: "user" } }); - expect(screen.getByText("users")).toBeDefined(); - expect(screen.queryByText("orders")).toBeNull(); - expect(screen.queryByText("products")).toBeNull(); - }); - - it("shows 'No tables found' when filter matches nothing", () => { - render(); - const searchInput = screen.getByPlaceholderText("Filter tables..."); - fireEvent.change(searchInput, { target: { value: "zzzzz" } }); - expect(screen.getByText("No tables found")).toBeDefined(); - }); - - it("switches to WHERE tab and shows empty conditions", () => { - render(); - fireEvent.click(screen.getByText("WHERE")); - expect(screen.getByText("WHERE Conditions")).toBeDefined(); - expect(screen.getByText("No conditions. Click \"Add\" to create one.")).toBeDefined(); - }); - - it("switches to ORDER BY tab and shows empty order clause", () => { - render(); - fireEvent.click(screen.getByText("ORDER BY")); - expect(screen.getByText("No ordering. Click \"Add\" to create one.")).toBeDefined(); - }); - - it("switches to GROUP BY tab and shows empty group clause", () => { - render(); - fireEvent.click(screen.getByText("GROUP BY")); - expect(screen.getByText("No grouping. Click \"Add\" to create one.")).toBeDefined(); - }); - - it("switches to HAVING tab", () => { - render(); - fireEvent.click(screen.getByText("HAVING")); - expect(screen.getByText("HAVING Conditions")).toBeDefined(); - }); - - it("has Execute and Copy to Editor buttons", () => { - render(); - expect(screen.getByText("Execute")).toBeDefined(); - expect(screen.getByText("Copy to Editor")).toBeDefined(); - }); - - it("has LIMIT input", () => { - render(); - expect(screen.getByText("LIMIT")).toBeDefined(); - }); - - it("uses cached tables when available", () => { - vi.mocked(useSchemaCache).mockImplementation((selector) => { - const state = { - fetchTables: mockFetchTables, - fetchColumns: mockFetchColumns, - tables: new Map([["testdb", mockTables]]), - }; - if (typeof selector === "function") return selector(state); - return state; - }); - - render(); - expect(screen.getByText("users")).toBeDefined(); - }); - - it("Execute and Copy to Editor are disabled when no SQL generated", () => { - render(); - const executeBtn = screen.getByText("Execute"); - const copyBtn = screen.getByText("Copy to Editor"); - expect(executeBtn.closest("button")?.disabled).toBe(true); - expect(copyBtn.closest("button")?.disabled).toBe(true); - }); - - describe("table added to canvas", () => { - it("renders table card with table name after adding", async () => { - mockFetchColumns.mockResolvedValue(mockColumns); - - render(); - - await act(async () => { - const btns = screen.getAllByText("users"); - fireEvent.click(btns[0]); - }); - - await waitFor(() => { - const card = document.querySelector(".absolute.select-none"); - expect(card).toBeTruthy(); - }); - }); - }); - - describe("copy to editor", () => { - it("copies SQL to editor when Copy to Editor is clicked", async () => { - const mockAddTab = vi.fn(() => "new-tab"); - const mockUpdateTabContent = vi.fn(); - vi.mocked(useEditorStore.getState).mockReturnValue({ - addTab: mockAddTab, - updateTabContent: mockUpdateTabContent, - tabs: [], - activeTabId: null, - }); - - mockFetchColumns.mockResolvedValue(mockColumns); - - render(); - - await act(async () => { - const btns = screen.getAllByText("users"); - fireEvent.click(btns[0]); - }); - - await waitFor(() => { - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - expect(checkboxes.length).toBeGreaterThan(0); - }); - - const checkboxes = document.querySelectorAll("input[type=\"checkbox\"]"); - if (checkboxes.length > 0) { - fireEvent.click(checkboxes[0]); - } - - await act(async () => { - fireEvent.click(screen.getByText("Copy to Editor")); - }); - - expect(mockAddTab).toHaveBeenCalled(); - expect(mockUpdateTabContent).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/lib/__tests__/query-builder-engine.test.ts b/src/lib/__tests__/query-builder-engine.test.ts deleted file mode 100644 index 0726813..0000000 --- a/src/lib/__tests__/query-builder-engine.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type CanvasTable, - generateAlias, - generateSQL, - getAllColumnRefs, - type QueryBuilderState, -} from "../query-builder-engine"; - -function makeTable( - overrides: Partial & { tableName: string }, -): CanvasTable { - return { - id: overrides.id ?? "t1", - tableName: overrides.tableName, - alias: overrides.alias ?? overrides.tableName, - columns: overrides.columns ?? [ - { - name: "id", - data_type: "int", - column_type: "int(11)", - nullable: false, - is_primary_key: true, - extra: "", - comment: "", - }, - { - name: "name", - data_type: "varchar", - column_type: "varchar(255)", - nullable: true, - is_primary_key: false, - extra: "", - comment: "", - }, - ], - selectedColumns: overrides.selectedColumns ?? [], - aggregates: overrides.aggregates ?? {}, - position: overrides.position ?? { x: 0, y: 0 }, - }; -} - -function makeState(overrides: Partial): QueryBuilderState { - return { - tables: overrides.tables ?? [], - joins: overrides.joins ?? [], - where: overrides.where ?? [], - orderBy: overrides.orderBy ?? [], - groupBy: overrides.groupBy ?? [], - having: overrides.having ?? [], - limit: overrides.limit ?? null, - }; -} - -describe("query-builder-engine", () => { - describe("generateSQL", () => { - it("returns empty string with no tables", () => { - expect(generateSQL(makeState({}))).toBe(""); - }); - - it("generates SELECT * for table with no selected columns", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - }), - ); - expect(sql).toContain("SELECT *"); - expect(sql).toContain("FROM `users` AS `users`"); - }); - - it("generates SELECT with specific columns", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - tableName: "users", - selectedColumns: ["id", "name"], - }), - ], - }), - ); - expect(sql).toContain("`users`.`id`"); - expect(sql).toContain("`users`.`name`"); - expect(sql).not.toContain("SELECT *"); - }); - - it("generates SELECT with aggregate functions", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - tableName: "orders", - selectedColumns: ["id", "name"], - aggregates: { id: "COUNT" }, - }), - ], - }), - ); - expect(sql).toContain("COUNT(`orders`.`id`)"); - expect(sql).toContain("`orders`.`name`"); - }); - - it("generates INNER JOIN", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ id: "t1", tableName: "users" }), - makeTable({ id: "t2", tableName: "orders" }), - ], - joins: [ - { - id: "j1", - leftTableId: "t1", - leftColumn: "id", - rightTableId: "t2", - rightColumn: "user_id", - joinType: "INNER", - }, - ], - }), - ); - expect(sql).toContain("INNER JOIN `orders` AS `orders`"); - expect(sql).toContain( - "ON `users`.`id` = `orders`.`user_id`", - ); - }); - - it("generates LEFT JOIN", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ id: "t1", tableName: "users" }), - makeTable({ id: "t2", tableName: "orders" }), - ], - joins: [ - { - id: "j1", - leftTableId: "t1", - leftColumn: "id", - rightTableId: "t2", - rightColumn: "user_id", - joinType: "LEFT", - }, - ], - }), - ); - expect(sql).toContain("LEFT JOIN"); - }); - - it("generates WHERE with equality", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`name`", - operator: "=", - value: "Alice", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("WHERE `users`.`name` = 'Alice'"); - }); - - it("generates WHERE with numeric value (unquoted)", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`id`", - operator: ">", - value: "42", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("WHERE `users`.`id` > 42"); - }); - - it("generates WHERE with IS NULL", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`name`", - operator: "IS NULL", - value: "", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("WHERE `users`.`name` IS NULL"); - }); - - it("generates WHERE with LIKE", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`name`", - operator: "LIKE", - value: "%test%", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("WHERE `users`.`name` LIKE '%test%'"); - }); - - it("generates WHERE with multiple conditions and AND/OR", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`id`", - operator: ">", - value: "10", - logic: "AND", - }, - { - id: "w2", - column: "`users`.`name`", - operator: "=", - value: "Bob", - logic: "OR", - }, - ], - }), - ); - expect(sql).toContain("WHERE `users`.`id` > 10"); - expect(sql).toContain("OR `users`.`name` = 'Bob'"); - }); - - it("generates ORDER BY", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - tableName: "users", - selectedColumns: ["id"], - }), - ], - orderBy: [ - { id: "o1", column: "`users`.`id`", direction: "DESC" }, - ], - }), - ); - expect(sql).toContain("ORDER BY `users`.`id` DESC"); - }); - - it("generates GROUP BY", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - tableName: "users", - selectedColumns: ["name"], - }), - ], - groupBy: ["`users`.`name`"], - }), - ); - expect(sql).toContain("GROUP BY `users`.`name`"); - }); - - it("generates HAVING with GROUP BY", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - tableName: "users", - selectedColumns: ["name", "id"], - aggregates: { id: "COUNT" }, - }), - ], - groupBy: ["`users`.`name`"], - having: [ - { - id: "h1", - column: "COUNT(`users`.`id`)", - operator: ">", - value: "5", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("HAVING COUNT(`users`.`id`) > 5"); - }); - - it("does not generate HAVING without GROUP BY", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - having: [ - { - id: "h1", - column: "COUNT(`users`.`id`)", - operator: ">", - value: "5", - logic: "AND", - }, - ], - }), - ); - expect(sql).not.toContain("HAVING"); - }); - - it("generates LIMIT", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - limit: 100, - }), - ); - expect(sql).toContain("LIMIT 100"); - }); - - it("handles table aliasing for duplicate tables", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ id: "t1", tableName: "users", alias: "users" }), - makeTable({ id: "t2", tableName: "users", alias: "users_2" }), - ], - joins: [ - { - id: "j1", - leftTableId: "t1", - leftColumn: "manager_id", - rightTableId: "t2", - rightColumn: "id", - joinType: "INNER", - }, - ], - }), - ); - expect(sql).toContain("FROM `users` AS `users`"); - expect(sql).toContain("INNER JOIN `users` AS `users_2`"); - }); - - it("escapes single quotes in string values", () => { - const sql = generateSQL( - makeState({ - tables: [makeTable({ tableName: "users" })], - where: [ - { - id: "w1", - column: "`users`.`name`", - operator: "=", - value: "O'Brien", - logic: "AND", - }, - ], - }), - ); - expect(sql).toContain("'O''Brien'"); - }); - - it("generates complete query with all clauses", () => { - const sql = generateSQL( - makeState({ - tables: [ - makeTable({ - id: "t1", - tableName: "users", - selectedColumns: ["name", "id"], - aggregates: { id: "COUNT" }, - }), - makeTable({ id: "t2", tableName: "orders" }), - ], - joins: [ - { - id: "j1", - leftTableId: "t1", - leftColumn: "id", - rightTableId: "t2", - rightColumn: "user_id", - joinType: "LEFT", - }, - ], - where: [ - { - id: "w1", - column: "`users`.`name`", - operator: "!=", - value: "admin", - logic: "AND", - }, - ], - groupBy: ["`users`.`name`"], - having: [ - { - id: "h1", - column: "COUNT(`users`.`id`)", - operator: ">=", - value: "2", - logic: "AND", - }, - ], - orderBy: [ - { id: "o1", column: "`users`.`name`", direction: "ASC" }, - ], - limit: 50, - }), - ); - expect(sql).toContain("SELECT"); - expect(sql).toContain("COUNT(`users`.`id`)"); - expect(sql).toContain("FROM `users`"); - expect(sql).toContain("LEFT JOIN `orders`"); - expect(sql).toContain("WHERE"); - expect(sql).toContain("GROUP BY"); - expect(sql).toContain("HAVING"); - expect(sql).toContain("ORDER BY"); - expect(sql).toContain("LIMIT 50"); - }); - }); - - describe("generateAlias", () => { - it("returns table name when no duplicates", () => { - expect(generateAlias("users", [])).toBe("users"); - }); - - it("returns table_2 for second instance", () => { - const existing = [makeTable({ tableName: "users" })]; - expect(generateAlias("users", existing)).toBe("users_2"); - }); - - it("returns table_3 for third instance", () => { - const existing = [ - makeTable({ id: "t1", tableName: "users", alias: "users" }), - makeTable({ id: "t2", tableName: "users", alias: "users_2" }), - ]; - expect(generateAlias("users", existing)).toBe("users_3"); - }); - }); - - describe("getAllColumnRefs", () => { - it("returns refs for all columns in all tables", () => { - const tables = [ - makeTable({ id: "t1", tableName: "users" }), - makeTable({ id: "t2", tableName: "orders" }), - ]; - const refs = getAllColumnRefs(tables); - expect(refs).toHaveLength(4); // 2 columns x 2 tables - expect(refs[0].label).toBe("users.id"); - expect(refs[0].ref).toBe("`users`.`id`"); - }); - }); -}); diff --git a/src/lib/query-builder-engine.ts b/src/lib/query-builder-engine.ts deleted file mode 100644 index fd25a49..0000000 --- a/src/lib/query-builder-engine.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { ColumnInfo } from "../types"; - -export type AggregateFunction = "COUNT" | "SUM" | "AVG" | "MAX" | "MIN"; -export type JoinType = "INNER" | "LEFT" | "RIGHT"; -export type WhereOperator = - | "=" - | "!=" - | ">" - | "<" - | ">=" - | "<=" - | "LIKE" - | "IN" - | "IS NULL" - | "IS NOT NULL"; -export type LogicOperator = "AND" | "OR"; -export type SortDirection = "ASC" | "DESC"; - -export const WHERE_OPERATORS: WhereOperator[] = [ - "=", - "!=", - ">", - "<", - ">=", - "<=", - "LIKE", - "IN", - "IS NULL", - "IS NOT NULL", -]; - -export const AGGREGATE_FUNCTIONS: AggregateFunction[] = [ - "COUNT", - "SUM", - "AVG", - "MAX", - "MIN", -]; - -export interface CanvasTable { - id: string; - tableName: string; - alias: string; - columns: ColumnInfo[]; - selectedColumns: string[]; - aggregates: Record; - position: { x: number; y: number }; -} - -export interface JoinConfig { - id: string; - leftTableId: string; - leftColumn: string; - rightTableId: string; - rightColumn: string; - joinType: JoinType; -} - -export interface WhereCondition { - id: string; - column: string; - operator: WhereOperator; - value: string; - logic: LogicOperator; -} - -export interface OrderByClause { - id: string; - column: string; - direction: SortDirection; -} - -export interface QueryBuilderState { - tables: CanvasTable[]; - joins: JoinConfig[]; - where: WhereCondition[]; - orderBy: OrderByClause[]; - groupBy: string[]; - having: WhereCondition[]; - limit: number | null; -} - -export const CARD_WIDTH = 220; -export const HEADER_HEIGHT = 36; -export const ROW_HEIGHT = 28; - -function escapeIdentifier(name: string): string { - return `\`${name.replace(/`/g, "``")}\``; -} - -function formatColumnRef(alias: string, column: string): string { - return `${escapeIdentifier(alias)}.${escapeIdentifier(column)}`; -} - -function formatSelectColumn( - alias: string, - column: string, - aggregate?: AggregateFunction, -): string { - const ref = formatColumnRef(alias, column); - if (aggregate) { - return `${aggregate}(${ref})`; - } - return ref; -} - -function formatCondition(cond: WhereCondition): string { - if (cond.operator === "IS NULL") { - return `${cond.column} IS NULL`; - } - if (cond.operator === "IS NOT NULL") { - return `${cond.column} IS NOT NULL`; - } - if (cond.operator === "IN") { - return `${cond.column} IN (${cond.value})`; - } - if (cond.operator === "LIKE") { - return `${cond.column} LIKE '${cond.value.replace(/'/g, "''")}'`; - } - const isNumeric = /^-?\d+(\.\d+)?$/.test(cond.value); - const val = isNumeric ? cond.value : `'${cond.value.replace(/'/g, "''")}'`; - return `${cond.column} ${cond.operator} ${val}`; -} - -function formatConditions(conditions: WhereCondition[]): string { - return conditions - .map((cond, i) => { - const condStr = formatCondition(cond); - return i === 0 ? condStr : `${cond.logic} ${condStr}`; - }) - .join("\n "); -} - -export function generateSQL(state: QueryBuilderState): string { - if (state.tables.length === 0) return ""; - - const lines: string[] = []; - - // SELECT - const selectCols: string[] = []; - for (const table of state.tables) { - for (const col of table.selectedColumns) { - const agg = table.aggregates[col]; - selectCols.push(formatSelectColumn(table.alias, col, agg)); - } - } - if (selectCols.length === 0) { - lines.push("SELECT *"); - } else { - lines.push("SELECT"); - lines.push(" " + selectCols.join(",\n ")); - } - - // FROM - const firstTable = state.tables[0]; - lines.push( - `FROM ${escapeIdentifier(firstTable.tableName)} AS ${escapeIdentifier(firstTable.alias)}`, - ); - - // JOINs - for (const join of state.joins) { - const rightTable = state.tables.find((t) => t.id === join.rightTableId); - const leftTable = state.tables.find((t) => t.id === join.leftTableId); - if (!rightTable || !leftTable) continue; - - const onClause = `${formatColumnRef(leftTable.alias, join.leftColumn)} = ${ - formatColumnRef(rightTable.alias, join.rightColumn) - }`; - lines.push( - `${join.joinType} JOIN ${escapeIdentifier(rightTable.tableName)} AS ${ - escapeIdentifier(rightTable.alias) - } ON ${onClause}`, - ); - } - - // WHERE - if (state.where.length > 0) { - lines.push("WHERE " + formatConditions(state.where)); - } - - // GROUP BY - if (state.groupBy.length > 0) { - lines.push("GROUP BY " + state.groupBy.join(", ")); - } - - // HAVING - if (state.having.length > 0 && state.groupBy.length > 0) { - lines.push("HAVING " + formatConditions(state.having)); - } - - // ORDER BY - if (state.orderBy.length > 0) { - const orderParts = state.orderBy.map((o) => `${o.column} ${o.direction}`); - lines.push("ORDER BY " + orderParts.join(", ")); - } - - // LIMIT - if (state.limit !== null && state.limit > 0) { - lines.push(`LIMIT ${state.limit}`); - } - - return lines.join("\n"); -} - -export function generateAlias( - tableName: string, - existingTables: CanvasTable[], -): string { - const existing = existingTables.filter((t) => t.tableName === tableName); - if (existing.length === 0) return tableName; - return `${tableName}_${existing.length + 1}`; -} - -export function getColumnRef(alias: string, column: string): string { - return formatColumnRef(alias, column); -} - -export function getAllColumnRefs( - tables: CanvasTable[], -): { ref: string; label: string; alias: string; column: string }[] { - const refs: { ref: string; label: string; alias: string; column: string }[] = []; - for (const table of tables) { - for (const col of table.columns) { - const ref = formatColumnRef(table.alias, col.name); - refs.push({ - ref, - label: `${table.alias}.${col.name}`, - alias: table.alias, - column: col.name, - }); - } - } - return refs; -} diff --git a/src/stores/__tests__/editorStore.test.ts b/src/stores/__tests__/editorStore.test.ts index 85725ce..97d926b 100644 --- a/src/stores/__tests__/editorStore.test.ts +++ b/src/stores/__tests__/editorStore.test.ts @@ -214,16 +214,6 @@ describe("editorStore", () => { }); }); - describe("addQueryBuilderTab", () => { - it("should add a query builder tab", () => { - const id = useEditorStore.getState().addQueryBuilderTab("conn-1", "testdb"); - const tab = useEditorStore.getState().tabs.find((t) => t.id === id); - expect(tab?.type).toBe("querybuilder"); - expect(tab?.connectionId).toBe("conn-1"); - expect(tab?.database).toBe("testdb"); - }); - }); - describe("setActiveTab", () => { it("should set active tab", () => { const id = useEditorStore.getState().addTab(); diff --git a/src/stores/editorStore.ts b/src/stores/editorStore.ts index 215ed2a..e872386 100644 --- a/src/stores/editorStore.ts +++ b/src/stores/editorStore.ts @@ -10,7 +10,6 @@ interface EditorState { addTab: (connectionId?: string, database?: string) => string; addStructureTab: (connectionId: string, database: string, tableName: string) => string; addAdminTab: (connectionId: string) => string; - addQueryBuilderTab: (connectionId: string, database: string) => string; addRoutineTab: (connectionId: string, database: string, routineName: string, routineType: string) => string; addCompareTab: () => string; addDesignerTab: (connectionId: string, database: string, tableName?: string) => string; @@ -251,36 +250,6 @@ export const useEditorStore = create((set, get) => ({ return id; }, - addQueryBuilderTab: (connectionId, database) => { - const { tabs: existingTabs } = get(); - const existing = existingTabs.find( - (t) => - t.type === "querybuilder" - && t.connectionId === connectionId - && t.database === database, - ); - if (existing) { - set({ activeTabId: existing.id }); - return existing.id; - } - tabCounter++; - const id = `tab-${tabCounter}`; - const tab: EditorTab = { - id, - title: "🔧 Query Builder", - content: "", - connectionId, - database, - type: "querybuilder", - isDirty: false, - }; - set((state) => ({ - tabs: [...state.tabs, tab], - activeTabId: id, - })); - return id; - }, - closeTab: (id) => { set((state) => { const tabToClose = state.tabs.find((t) => t.id === id); diff --git a/src/types/index.ts b/src/types/index.ts index 0303e4f..a50923b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -234,7 +234,7 @@ export interface EditorTab { tableName?: string; routineName?: string; routineType?: string; - type?: "query" | "structure" | "admin" | "compare" | "designer" | "routine" | "querybuilder"; + type?: "query" | "structure" | "admin" | "compare" | "designer" | "routine"; isDirty: boolean; } From 67f754fe7d4bb416295d3d4486ce69611857f0bd Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 19 Jul 2026 12:57:33 -0500 Subject: [PATCH 3/3] ci: add lint, type-check, clippy to pre-commit Lefthook was missing 3 checks CI also runs (npm lint, tsc, cargo clippy). Adds them with the same glob-skip pattern so docs-only commits stay fast. CI still runs them in lint-ts and lint-rust jobs; this just catches failures before push instead of after. --- lefthook.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lefthook.yml b/lefthook.yml index 7f1e735..1c0a27e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,6 +3,12 @@ pre-commit: unit-tests: glob: "*.{ts,tsx,rs}" run: npm run test:unit -- --reporter=dot + lint: + glob: "*.{ts,tsx,js,jsx}" + run: npm run lint + type-check: + glob: "*.{ts,tsx}" + run: npm run type-check dprint: glob: "*.{ts,tsx,js,jsx,json,yml,yaml,md,toml}" run: npx dprint check --list-different @@ -10,6 +16,10 @@ pre-commit: root: "src-tauri/" glob: "*.rs" run: cargo fmt --check + cargo-clippy: + root: "src-tauri/" + glob: "*.rs" + run: cargo clippy --all-targets --all-features -- -D warnings lockfile-check: glob: "package.json" run: |