diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts index b625541f..1b0be058 100644 --- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -140,8 +140,8 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { expect(anyDiff).toBeGreaterThanOrEqual(modified); const results = await sql.resultsText(); - expect(results).toMatch(/baseline|source/i); - expect(results).toMatch(/differ|match/i); + expect(results).toMatch(/original|baseline|source/i); + expect(results).toMatch(/aligned by|edit|match/i); // Data migrate bar: Add / Edit / Delete (user opts in; none checked by default). await driver.waitForSelector('[data-testid="sql-data-migrate-bar-0"]', { diff --git a/apps/web/src/frontend/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/components/ObjectDetailPanel.tsx index 020ee584..ef0c5569 100644 --- a/apps/web/src/frontend/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/components/ObjectDetailPanel.tsx @@ -775,7 +775,7 @@ export const ObjectDetailPanel: React.FC = () => { Attribute - Source + Original Server Compare Target @@ -813,7 +813,7 @@ export const ObjectDetailPanel: React.FC = () => { Attribute - Source Type + Original Type Compare Target Type @@ -867,7 +867,7 @@ export const ObjectDetailPanel: React.FC = () => { {selectedTable.objectType === 'ROLE' ? 'Member' : 'Column Name'} - Source State + Original State Compare Target State Operation @@ -972,7 +972,7 @@ export const ObjectDetailPanel: React.FC = () => { Constraint Name - Source Columns + Original Columns Compare Target Columns Operation @@ -1150,7 +1150,7 @@ export const ObjectDetailPanel: React.FC = () => { Trigger Name - Source State + Original State Compare Target State Operation diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index df2682c2..50eebcf7 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -186,8 +186,11 @@ export const TopToolbar: React.FC = () => { <> {/* Database Connection Control Grid */}
- {/* Source Configuration */} + {/* Source Configuration — left side is the Original Server (read / compare from). */}
+
+ Original Server +
{/* Label + Add/Edit Connection + status, all inline */}
{connections.length > 0 && ( @@ -269,22 +272,29 @@ export const TopToolbar: React.FC = () => {
- {/* Direction / Swap control — migration always flows Source → Target */} + {/* Direction / Swap control — migration always flows Original Server → Target */}
{/* Target Configuration */}
+
+ Target +
{/* Label + Add/Edit Connection + status, all inline */}
{connections.length > 0 && ( @@ -445,7 +455,7 @@ export const TopToolbar: React.FC = () => {
{sameConfig && ( - Source and target are the same + Original Server and Target are the same )} @@ -464,7 +474,7 @@ export const TopToolbar: React.FC = () => { !canSchemaCompare ? 'Your role cannot compare schemas' : sameConfig - ? 'Source and target point to the same database and schema' + ? 'Original Server and Target point to the same database and schema' : undefined } className={`flex items-center gap-2 px-5 py-2 rounded-lg text-base font-bold transition shadow-lg ${ diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx index 9a10ca54..ab6c068c 100644 --- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx @@ -57,6 +57,9 @@ interface Props { dest: DataMigrateGrid; /** Trigger/audit columns excluded from UPDATE detection and INSERT/UPDATE SET. */ ignoreColumns?: string[]; + /** Controlled key columns (shared with Compare alignment). */ + keyNames?: string[]; + onKeyNamesChange?: (names: string[]) => void; onAfterMigrate?: () => void; onOpenServerBeamSample?: () => void; } @@ -66,6 +69,8 @@ export const DataMigrateBar: React.FC = ({ source, dest, ignoreColumns = [], + keyNames: keyNamesProp, + onKeyNamesChange, onAfterMigrate, onOpenServerBeamSample, }) => { @@ -91,10 +96,13 @@ export const DataMigrateBar: React.FC = ({ [table, source.columns] ); - const [keyNames, setKeyNames] = useState([]); + const [keyNamesLocal, setKeyNamesLocal] = useState([]); + const keyNames = keyNamesProp ?? keyNamesLocal; + const setKeyNames = onKeyNamesChange ?? setKeyNamesLocal; useEffect(() => { - setKeyNames(defaultKeys.length ? defaultKeys : source.columns.slice(0, 1)); - }, [defaultKeys.join('\0'), source.columns.join('\0')]); + if (keyNamesProp) return; + setKeyNamesLocal(defaultKeys.length ? defaultKeys : source.columns.slice(0, 1)); + }, [defaultKeys.join('\0'), source.columns.join('\0'), keyNamesProp]); /** User opts into each op — nothing selected until they choose. */ const [doInsert, setDoInsert] = useState(false); @@ -139,11 +147,10 @@ export const DataMigrateBar: React.FC = ({ ); const toggleKey = (name: string) => { - setKeyNames((prev) => - prev.some((k) => k.toLowerCase() === name.toLowerCase()) - ? prev.filter((k) => k.toLowerCase() !== name.toLowerCase()) - : [...prev, name] - ); + const next = keyNames.some((k) => k.toLowerCase() === name.toLowerCase()) + ? keyNames.filter((k) => k.toLowerCase() !== name.toLowerCase()) + : [...keyNames, name]; + setKeyNames(next); }; const openHistory = async () => { diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 5a1b9b9e..f86da904 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -25,7 +25,12 @@ import { type CellDiffKind, type GridDiffSummary, } from '../../lib/resultDataDiff'; +import { + alignResultGridsByKey, + compareKeyAlignedGrids, +} from '../../lib/resultKeyAlign'; import { detectTriggerManagedColumns } from '../../lib/triggerManagedColumns'; +import { resolvePeekKeyColumns } from '../../lib/rowDml'; import { buildSampleBookmarks } from '../../lib/sqlEditorSamples'; import { DataMigrateBar } from './DataMigrateBar'; import { usePeekGridCrud } from './usePeekGridCrud'; @@ -117,8 +122,10 @@ const ResultGridPane: React.FC<{ onSyncScrollRow?: (row: number | null) => void; /** Cross-connection compare highlights for this grid. */ diffSummary?: GridDiffSummary | null; - /** Suffix shown after the grid label (e.g. baseline / N differ). */ + /** Suffix shown after the grid label (e.g. original / N differ). */ compareBadge?: string | null; + /** Key-aligned compare remaps rows — disable inline CRUD to avoid wrong targets. */ + compareLocked?: boolean; }> = ({ item, refreshing, @@ -129,6 +136,7 @@ const ResultGridPane: React.FC<{ onSyncScrollRow, diffSummary = null, compareBadge = null, + compareLocked = false, }) => { const schemaCache = useSqlEditorStore((s) => s.schemaCache); const openDataPeekFromFk = useSqlEditorStore((s) => s.openDataPeekFromFk); @@ -211,7 +219,8 @@ const ResultGridPane: React.FC<{ columns, rows, // Match Data Peek: resultOk is about the grid, not whether schema resolved yet. - resultOk: Boolean(item.result.ok), + // Key-aligned compare pads/reorders rows — keep CRUD off until Compare is off. + resultOk: Boolean(item.result.ok) && !compareLocked, sessionKey: `${item.connectionId}:${item.statementIndex}:${pageIndex}`, resultEpoch: item.result, onAfterWrite: afterWrite, @@ -219,9 +228,11 @@ const ResultGridPane: React.FC<{ }); const readOnlyReason = - !crud.showCrud && item.result.ok - ? (!editTarget.ok ? editTarget.reason : crud.editability.reason) - : undefined; + compareLocked && item.result.ok + ? 'Turn off Compare data to edit rows' + : !crud.showCrud && item.result.ok + ? (!editTarget.ok ? editTarget.reason : crud.editability.reason) + : undefined; const toolbarExtra = ( <> @@ -313,6 +324,7 @@ const PaneBody: React.FC<{ onSyncScrollRow?: (row: number | null) => void; diffSummary?: GridDiffSummary | null; compareBadge?: string | null; + compareLocked?: boolean; }> = ({ item, refreshing, @@ -323,6 +335,7 @@ const PaneBody: React.FC<{ onSyncScrollRow, diffSummary = null, compareBadge = null, + compareLocked = false, }) => { if (item.kind === 'grid') { return ( @@ -336,6 +349,7 @@ const PaneBody: React.FC<{ onSyncScrollRow={onSyncScrollRow} diffSummary={diffSummary} compareBadge={compareBadge} + compareLocked={compareLocked} /> ); } @@ -394,8 +408,10 @@ const ResizablePaneRow: React.FC<{ pageState?: Props['pageState']; /** Per connectionId: cell diff summary when Compare is on. */ diffByConnection?: Record; - /** Per connectionId: label badge (baseline / N differ). */ + /** Per connectionId: label badge (original / N differ). */ badgeByConnection?: Record; + /** Key-aligned compare remaps rows — lock inline CRUD. */ + compareLocked?: boolean; }> = ({ items, rowKey, @@ -405,6 +421,7 @@ const ResizablePaneRow: React.FC<{ pageState, diffByConnection, badgeByConnection, + compareLocked = false, }) => { const rowRef = useRef(null); const [widths, setWidths] = useState(() => items.map(() => PANE_DEFAULT_PX)); @@ -501,6 +518,7 @@ const ResizablePaneRow: React.FC<{ onSyncScrollRow={syncScroll ? setSyncRow : undefined} diffSummary={diffByConnection?.[item.connectionId] ?? null} compareBadge={badgeByConnection?.[item.connectionId] ?? null} + compareLocked={compareLocked} />
(''); + const [destId, setDestId] = useState(''); + /** Shared with Data migrate — Compare aligns rows by these keys. */ + const [keyNames, setKeyNames] = useState([]); + + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const connections = useSyncStore((s) => s.connections); useEffect(() => { if (!canCompare) return; @@ -654,6 +678,54 @@ const SideBySideStatementSection: React.FC<{ const compareActive = canCompare && compareOn && Boolean(baselineId); + useEffect(() => { + if (!compareActive) return; + const others = okGrids.filter((g) => g.connectionId !== baselineId); + if (!destId || !others.some((g) => g.connectionId === destId)) { + setDestId(others[0]?.connectionId ?? ''); + } + }, [compareActive, okGrids, baselineId, destId]); + + const sourceGrid = okGrids.find((g) => g.connectionId === baselineId); + const destGrid = okGrids.find((g) => g.connectionId === destId); + + const defaultKeys = useMemo(() => { + if (!sourceGrid?.result.ok) return [] as string[]; + const conn = connections.find((c) => c.id === sourceGrid.connectionId); + const tables = schemaCache[sourceGrid.connectionId]?.tables; + const editTarget = sourceGrid.statementSql + ? singleTableForResultEdit(sourceGrid.statementSql, tables, conn?.schema) + : { ok: false as const }; + const table = editTarget.ok ? editTarget.table : undefined; + const resolved = resolvePeekKeyColumns(table, sourceGrid.result.columns).map((k) => k.name); + if (resolved.length) return resolved; + const idCol = sourceGrid.result.columns.find((c) => c.toLowerCase() === 'id'); + return idCol ? [idCol] : sourceGrid.result.columns.slice(0, 1); + }, [sourceGrid, connections, schemaCache]); + + const defaultKeysKey = defaultKeys.join('\0'); + const keyNamesKey = keyNames.join('\0'); + const sourceColsKey = sourceGrid?.result.ok ? sourceGrid.result.columns.join('\0') : ''; + + useEffect(() => { + if (!compareActive) return; + if (keyNames.length === 0 && defaultKeys.length > 0) { + setKeyNames(defaultKeys); + return; + } + const cols = + sourceGrid?.result.ok ? sourceGrid.result.columns : null; + if ( + keyNames.length > 0 && + cols && + keyNames.every( + (k) => !cols.some((c) => c.toLowerCase() === k.toLowerCase()) + ) + ) { + setKeyNames(defaultKeys); + } + }, [compareActive, defaultKeysKey, keyNamesKey, sourceColsKey, defaultKeys, keyNames, sourceGrid]); + const triggerIgnoreColumns = useMemo(() => { if (!skipTriggerCols) return [] as string[]; const names = new Set(); @@ -664,44 +736,148 @@ const SideBySideStatementSection: React.FC<{ return [...names]; }, [skipTriggerCols, okGrids]); - const { diffByConnection, badgeByConnection, legendBits } = useMemo(() => { + const triggerIgnoreKey = triggerIgnoreColumns.join('\0'); + const ignoreOpts = useMemo( + () => + triggerIgnoreColumns.length > 0 + ? { ignoreColumns: triggerIgnoreColumns } + : undefined, + [triggerIgnoreKey, triggerIgnoreColumns] + ); + + const effectiveKeys = keyNames.length ? keyNames : defaultKeys; + + /** Key-align source ↔ dest (same pair as Data migrate) for a friendly visual. */ + const keyAligned = useMemo(() => { + if (!compareActive || !sourceGrid?.result.ok || !destGrid?.result.ok) return null; + if (effectiveKeys.length === 0) return null; + return alignResultGridsByKey( + { columns: sourceGrid.result.columns, rows: sourceGrid.result.rows }, + { columns: destGrid.result.columns, rows: destGrid.result.rows }, + effectiveKeys, + ignoreOpts + ); + }, [compareActive, sourceGrid, destGrid, effectiveKeys.join('\0'), ignoreOpts]); + + const { diffByConnection, badgeByConnection, legendBits, displayItems } = useMemo(() => { const diffByConnection: Record = {}; const badgeByConnection: Record = {}; const legendBits: string[] = []; + let displayItems = items; + if (!compareActive) { - return { diffByConnection, badgeByConnection, legendBits }; + return { diffByConnection, badgeByConnection, legendBits, displayItems }; } const baselineItem = okGrids.find((g) => g.connectionId === baselineId); if (!baselineItem || !baselineItem.result.ok) { - return { diffByConnection, badgeByConnection, legendBits }; + return { diffByConnection, badgeByConnection, legendBits, displayItems }; } + const baselineGrid = { columns: baselineItem.result.columns, rows: baselineItem.result.rows, }; - badgeByConnection[baselineId] = 'baseline'; + badgeByConnection[baselineId] = 'original'; + + if (keyAligned && destId) { + const destItem = okGrids.find((g) => g.connectionId === destId); + if (destItem?.result.ok) { + const destGridLike = { + columns: destItem.result.columns, + rows: destItem.result.rows, + }; + const pair = compareKeyAlignedGrids( + baselineGrid, + destGridLike, + keyAligned, + ignoreOpts + ); + diffByConnection[baselineId] = pair.baseline; + diffByConnection[destId] = pair.other; + + const keyLabel = keyAligned.keyNames.join('+'); + legendBits.push(`aligned by ${keyLabel}`); + if (keyAligned.updateCount > 0) legendBits.push(`${keyAligned.updateCount} edit`); + if (keyAligned.insertCount > 0) legendBits.push(`${keyAligned.insertCount} add`); + if (keyAligned.deleteCount > 0) legendBits.push(`${keyAligned.deleteCount} delete`); + if (keyAligned.matchCount > 0) legendBits.push(`${keyAligned.matchCount} match`); + if (triggerIgnoreColumns.length > 0) { + legendBits.push(`skipping ${triggerIgnoreColumns.join(', ')}`); + } + if ( + keyAligned.updateCount === 0 && + keyAligned.insertCount === 0 && + keyAligned.deleteCount === 0 + ) { + legendBits.push('grids match by key'); + } + + badgeByConnection[baselineId] = + keyAligned.deleteCount + keyAligned.updateCount === 0 + ? 'original · key' + : `original · ${keyAligned.updateCount} edit · ${keyAligned.deleteCount} only-here`; + badgeByConnection[destId] = + keyAligned.insertCount + keyAligned.updateCount === 0 + ? 'match by key' + : `${keyAligned.updateCount} edit · ${keyAligned.insertCount} only-here`; + + displayItems = items.map((item) => { + if (item.kind !== 'grid' || !item.result.ok) return item; + if (item.connectionId === baselineId) { + return { + ...item, + result: { + ...item.result, + rows: keyAligned.leftRows, + rowCount: keyAligned.leftRows.length, + }, + }; + } + if (item.connectionId === destId) { + return { + ...item, + result: { + ...item.result, + rows: keyAligned.rightRows, + rowCount: keyAligned.rightRows.length, + }, + }; + } + return item; + }); + + for (const g of okGrids) { + if (g.connectionId === baselineId || g.connectionId === destId || !g.result.ok) { + continue; + } + const pairIdx = compareResultGrids( + baselineGrid, + { columns: g.result.columns, rows: g.result.rows }, + ignoreOpts + ); + diffByConnection[g.connectionId] = pairIdx.other; + const n = pairIdx.other.cells.size; + badgeByConnection[g.connectionId] = + n === 0 ? 'match (by index)' : `${n} differ (by index)`; + } + + return { diffByConnection, badgeByConnection, legendBits, displayItems }; + } + } let totalModified = 0; let totalMissing = 0; let totalExtra = 0; const missingCols = new Set(); const extraCols = new Set(); - const ignoreOpts = - triggerIgnoreColumns.length > 0 - ? { ignoreColumns: triggerIgnoreColumns } - : undefined; for (const g of okGrids) { if (g.connectionId === baselineId || !g.result.ok) continue; const pair = compareResultGrids( baselineGrid, - { - columns: g.result.columns, - rows: g.result.rows, - }, + { columns: g.result.columns, rows: g.result.rows }, ignoreOpts ); - // Merge baseline highlights across all others (union of diffs). const prev = diffByConnection[baselineId]; if (!prev) { diffByConnection[baselineId] = pair.baseline; @@ -733,13 +909,14 @@ const SideBySideStatementSection: React.FC<{ const baseCells = diffByConnection[baselineId]?.cells.size ?? 0; badgeByConnection[baselineId] = - baseCells === 0 ? 'baseline' : `baseline · ${baseCells} differ`; + baseCells === 0 ? 'original' : `original · ${baseCells} differ`; + legendBits.push('aligned by row index (pick Keys below for friendlier match)'); if (totalModified > 0) legendBits.push(`${totalModified} modified`); if (totalMissing > 0) legendBits.push(`${totalMissing} missing`); if (totalExtra > 0) legendBits.push(`${totalExtra} extra`); if (missingCols.size > 0) { - legendBits.push(`cols only in baseline: ${[...missingCols].join(', ')}`); + legendBits.push(`cols only in original: ${[...missingCols].join(', ')}`); } if (extraCols.size > 0) { legendBits.push(`cols only in other: ${[...extraCols].join(', ')}`); @@ -747,22 +924,19 @@ const SideBySideStatementSection: React.FC<{ if (triggerIgnoreColumns.length > 0) { legendBits.push(`skipping ${triggerIgnoreColumns.join(', ')}`); } - if (legendBits.length === 0) legendBits.push('grids match on this page'); - - return { diffByConnection, badgeByConnection, legendBits }; - }, [compareActive, okGrids, baselineId, triggerIgnoreColumns]); - - const [destId, setDestId] = useState(''); - useEffect(() => { - if (!compareActive) return; - const others = okGrids.filter((g) => g.connectionId !== baselineId); - if (!destId || !others.some((g) => g.connectionId === destId)) { - setDestId(others[0]?.connectionId ?? ''); - } - }, [compareActive, okGrids, baselineId, destId]); + if (legendBits.length === 1) legendBits.push('grids match on this page'); - const sourceGrid = okGrids.find((g) => g.connectionId === baselineId); - const destGrid = okGrids.find((g) => g.connectionId === destId); + return { diffByConnection, badgeByConnection, legendBits, displayItems }; + }, [ + compareActive, + okGrids, + baselineId, + destId, + keyAligned, + items, + triggerIgnoreColumns, + ignoreOpts, + ]); const insertServerBeamSample = () => { const sample = buildSampleBookmarks().find((b) => b.id === 'sample-server-beam-chunked'); @@ -802,7 +976,7 @@ const SideBySideStatementSection: React.FC<{ )} {compareOn && (