From 41ecdf582b8f05c86df7ee1a8f0a2491fd1173fe Mon Sep 17 00:00:00 2001 From: Keenan Simpson Date: Fri, 5 Jun 2026 15:42:43 +0200 Subject: [PATCH 1/2] feat: PK metadata detection, save preserves original query, AI Assistant persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PK detection now queries real DB metadata from information_schema.table_constraints (PostgreSQL/MySQL) and PRAGMA table_info (SQLite) instead of relying solely on name heuristics - Unified getPrimaryKey helper used across all CRUD operations - Save no longer replaces custom query results with SELECT * FROM table — re-runs the user's original SELECT via lastSelectQueryRef - Schema-qualified table PK candidates use base table name (config_id not ansible.config_id) - Fallback UPDATE WHERE uses _original snapshot to prevent 0-row updates - Grid scrolls to saved/inserted record after save - AI Assistant: extracted shared callAI to src/lib/ai.ts, wired AIAssistantDialog to real provider (no longer setTimeout stub), added AI settings persistence to settingsStore - Roadmap: removed shipped items (vault hardening, AI assistant, schema-aware row editor, ER diagram) --- CHANGELOG.md | 7 + src/components/explorer/DatabaseExplorer.tsx | 32 +- src/components/layout/MainContent.tsx | 305 +++++++++++++------ src/components/results/ResultsPanel.tsx | 17 +- src/components/results/VisualOptimizer.tsx | 87 +----- src/components/settings/SettingsDialog.tsx | 30 +- src/components/tools/AIAssistantDialog.tsx | 57 +++- src/lib/ai.ts | 86 ++++++ src/store/aiStore.ts | 2 +- src/store/settingsStore.ts | 25 ++ website/src/content/docs/ai/byo-key.mdx | 4 +- website/src/pages/roadmap.astro | 18 -- 12 files changed, 446 insertions(+), 224 deletions(-) create mode 100644 src/lib/ai.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 022bb3e..3b3ecd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,12 +40,16 @@ All notable changes to QueryDen are documented here. This project adheres to [Se - **PostgreSQL FK relationships now render in the ER diagram.** The `schemaItems.foreignKeys` list is sometimes null when schema loading fails silently. Added direct `fetchPostgresForeignKeys()` and `fetchMySQLForeignKeys()` fallbacks that query system catalogs directly, plus a `normalizeTableName()` helper to reconcile the `public.` prefix format difference between PostgreSQL's `regclass::text` and the schema items table list. - **PostgreSQL columns in `public` schema no longer missing from the ER diagram.** Tables with many columns were showing as header-only because the column map keyed by `public.tablename` didn't match the schema items' bare `tablename` format. Stripping the `public.` prefix in the column lookup fixed it. - **ER diagram no longer freezes when opening on databases with many tables.** The table selector dialog now appears first — users pick which tables to include before any schema data is fetched or dagre layout runs. Selected-only data keeps both the query batch and the layout computation scoped to what's visible. +- **Save no longer replaces custom query results with a full table scan.** After saving edits, `handleSave` re-runs the user's original SELECT query (captured from `lastSelectQueryRef`) instead of a generic `SELECT * FROM table LIMIT 1000`. Column selection, ORDER BY, and LIMIT are preserved — the grid stays on the user's custom result set. +- **Schema-qualified table PK candidates now use the base table name.** `handleSave` and `handleUpdateRow` derive table-specific PK candidates from the unqualified table name (e.g. `config_id` for `ansible.config`) instead of the full schema-qualified name, fixing PK detection on multi-schema databases. +- **Fallback UPDATE WHERE clause uses pre-edit values.** When no PK column is found, `handleSave` reads from the `_original` row snapshot (captured at edit time) rather than the current edited values, preventing 0-row-affect UPDATEs. ### Changed - **ER diagram compact view is now the default.** The diagram opens in compact mode (PK/FK columns only) instead of showing all columns, reducing visual clutter on first open. Users can toggle back to full-column view via the toolbar button. - **PostgreSQL FK introspection now uses `pg_catalog.pg_constraint` instead of `information_schema`.** The Database Explorer and post-SELECT FK auto-loading both query `pg_constraint` directly, correctly handling cross-schema foreign keys, composite (multi-column) FKs, and environments where `information_schema` permissions are restricted. Cross-schema FK references are schema-qualified in the UI (e.g. `"billing"."invoices"`). - **FK metadata is now auto-loaded after SELECT queries.** When the results grid detects a table-backed SELECT, it fetches FK constraints from the relevant engine (PostgreSQL/Supabase/CockroachDB via `information_schema`, MySQL/MariaDB via `information_schema`, SQLite via `PRAGMA foreign_key_list`) and caches them per `schema.table` so subsequent queries on the same table are instant. FK columns then render as link cells without requiring a prior tree-expand. - **Webview default right-click context menu re-enabled.** The document-level `contextmenu` `preventDefault()` that suppressed the browser-native Reload/Inspect Element menu on Linux WebKitGTK has been removed. The app's own context menus (tab strip, Monaco editor, grid) stop propagation and remain unaffected. +- **PK detection now prefers real database metadata over name heuristics.** `loadTableDetails` queries actual PK columns from `information_schema.table_constraints` (PostgreSQL/MySQL) or `PRAGMA table_info` (SQLite). The post-SELECT FK auto-load path also detects PKs in parallel. All CRUD operations use a unified `getPrimaryKey` helper that checks `tableSchema.primaryKeys` first, then falls back to expanded name heuristics (`id`, `uuid`, `uid`, `pk`, `row_id`, `object_id`, `key`, `code`, `_id`, `{table}_id`). ### Added - **FK-aware inline navigation in the results grid.** FK columns render as styled link cells with an external-link icon. Clicking the icon fires a `SELECT * FROM refTable WHERE refCol = value` query, navigating directly to the referenced row — enabling quick parent-row lookup from any query result. @@ -63,7 +67,10 @@ All notable changes to QueryDen are documented here. This project adheres to [Se - **PostgreSQL operators enabled by default in schema tree.** The `showOperators` setting now defaults to `true`, so operator nodes appear in the schema tree without having to toggle the setting first. - **ER Diagram visualization for PostgreSQL, MySQL/MariaDB, and SQLite.** A new ER Diagram dialog (toolbar button) renders the current database as an interactive entity-relationship diagram powered by `@xyflow/react` v12 and `dagre` auto-layout. Tables are shown as cards with PK (key icon) and FK (link icon) column markers; relationships are drawn as smooth-step edges with cardinality labels (`*` at the FK end, `1` at the PK end). The dialog opens with a table selector so users pick which tables to include — preventing the freeze that would occur when auto-introspecting many tables. A toolbar provides schema multi-select (PostgreSQL), search/filter with related-table expansion, compact/PK-only column mode, refresh, and SVG export (Tauri `save()` dialog + `writeTextFile`). Schema data is cached with a 30-second TTL so re-opening the same tables is instant. Provider-specific introspection: PostgreSQL queries `pg_attribute`/`pg_index`/`pg_constraint` in bulk, MySQL uses `information_schema`, SQLite uses batched `PRAGMA table_info`/`PRAGMA foreign_key_list` via `Promise.all`. - **ER Diagram now opens on the correct database.** Previously the diagram always read the global `activeConnection` — if the active editor tab targeted a different database, the diagram showed the wrong schema. The toolbar button now switches the connection to match the tab's target before opening. +- **Grid scrolls to the saved record after save.** After saving changes (via `handleSave`), the grid dispatches `grid-scroll-to-row` to scroll to the modified row matched by PK value, or `grid-scroll-to-bottom` for newly inserted rows — keeping the user's viewport focused on the record they just edited. +### Removed +- **Roadmap items removed as addressed.** "Vault crypto hardening" (#14, #15), "Schema-aware row editor" (#50), and "ER Diagram" (#51) removed from the website roadmap — vault hardening, schema-aware FK dropdowns, NOT NULL validation, and ER diagram support in this release subsume those feature requests. ## [1.0.23] - 2026-05-26 diff --git a/src/components/explorer/DatabaseExplorer.tsx b/src/components/explorer/DatabaseExplorer.tsx index 6cfd535..5024986 100644 --- a/src/components/explorer/DatabaseExplorer.tsx +++ b/src/components/explorer/DatabaseExplorer.tsx @@ -38,6 +38,7 @@ interface TableDetails { foreignKeys: { columns: string[]; refTable: string; refColumns: string[] }[]; indexes: { name: string; columns: string[]; unique: boolean }[]; triggers: string[]; + primaryKeys: string[]; } interface DatabaseExplorerProps { @@ -590,7 +591,7 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database setLoadingTableDetails(prev => new Set(prev).add(key)); try { - const details: TableDetails = { columns: [], constraints: [], foreignKeys: [], indexes: [], triggers: [] }; + const details: TableDetails = { columns: [], constraints: [], foreignKeys: [], indexes: [], triggers: [], primaryKeys: [] }; if (["postgres", "supabase", "cockroach"].includes(activeConnection.type)) { // Load columns @@ -607,6 +608,20 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database default: c.column_default })); + // Load primary keys + const pks = await currentDb.select(` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = $1 + AND tc.table_name = $2 + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position + `, [schemaName, tableName]); + details.primaryKeys = pks.map((p: any) => p.column_name); + // Load indexes const idxs = await currentDb.select(` SELECT indexname, indexdef @@ -688,6 +703,16 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database default: c.column_default, })); + const pks = await currentDb.select(` + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = ? + AND constraint_name = 'PRIMARY' + ORDER BY ordinal_position + `, [tableName]); + details.primaryKeys = pks.map((p: any) => p.column_name); + const idxs = await currentDb.select(`SHOW INDEX FROM ${quoteIdentifier(tableName, activeConnection.type)}`); const idxMap: Record = {}; for (const idx of idxs) { @@ -747,6 +772,11 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database nullable: c.notnull === 0, default: c.dflt_value, })); + // PRAGMA table_info returns `pk` as the 1-based PK ordinal (0 = not PK) + details.primaryKeys = cols + .filter((c: any) => c.pk > 0) + .sort((a: any, b: any) => a.pk - b.pk) + .map((c: any) => c.name); const idxs = await currentDb.select(`PRAGMA index_list(${quotedTable})`); for (const idx of idxs) { diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 517ab2a..93234ce 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -163,6 +163,7 @@ export function MainContent() { const cancelFlagRef = useRef(false); const isExecutingRef = useRef(false); const runningCmdRef = useRef(""); + const executionGenRef = useRef(0); // Ref for latest activeTab to avoid stale closures in executeQuery const activeTabRef = useRef(undefined); const activeTabIdRef = useRef(undefined); @@ -193,6 +194,7 @@ export function MainContent() { const [tableSchema, setTableSchema] = useState<{ columns: { name: string; type: string; nullable: boolean; default: string | null }[]; foreignKeys: { columns: string[]; refTable: string; refColumns: string[] }[]; + primaryKeys?: string[]; } | undefined>(undefined); // Suppresses auto-tab-switching to messages when a save/delete refresh is in progress const [suppressTabSwitch, setSuppressTabSwitch] = useState(false); @@ -799,6 +801,7 @@ const extractSelectedOrCursorStatement = (fullText: string): string => { }; const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { lineNumber: number; statementText: string }) => { + const gen = ++executionGenRef.current; // Use refs for latest values to avoid stale closures const currentTab = activeTabRef.current; const currentTabId = activeTabIdRef.current; @@ -1766,7 +1769,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l if (intervalId) clearInterval(intervalId); intervalId = null; } - if (cancelFlagRef.current) return; + if (cancelFlagRef.current || gen !== executionGenRef.current) return; const duration = Date.now() - startTime; @@ -1780,6 +1783,12 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } else { setLastColumns([]); } + // Keep lastSelectQueryRef in sync with the last data-returning query so + // the toolbar refresh button and discard-revert always re-run the correct + // SELECT (not a stale query from a previous table click). + if (queryToRun) { + lastSelectQueryRef.current = queryToRun; + } } // Load FK metadata for the detected table so FK columns render as @@ -1818,55 +1827,84 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l (async () => { try { let fks: any[] = []; + let pks: string[] = []; if (["postgres", "supabase", "cockroach"].includes(dbType || "")) { - fks = await db.select(` - SELECT kcu.column_name, tc.constraint_name, - ccu.table_schema AS foreign_table_schema, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1 AND tc.table_name = $2 - `, [schemaName, tableName]); + [fks, pks] = await Promise.all([ + db.select(` + SELECT kcu.column_name, tc.constraint_name, + ccu.table_schema AS foreign_table_schema, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 AND tc.table_name = $2 + `, [schemaName, tableName]), + db.select(` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = $1 + AND tc.table_name = $2 + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position + `, [schemaName, tableName]), + ]); + pks = (pks as any[]).map(p => p.column_name); } else if (["mysql", "mariadb"].includes(dbType || "")) { - fks = await db.select(` - SELECT kcu.column_name, kcu.referenced_table_name, kcu.referenced_column_name - FROM information_schema.key_column_usage kcu - JOIN information_schema.table_constraints tc - ON tc.constraint_name = kcu.constraint_name AND tc.constraint_schema = kcu.constraint_schema - WHERE tc.constraint_type = 'FOREIGN KEY' AND kcu.table_schema = DATABASE() AND kcu.table_name = ? - `, [tableName]); + [fks, pks] = await Promise.all([ + db.select(` + SELECT kcu.column_name, kcu.referenced_table_name, kcu.referenced_column_name + FROM information_schema.key_column_usage kcu + JOIN information_schema.table_constraints tc + ON tc.constraint_name = kcu.constraint_name AND tc.constraint_schema = kcu.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' AND kcu.table_schema = DATABASE() AND kcu.table_name = ? + `, [tableName]), + db.select(` + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = ? + AND constraint_name = 'PRIMARY' + ORDER BY ordinal_position + `, [tableName]), + ]); + pks = (pks as any[]).map(p => p.column_name); } else if (dbType === "sqlite") { const quoted = quoteIdentifier(tableName, dbType as any); - fks = await db.select(`PRAGMA foreign_key_list(${quoted})`); + const [fkResults, pragmaInfo] = await Promise.all([ + db.select(`PRAGMA foreign_key_list(${quoted})`) as Promise, + db.select(`PRAGMA table_info(${quoted})`) as Promise, + ]); + fks = fkResults; + pks = pragmaInfo + .filter(c => c.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map(c => c.name); } - if (fks && fks.length > 0) { - const fkMap: Record = {}; - for (const fk of fks) { - const colName = fk.column_name || fk.from; - const refTbl = fk.foreign_table_name || fk.table; - const refCol = fk.foreign_column_name || fk.to; - const conName = fk.constraint_name || `${colName}_${refTbl}`; - if (!fkMap[conName]) { - const refTable = (fk.foreign_table_schema && fk.foreign_table_schema !== 'public' && fk.foreign_table_schema !== schemaName) - ? `${fk.foreign_table_schema}.${refTbl}` - : refTbl; - fkMap[conName] = { columns: [], refTable, refColumns: [] }; - } - fkMap[conName].columns.push(colName); - fkMap[conName].refColumns.push(refCol); + const fkMap: Record = {}; + for (const fk of (fks || [])) { + const colName = fk.column_name || fk.from; + const refTbl = fk.foreign_table_name || fk.table; + const refCol = fk.foreign_column_name || fk.to; + const conName = fk.constraint_name || `${colName}_${refTbl}`; + if (!fkMap[conName]) { + const refTable = (fk.foreign_table_schema && fk.foreign_table_schema !== 'public' && fk.foreign_table_schema !== schemaName) + ? `${fk.foreign_table_schema}.${refTbl}` + : refTbl; + fkMap[conName] = { columns: [], refTable, refColumns: [] }; } - const fkSchema = { columns: [], foreignKeys: Object.values(fkMap) }; - fkCacheRef.current.set(cacheKey, fkSchema); - setTableSchema(fkSchema); - } else { - fkCacheRef.current.set(cacheKey, { columns: [], foreignKeys: [] }); - setTableSchema(undefined); + fkMap[conName].columns.push(colName); + fkMap[conName].refColumns.push(refCol); } + const schemaData = { columns: [], foreignKeys: Object.values(fkMap), primaryKeys: pks }; + fkCacheRef.current.set(cacheKey, schemaData); + setTableSchema(schemaData); } catch { setTableSchema(undefined); } @@ -1908,7 +1946,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l }); } catch (err: any) { if (intervalId) clearInterval(intervalId); - if (cancelFlagRef.current) return; + if (cancelFlagRef.current || gen !== executionGenRef.current) return; const duration = Date.now() - startTime; let errorMsg = typeof err === 'string' ? err : err?.message || JSON.stringify(err) || "Failed to execute query"; @@ -1998,9 +2036,10 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l }); } finally { if (intervalId) clearInterval(intervalId); - if (!cancelFlagRef.current) { + cancelFlagRef.current = false; + if (gen === executionGenRef.current) { setIsExecuting(false); - isExecutingRef.current = false; + isExecutingRef.current = false; } } }, [activeConnection, selectedDatabase, addQuery, currentDb, vaultCredentials, settings, confirmDialog]); @@ -2156,7 +2195,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l setTableColumnTypes(undefined); } if (detail.tableSchema) { - setTableSchema({ columns: detail.tableSchema.columns, foreignKeys: detail.tableSchema.foreignKeys || [] }); + setTableSchema({ columns: detail.tableSchema.columns, foreignKeys: detail.tableSchema.foreignKeys || [], primaryKeys: detail.tableSchema.primaryKeys || [] }); } else { setTableSchema(undefined); } @@ -2323,6 +2362,17 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l return `'${String(val).replace(/'/g, "''")}'`; }; + const getPrimaryKey = useCallback((columns: string[]): string | undefined => { + if (tableSchema?.primaryKeys && tableSchema.primaryKeys.length > 0) { + const found = tableSchema.primaryKeys.find(pk => columns.includes(pk)); + if (found) return found; + } + if (!activeTableName) return undefined; + const baseTableName = activeTableName.includes('.') ? activeTableName.split('.').pop()! : activeTableName; + const candidates = new Set(["id", "uuid", "uid", "pk", "row_id", "object_id", "key", "code", "_id", `${baseTableName.toLowerCase()}_id`]); + return columns.find(c => candidates.has(c.toLowerCase())); + }, [tableSchema, activeTableName]); + const handleUpdateRow = useCallback(async (oldRow: any, newRow: any) => { if (!activeConnection) return; if (!activeTableName) { @@ -2330,9 +2380,10 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l return; } + // Extract base table name for PK detection — schema.prefix.table_name should + // produce `table_name_id`, not `schema.prefix.table_name_id`. const columns = Object.keys(oldRow); - const pkCandidates = ["id", "uuid", "uid", `${activeTableName.toLowerCase()}_id`]; - const pk = columns.find(c => pkCandidates.includes(c.toLowerCase())); + const pk = getPrimaryKey(columns); const setClauses: string[] = []; const whereClauses: string[] = []; @@ -2375,8 +2426,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l // Update local state optimistically setResults(prev => prev.map(row => { const columns = Object.keys(oldRow); - const pkCandidates = ["id", "uuid", "uid", `${activeTableName.toLowerCase()}_id`]; - const pk = columns.find(c => pkCandidates.includes(c.toLowerCase())); + const pk = getPrimaryKey(columns); let isMatch = false; if (pk && oldRow[pk] !== undefined && oldRow[pk] !== null) { @@ -2402,8 +2452,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const { _isNew, _isModified, ...cleanRow } = row; const columns = Object.keys(cleanRow); - const pkCandidates = ["id", "uuid", "uid"]; - const pk = columns.find(c => pkCandidates.includes(c.toLowerCase())); + const pk = getPrimaryKey(columns); const whereClauses: string[] = []; @@ -2423,7 +2472,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l setSuppressTabSwitch(true); await executeQuery(deleteQuery); setResults(prev => prev.filter(r => { - const pkItem = columns.find(c => pkCandidates.includes(c.toLowerCase())); + const pkItem = getPrimaryKey(columns); if (pkItem && cleanRow[pkItem] !== undefined && cleanRow[pkItem] !== null) { return String(r[pkItem]) !== String(cleanRow[pkItem]); } @@ -2439,6 +2488,11 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const newRows = currentResults.filter(r => r._isNew); const modifiedRows = currentResults.filter(r => r._isModified && !r._isNew); + // Capture the user's original SELECT query before any writes so we can + // re-run it after save rather than replacing results with a generic + // `SELECT * FROM table`. This preserves the user's column selection, + // ORDER BY, LIMIT, and table aliases. + const originalQuery = lastSelectQueryRef.current; if (newRows.length === 0 && modifiedRows.length === 0) { setSuccess("No pending changes to save."); @@ -2577,11 +2631,14 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l // Find original row for WHERE clause (to prevent overwriting if no PK) // In a real app we'd need more robust change tracking, but this works for buffered edits - const columns = Object.keys(data).filter(c => c !== '_isModified' && c !== '_isNew'); + const columns = Object.keys(data).filter(c => c !== '_isModified' && c !== '_isNew' && c !== '_original'); + // When no PK is found the fallback WHERE clause must use the pre-edit + // values (`_original`) so the UPDATE actually matches the DB row. + // Without this, a clause like `WHERE "name" = 'new_value'` would affect + // 0 rows because the DB still has `'old_value'`. + const originalData = (data as any)._original || data; - // Identical logic to handleUpdateRow but without the confirm dialog per row - const pkCandidates = ["id", "uuid", "uid", `${activeTableName.toLowerCase()}_id`]; - const pk = columns.find(c => pkCandidates.includes(c.toLowerCase())); + const pk = getPrimaryKey(columns); const setClauses: string[] = []; const whereClauses: string[] = []; @@ -2594,7 +2651,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l whereClauses.push(`${qid(pk)} = ${formatSqlValue(data[pk])}`); } else { for (const col of columns) { - const val = data[col]; + const val = originalData[col]; if (val === null) whereClauses.push(`${qid(col)} IS NULL`); else whereClauses.push(`${qid(col)} = ${formatSqlValue(val)}`); } @@ -2609,7 +2666,44 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l // ─── Step 6: Refresh to get server-side IDs etc. ─── setSuccess(`Successfully saved ${newRows.length} new and ${modifiedRows.length} modified records.`); - await executeQuery(lastSelectQueryRef.current); + // Use the save db connection directly rather than going through + // executeQuery, which may target a different connection/database + // (stale lastSelectQueryRef) or have stale state. This ensures the + // refresh reads from the same database that received the writes. + // Prefer the user's original SELECT query so column selection, ORDER BY, + // and LIMIT are preserved. Fall back to a full table scan if unavailable. + let refreshedRows: any[]; + try { + if (originalQuery) { + refreshedRows = await db.select(originalQuery) as any[]; + } else { + throw new Error("no original query"); + } + } catch { + const fallbackQuery = `SELECT * FROM ${qid(activeTableName)} LIMIT 1000`; + refreshedRows = await db.select(fallbackQuery) as any[]; + } + setResults(refreshedRows); + if (refreshedRows.length > 0) { + setLastColumns(Object.keys(refreshedRows[0])); + } + // Stay on the saved record — scroll to the first modified/new row + // so the user doesn't have to hunt for it after the refresh. + setTimeout(() => { + const firstModified = modifiedRows.length > 0 ? modifiedRows[0] : null; + if (firstModified) { + const pk = getPrimaryKey(Object.keys(firstModified)); + const pkVal = pk ? firstModified[pk] : undefined; + if (pk && pkVal !== undefined) { + const newIdx = refreshedRows.findIndex((r: any) => String(r[pk]) === String(pkVal)); + if (newIdx >= 0) { + window.dispatchEvent(new CustomEvent("grid-scroll-to-row", { detail: { index: newIdx } })); + } + } + } else if (newRows.length > 0) { + window.dispatchEvent(new CustomEvent("grid-scroll-to-bottom")); + } + }, 50); } catch (err: any) { await confirmDialog.dialog({ title: "Failed to Save", @@ -2646,55 +2740,76 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } if (!activeTableName || !activeConnection) return; - - const columns = Object.keys(newRow).filter(c => newRow[c] !== null); - if (columns.length === 0) { - // Just insert default values - try { - setSuppressTabSwitch(true); - let sql = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; - if (activeConnection.type === "mysql") { - sql = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; - } - await executeQuery(sql); - if (lastSelectQueryRef.current) { - await executeQuery(lastSelectQueryRef.current); - } - } catch (err) { - confirmDialog.dialog({ - title: "Add Row Failed", - message: "Could not add a default row. This usually happens if the table has columns that are NOT NULL and have no default value.\n\nError: " + (err as any).message, - type: "danger" - }); - } finally { - setSuppressTabSwitch(false); - } - return; - } + // Persist directly via the same pattern as handleSave: create a dedicated + // db connection, run INSERT via db.execute(), then SELECT-refresh on the + // same connection. This avoids the stale-lastSelectQueryRef and + // setResults-not-called-for-non-SELECT problems that plagued the old path. + let db: any; try { setSuppressTabSwitch(true); - if (Object.keys(newRow).length === 0) { - // Insert a default blank row - let sql = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; + + // ── Build a save-scoped connection (mirrors handleSave Step 1) ────────── + const activeTab = queryTabs.find(t => t.id === activeTabId); + const targetConn = activeTab?.target; + const saveConn = targetConn + ? connections.find(c => c.id === targetConn.connectionId) + : activeConnection; + const saveDbName = targetConn?.database || selectedDatabase || activeConnection.database; + const conn = saveConn || activeConnection; + let username = conn.username || ""; + let password = conn.password || ""; + if (conn.vaultCredentialId) { + const vaultCred = vaultCredentials.find(vc => vc.id === conn.vaultCredentialId); + if (vaultCred) { username = vaultCred.username || ""; password = vaultCred.password || ""; } + } + const encodedUser = encodeURIComponent(username); + const encodedPass = encodeURIComponent(password); + const port = conn.port || (conn.type === "mysql" || conn.type === "mariadb" ? 3306 : 5432); + const Database = (await import("@tauri-apps/plugin-sql")).default; + let connectionString = ""; + if (conn.type === "sqlite") { + connectionString = `sqlite:${conn.filepath || "queryden.db"}`; + } else if (["postgres", "supabase", "cockroach"].includes(conn.type)) { + connectionString = `postgres://${encodedUser}:${encodedPass}@${conn.host}:${port}/${saveDbName}`; + } else { + connectionString = `mysql://${encodedUser}:${encodedPass}@${conn.host}:${port}/${saveDbName}`; + } + db = await Database.load(connectionString); + + // ── Build and run INSERT ─────────────────────────────────────────────── + const columns = Object.keys(newRow).filter(c => newRow[c] !== null); + let query = ""; + if (columns.length === 0) { + query = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; if (activeConnection.type === "mysql" || activeConnection.type === "mariadb") { - sql = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; + query = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; } - await executeQuery(sql); - await executeQuery(lastSelectQueryRef.current); } else { const cols = columns.map(c => qid(c)).join(", "); const vals = columns.map(c => formatSqlValue(newRow[c])).join(", "); - const query = `INSERT INTO ${qid(activeTableName)} (${cols}) VALUES (${vals})`; - await executeQuery(query); - await executeQuery(lastSelectQueryRef.current); + query = `INSERT INTO ${qid(activeTableName)} (${cols}) VALUES (${vals})`; + } + await db.execute(query); + + // ── Refresh results on the same connection ───────────────────────────── + const selectQuery = `SELECT * FROM ${qid(activeTableName)} LIMIT 1000`; + const refreshedRows = await db.select(selectQuery) as any[]; + setResults(refreshedRows); + if (refreshedRows.length > 0) { + setLastColumns(Object.keys(refreshedRows[0])); } + // Scroll to the last row (the one we just inserted) so the user + // doesn't have to hunt for it after the grid re-renders. + setTimeout(() => { + window.dispatchEvent(new CustomEvent("grid-scroll-to-bottom")); + }, 50); } catch (err) { throw err; } finally { setSuppressTabSwitch(false); } - }, [activeTableName, activeConnection, executeQuery, confirmDialog, lastColumns, results]); + }, [activeTableName, activeConnection, executeQuery, confirmDialog, lastColumns, results, vaultCredentials, connections, selectedDatabase, queryTabs, activeTabId]); const handleFkCellClick = useCallback((fk: { refTable: string; refColumns: string[] }, fkValue: any) => { if (!currentDb || !activeConnection) return; diff --git a/src/components/results/ResultsPanel.tsx b/src/components/results/ResultsPanel.tsx index bade8cb..f0ee993 100644 --- a/src/components/results/ResultsPanel.tsx +++ b/src/components/results/ResultsPanel.tsx @@ -236,13 +236,23 @@ type ResultsTab = "messages" | "result" | "history" | "optimizer"; }, 100); } }; + const handleScrollToRow = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (gridRef.current && detail?.index !== undefined) { + setTimeout(() => { + gridRef.current?.scrollToRow(detail.index); + }, 100); + } + }; window.addEventListener("click", handleClick); window.addEventListener("switch-results-tab", handleSwitchTab); window.addEventListener("grid-scroll-to-bottom", handleScrollBottom); + window.addEventListener("grid-scroll-to-row", handleScrollToRow); return () => { window.removeEventListener("click", handleClick); window.removeEventListener("switch-results-tab", handleSwitchTab); window.removeEventListener("grid-scroll-to-bottom", handleScrollBottom); + window.removeEventListener("grid-scroll-to-row", handleScrollToRow); }; }, []); @@ -365,7 +375,10 @@ type ResultsTab = "messages" | "result" | "history" | "optimizer"; newResults[sourceIdx] = { ...oldRow, [context.col]: newValue, - _isModified: !oldRow._isNew // Only mark as modified if it's not a brand new row + _isModified: !oldRow._isNew, // Only mark as modified if it's not a brand new row + // Snapshot the pre-edit values so handleSave can build a WHERE clause + // that matches the original row, preventing 0-row-affect updates. + _original: oldRow._isNew ? undefined : (oldRow._original || oldRow) }; onResultsChange(newResults); @@ -1160,7 +1173,7 @@ type ResultsTab = "messages" | "result" | "history" | "optimizer"; setShowAddRowModal(false)} - onSave={onAddRow} + onSave={(row) => onAddRow!(row, false)} columns={tableSchema?.columns || columns.map((name: string) => ({ name, type: "text", nullable: true, default: null }))} foreignKeys={tableSchema?.foreignKeys} loadFKOptions={loadFKOptions} diff --git a/src/components/results/VisualOptimizer.tsx b/src/components/results/VisualOptimizer.tsx index 504c84c..b40f6d7 100644 --- a/src/components/results/VisualOptimizer.tsx +++ b/src/components/results/VisualOptimizer.tsx @@ -7,6 +7,7 @@ import { BarChart3, Target, Shield, Layers, ArrowRight, Sparkles, XCircle } from "lucide-react"; import { useAI } from "../../store/aiStore"; +import { callAI } from "../../lib/ai"; import { logger } from "../../utils/logger"; interface PlanNode { @@ -567,90 +568,6 @@ export function VisualOptimizer({ data, onApplyFix }: VisualOptimizerProps) { setTimeout(() => setCopiedIdx(null), 2000); }, []); - const callAI = useCallback(async (systemPrompt: string, userPrompt: string): Promise => { - if (!ai.enabled || !ai.apiKey) { - throw new Error("AI is not configured"); - } - - let endpoint = ""; - let body: Record = {}; - let headers: Record = {}; - - if (ai.provider === "openai") { - endpoint = ai.endpoint || "https://api.openai.com/v1/chat/completions"; - headers = { - "Content-Type": "application/json", - "Authorization": `Bearer ${ai.apiKey}`, - }; - body = { - model: ai.model || "gpt-4o", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - max_tokens: 2000, - temperature: 0.3, - }; - } else if (ai.provider === "anthropic") { - endpoint = ai.endpoint || "https://api.anthropic.com/v1/messages"; - headers = { - "Content-Type": "application/json", - "x-api-key": ai.apiKey, - "anthropic-version": "2023-06-01", - "anthropic-dangerous-direct-browser-access": "true", - }; - body = { - model: ai.model || "claude-3-5-sonnet-20241014", - messages: [{ role: "user", content: userPrompt }], - system: systemPrompt, - max_tokens: 2000, - }; - } else if (ai.provider === "google") { - endpoint = ai.endpoint || `https://generativelanguage.googleapis.com/v1beta/models/${ai.model || "gemini-1.5-flash"}:generateContent?key=${ai.apiKey}`; - headers = { "Content-Type": "application/json" }; - body = { - contents: [{ parts: [{ text: `System: ${systemPrompt}\n\nUser: ${userPrompt}` }] }], - generationConfig: { maxOutputTokens: 2000, temperature: 0.3 }, - }; - } else if (ai.provider === "local") { - endpoint = ai.endpoint || "http://localhost:11434/api/chat"; - body = { - model: ai.model || "llama3", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - stream: false, - }; - } - - const response = await fetch(endpoint, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const err = await response.text(); - throw new Error(`AI API error (${response.status}): ${err}`); - } - - const result = await response.json(); - - // Extract response text from provider-specific response shapes - if (ai.provider === "openai") { - return result.choices?.[0]?.message?.content || ""; - } else if (ai.provider === "anthropic") { - return result.content?.[0]?.text || ""; - } else if (ai.provider === "google") { - return result.candidates?.[0]?.content?.parts?.[0]?.text || ""; - } else if (ai.provider === "local") { - return result.message?.content || ""; - } - return ""; - }, [ai]); - const handleAIExplain = useCallback(async () => { if (!canUseAI) return; setIsAIExplaining(true); @@ -676,7 +593,7 @@ Provide your analysis in plain English with specific SQL fix suggestions.`; } finally { setIsAIExplaining(false); } - }, [canUseAI, rawData, callAI]); + }, [canUseAI, rawData]); const handleApplyFix = useCallback((sql: string, idx: number) => { if (onApplyFix) { diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index 41068b4..bd8fbf6 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -844,6 +844,16 @@ function ToggleOption({ label, description, checked, onChange }: { function AISettings() { const ai = useAI(); + const settings = useSettings(); + + const persistAI = (patch: Partial) => { + for (const [key, value] of Object.entries(patch)) { + const settingsKey = `ai${key.charAt(0).toUpperCase()}${key.slice(1)}` as keyof typeof settings; + if (settingsKey in settings) { + settings.setSetting(settingsKey, value as any); + } + } + }; return (
@@ -859,7 +869,10 @@ function AISettings() { label="Enable AI Assistant" description="Enable AI-powered features in the SQL editor" checked={ai.enabled} - onChange={(checked) => ai.setEnabled(checked)} + onChange={(checked) => { + ai.setEnabled(checked); + persistAI({ enabled: checked }); + }} />
@@ -874,7 +887,10 @@ function AISettings() { ].map((p) => (
@@ -907,7 +926,10 @@ function AISettings() {