From 1fd86bdb5133126c1ae8b0b990f5f1b05337437c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 05:51:06 +0000 Subject: [PATCH 1/3] feat(sql-editor): key-align side-by-side compare for friendlier diffs Compare was index-aligned so mismatched ORDER BY made every row look wrong even when keys matched. Align source/dest rows by the same Keys as Data migrate, pad only-here rows, and tint real edits/adds/deletes. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataMigrateBar.tsx | 23 +- .../components/sql-editor/ResultsPanel.tsx | 255 ++++++++++++--- .../src/frontend/lib/resultKeyAlign.test.ts | 88 +++++ apps/web/src/frontend/lib/resultKeyAlign.ts | 305 ++++++++++++++++++ apps/web/src/frontend/lib/resultRowDiff.ts | 2 +- docs/USER_GUIDE.md | 11 +- 6 files changed, 633 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/frontend/lib/resultKeyAlign.test.ts create mode 100644 apps/web/src/frontend/lib/resultKeyAlign.ts 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..8ff2b266 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'; @@ -119,6 +124,8 @@ const ResultGridPane: React.FC<{ diffSummary?: GridDiffSummary | null; /** Suffix shown after the grid label (e.g. baseline / 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} /> ); } @@ -396,6 +410,8 @@ const ResizablePaneRow: React.FC<{ diffByConnection?: Record; /** Per connectionId: label badge (baseline / 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'; + 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 + ? 'baseline · key' + : `baseline · ${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; @@ -735,6 +911,7 @@ const SideBySideStatementSection: React.FC<{ badgeByConnection[baselineId] = baseCells === 0 ? 'baseline' : `baseline · ${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`); @@ -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'); @@ -865,7 +1039,7 @@ const SideBySideStatementSection: React.FC<{ extra - + {legendBits.join(' · ')} @@ -893,25 +1067,32 @@ const SideBySideStatementSection: React.FC<{ statementSql: destGrid.statementSql, }} ignoreColumns={triggerIgnoreColumns} + keyNames={effectiveKeys} + onKeyNamesChange={setKeyNames} onAfterMigrate={() => onRefresh?.(destGrid.connectionId)} onOpenServerBeamSample={insertServerBeamSample} /> )} x.key).join('|')}`} + items={displayItems} + rowKey={`side-${statementIndex}-${displayItems.map((x) => x.key).join('|')}-${ + keyAligned ? `key-${effectiveKeys.join('+')}` : 'idx' + }`} refreshing={refreshing} onRefresh={onRefresh} onPage={onPage} pageState={pageState} diffByConnection={compareActive ? diffByConnection : undefined} badgeByConnection={compareActive ? badgeByConnection : undefined} + compareLocked={Boolean(compareActive && keyAligned)} /> {compareActive && (

- Cell colors align by row index. Choose Add / Edit / Delete yourself; Transaction and Stop / - Continue are safety assists. Skip trigger cols ignores createdAt / updatedBy. Same ORDER BY - when scanning. Cap: 500 ops — larger sets use Server Beam. + {keyAligned + ? 'Rows line up by Keys (same as Data migrate). Matching keys share a row; only-here rows show as missing/extra. ' + : 'No usable key yet — cells align by row index. Pick Keys in Data migrate for friendlier matching. '} + Choose Add / Edit / Delete yourself; Transaction and Stop / Continue are safety assists. + Cap: 500 ops — larger sets use Server Beam.

)} diff --git a/apps/web/src/frontend/lib/resultKeyAlign.test.ts b/apps/web/src/frontend/lib/resultKeyAlign.test.ts new file mode 100644 index 00000000..e1e700f1 --- /dev/null +++ b/apps/web/src/frontend/lib/resultKeyAlign.test.ts @@ -0,0 +1,88 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { alignResultGridsByKey, compareKeyAlignedGrids } from './resultKeyAlign'; +import { cellDiffKey } from './resultDataDiff'; + +describe('alignResultGridsByKey', () => { + it('lines up matching keys and pads inserts/deletes', () => { + const left = { + columns: ['id', 'name'], + rows: [ + [1, 'Alice'], + [2, 'Shared'], + [4, 'OnlyLeft'], + ], + }; + const right = { + columns: ['id', 'name'], + rows: [ + [2, 'Shared'], + [1, 'Bob'], + [3, 'OnlyRight'], + ], + }; + const aligned = alignResultGridsByKey(left, right, ['id']); + expect(aligned).not.toBeNull(); + expect(aligned!.rowOps).toEqual(['update', 'match', 'delete', 'insert']); + expect(aligned!.leftRows.map((r) => r[0])).toEqual([1, 2, 4, null]); + expect(aligned!.rightRows.map((r) => r[0])).toEqual([1, 2, null, 3]); + expect(aligned!.updateCount).toBe(1); + expect(aligned!.matchCount).toBe(1); + expect(aligned!.deleteCount).toBe(1); + expect(aligned!.insertCount).toBe(1); + }); + + it('ignores trigger columns when deciding update vs match', () => { + const left = { + columns: ['id', 'name', 'updatedBy'], + rows: [[1, 'Alice', 'a']], + }; + const right = { + columns: ['id', 'name', 'updatedBy'], + rows: [[1, 'Alice', 'b']], + }; + const aligned = alignResultGridsByKey(left, right, ['id'], { + ignoreColumns: ['updatedBy'], + }); + expect(aligned!.rowOps).toEqual(['match']); + }); + + it('returns null when a key column is missing', () => { + const left = { columns: ['id'], rows: [[1]] }; + const right = { columns: ['name'], rows: [['x']] }; + expect(alignResultGridsByKey(left, right, ['id'])).toBeNull(); + }); +}); + +describe('compareKeyAlignedGrids', () => { + it('tints only differing cells on updates; full rows for insert/delete', () => { + const left = { + columns: ['id', 'name'], + rows: [ + [1, 'Alice'], + [2, 'Keep'], + ], + }; + const right = { + columns: ['id', 'name'], + rows: [ + [1, 'Bob'], + [3, 'New'], + ], + }; + const aligned = alignResultGridsByKey(left, right, ['id'])!; + const diff = compareKeyAlignedGrids(left, right, aligned); + // update on row 0 (id=1): name modified + expect(diff.baseline.cells.get(cellDiffKey(0, 1))).toBe('modified'); + expect(diff.other.cells.get(cellDiffKey(0, 1))).toBe('modified'); + // match id=2 then delete? left has 2, right doesn't → delete; right has 3 → insert + const deleteIdx = aligned.rowOps.indexOf('delete'); + const insertIdx = aligned.rowOps.indexOf('insert'); + expect(diff.baseline.cells.get(cellDiffKey(deleteIdx, 0))).toBe('missing'); + expect(diff.other.cells.get(cellDiffKey(insertIdx, 0))).toBe('extra'); + }); +}); diff --git a/apps/web/src/frontend/lib/resultKeyAlign.ts b/apps/web/src/frontend/lib/resultKeyAlign.ts new file mode 100644 index 00000000..42d48b93 --- /dev/null +++ b/apps/web/src/frontend/lib/resultKeyAlign.ts @@ -0,0 +1,305 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Key-align two result grids so matching keys share a row index (friendlier + * than ORDER BY index compare). Used by SQL Editor side-by-side Compare. + */ + +import { + type CellDiffKind, + type GridDiffSummary, + type ResultGridLike, + type ResultPairDiff, + resultValuesEqual, +} from './resultDataDiff'; +import { keyColumnsForGrid } from './resultRowDiff'; + +export type AlignRowOp = 'match' | 'update' | 'insert' | 'delete'; + +export interface KeyAlignedGrids { + keyNames: string[]; + /** Display rows (gaps are all-null placeholder rows). */ + leftRows: unknown[][]; + rightRows: unknown[][]; + /** True when that side has no real row at this aligned index. */ + leftGap: boolean[]; + rightGap: boolean[]; + rowOps: AlignRowOp[]; + matchCount: number; + updateCount: number; + insertCount: number; + deleteCount: number; +} + +function emptySummary(): GridDiffSummary { + return { + cells: new Map(), + modified: 0, + missing: 0, + extra: 0, + missingColumns: [], + extraColumns: [], + }; +} + +function mark( + summary: GridDiffSummary, + rowIdx: number, + colIdx: number, + kind: CellDiffKind +): void { + const key = `${rowIdx}:${colIdx}`; + if (summary.cells.has(key)) return; + summary.cells.set(key, kind); + if (kind === 'modified') summary.modified += 1; + else if (kind === 'missing') summary.missing += 1; + else summary.extra += 1; +} + +function nullRow(width: number): unknown[] { + return Array.from({ length: width }, () => null); +} + +function rowKeyParts( + row: unknown[], + keys: ReturnType +): string | null { + const parts: string[] = []; + for (const k of keys) { + if (k.resultIndex < 0) return null; + const v = row[k.resultIndex]; + if (v === null || v === undefined) return null; + parts.push(`${k.name.toLowerCase()}=${String(v)}`); + } + return parts.join('|'); +} + +/** + * Reorder left/right so shared keys line up. Order: matches & updates (left + * encounter order), then left-only (delete), then right-only (insert). + * Returns null when keys are missing from either grid. + */ +export function alignResultGridsByKey( + left: ResultGridLike, + right: ResultGridLike, + keyNames: string[], + opts?: { ignoreColumns?: string[] } +): KeyAlignedGrids | null { + if (keyNames.length === 0) return null; + const leftKeys = keyColumnsForGrid(keyNames, left.columns); + const rightKeys = keyColumnsForGrid(keyNames, right.columns); + if ( + leftKeys.some((k) => k.resultIndex < 0) || + rightKeys.some((k) => k.resultIndex < 0) + ) { + return null; + } + + const ignoreLower = new Set((opts?.ignoreColumns ?? []).map((c) => c.toLowerCase())); + const keyLower = new Set(keyNames.map((k) => k.toLowerCase())); + + const leftMap = new Map(); + const leftOrder: string[] = []; + for (const row of left.rows) { + const k = rowKeyParts(row, leftKeys); + if (k == null) continue; + if (!leftMap.has(k)) { + leftMap.set(k, row); + leftOrder.push(k); + } + } + const rightMap = new Map(); + const rightOrder: string[] = []; + for (const row of right.rows) { + const k = rowKeyParts(row, rightKeys); + if (k == null) continue; + if (!rightMap.has(k)) { + rightMap.set(k, row); + rightOrder.push(k); + } + } + + const leftRows: unknown[][] = []; + const rightRows: unknown[][] = []; + const leftGap: boolean[] = []; + const rightGap: boolean[] = []; + const rowOps: AlignRowOp[] = []; + let matchCount = 0; + let updateCount = 0; + let insertCount = 0; + let deleteCount = 0; + + const seenRight = new Set(); + + for (const k of leftOrder) { + const lRow = leftMap.get(k)!; + const rRow = rightMap.get(k); + if (rRow) { + seenRight.add(k); + const changed = nonKeyDiffer( + lRow, + rRow, + left.columns, + right.columns, + keyLower, + ignoreLower + ); + leftRows.push(lRow); + rightRows.push(rRow); + leftGap.push(false); + rightGap.push(false); + if (changed) { + rowOps.push('update'); + updateCount += 1; + } else { + rowOps.push('match'); + matchCount += 1; + } + } else { + leftRows.push(lRow); + rightRows.push(nullRow(right.columns.length)); + leftGap.push(false); + rightGap.push(true); + rowOps.push('delete'); + deleteCount += 1; + } + } + + for (const k of rightOrder) { + if (seenRight.has(k) || leftMap.has(k)) continue; + const rRow = rightMap.get(k)!; + leftRows.push(nullRow(left.columns.length)); + rightRows.push(rRow); + leftGap.push(true); + rightGap.push(false); + rowOps.push('insert'); + insertCount += 1; + } + + return { + keyNames, + leftRows, + rightRows, + leftGap, + rightGap, + rowOps, + matchCount, + updateCount, + insertCount, + deleteCount, + }; +} + +function nonKeyDiffer( + leftRow: unknown[], + rightRow: unknown[], + leftCols: string[], + rightCols: string[], + keyLower: Set, + ignoreLower: Set +): boolean { + const rightIdx = new Map(); + rightCols.forEach((c, i) => { + const k = c.toLowerCase(); + if (!rightIdx.has(k)) rightIdx.set(k, i); + }); + for (let i = 0; i < leftCols.length; i++) { + const name = leftCols[i]!; + const lower = name.toLowerCase(); + if (keyLower.has(lower) || ignoreLower.has(lower)) continue; + const ri = rightIdx.get(lower); + if (ri === undefined) continue; + if (!resultValuesEqual(leftRow[i], rightRow[ri])) return true; + } + return false; +} + +/** Build cell tint maps for a key-aligned pair (display indexes). */ +export function compareKeyAlignedGrids( + left: ResultGridLike, + right: ResultGridLike, + aligned: KeyAlignedGrids, + opts?: { ignoreColumns?: string[] } +): ResultPairDiff { + const baseSum = emptySummary(); + const otherSum = emptySummary(); + const ignore = new Set((opts?.ignoreColumns ?? []).map((c) => c.toLowerCase())); + + const leftByName = new Map(); + left.columns.forEach((c, i) => { + const k = c.toLowerCase(); + if (!leftByName.has(k)) leftByName.set(k, i); + }); + const rightByName = new Map(); + right.columns.forEach((c, i) => { + const k = c.toLowerCase(); + if (!rightByName.has(k)) rightByName.set(k, i); + }); + + for (const [k, idx] of leftByName) { + if (ignore.has(k)) continue; + if (!rightByName.has(k)) baseSum.missingColumns.push(left.columns[idx]!); + } + for (const [k, idx] of rightByName) { + if (ignore.has(k)) continue; + if (!leftByName.has(k)) otherSum.extraColumns.push(right.columns[idx]!); + } + otherSum.missingColumns = [...baseSum.missingColumns]; + baseSum.extraColumns = [...otherSum.extraColumns]; + + const shared: { leftIdx: number; rightIdx: number }[] = []; + for (const [k, leftIdx] of leftByName) { + if (ignore.has(k)) continue; + const rightIdx = rightByName.get(k); + if (rightIdx === undefined) continue; + shared.push({ leftIdx, rightIdx }); + } + + for (let r = 0; r < aligned.rowOps.length; r++) { + const op = aligned.rowOps[r]!; + if (op === 'match') continue; + + if (op === 'delete') { + for (const { leftIdx } of shared) mark(baseSum, r, leftIdx, 'missing'); + for (const [k, leftIdx] of leftByName) { + if (ignore.has(k) || rightByName.has(k)) continue; + mark(baseSum, r, leftIdx, 'missing'); + } + continue; + } + + if (op === 'insert') { + for (const { rightIdx } of shared) mark(otherSum, r, rightIdx, 'extra'); + for (const [k, rightIdx] of rightByName) { + if (ignore.has(k) || leftByName.has(k)) continue; + mark(otherSum, r, rightIdx, 'extra'); + } + continue; + } + + // update — tint only cells that actually differ + const lRow = aligned.leftRows[r]!; + const rRow = aligned.rightRows[r]!; + for (const { leftIdx, rightIdx } of shared) { + if (resultValuesEqual(lRow[leftIdx], rRow[rightIdx])) continue; + mark(baseSum, r, leftIdx, 'modified'); + mark(otherSum, r, rightIdx, 'modified'); + } + for (const [k, rightIdx] of rightByName) { + if (ignore.has(k) || leftByName.has(k)) continue; + mark(otherSum, r, rightIdx, 'extra'); + } + for (const [k, leftIdx] of leftByName) { + if (ignore.has(k) || rightByName.has(k)) continue; + mark(baseSum, r, leftIdx, 'missing'); + } + } + + return { + baseline: baseSum, + other: otherSum, + totalDiffCells: baseSum.cells.size + otherSum.cells.size, + }; +} diff --git a/apps/web/src/frontend/lib/resultRowDiff.ts b/apps/web/src/frontend/lib/resultRowDiff.ts index 4dd8b83b..89d38359 100644 --- a/apps/web/src/frontend/lib/resultRowDiff.ts +++ b/apps/web/src/frontend/lib/resultRowDiff.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 * * Key-based row classification for data migrate (source → destination). - * Side-by-side cell tinting stays index-aligned; DML uses this classifier. + * Visual compare uses resultKeyAlign for key-aligned cell tinting. */ import { resultValuesEqual } from './resultDataDiff'; diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 24d007d5..d3312829 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -131,11 +131,12 @@ compare / migrate). It lives in the same local web UI you open with `foxschema`. 6. **Compare data across servers** — switch the results layout to **Side-by-side** and check two or more Destinations. **Compare data** is off by default so grids - stay plain until you turn it on. Then cells that differ from the source are - colored: amber (modified), rose (missing), emerald (extra). Pick **source** - (and **dest** when more than two). **Skip trigger cols** (on by default) ignores - audit fields such as `createdAt` / `updatedBy`. Cell tinting aligns by row - index — use the same `ORDER BY` when scanning. + stay plain until you turn it on. Rows then **line up by key columns** (PK / your + Keys checkboxes — same as Data migrate), so matching IDs share a row instead of + comparing by ORDER BY index. Differing cells are colored: amber (modified), rose + (missing / only in source), emerald (extra / only in dest). Pick **source** (and + **dest** when more than two). **Skip trigger cols** (on by default) ignores audit + fields such as `createdAt` / `updatedBy`. 7. **Data migrate (≤500 row ops)** — with Compare on, **Data migrate** appears. You choose **Add / Edit / Delete** (none selected until you check them). Safety From 91acb7f70d7117052cf32c93812dbdb01f3e88c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 05:57:44 +0000 Subject: [PATCH 2/3] fix(ui): rename Source/baseline labels to Original Server Schema Sync and Compare data now say Original Server instead of Source/ baseline so the read-from side is clearer next to Target/Destination. Co-authored-by: huy.phan9 --- apps/e2e/src/tests/sql-editor-sqlite.test.ts | 4 ++-- .../src/frontend/components/TopToolbar.tsx | 24 +++++++++++++------ .../components/sql-editor/ResultsPanel.tsx | 14 +++++------ docs/USER_GUIDE.md | 8 +++---- 4 files changed, 30 insertions(+), 20 deletions(-) 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/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/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 8ff2b266..2a19f0b9 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -777,7 +777,7 @@ const SideBySideStatementSection: React.FC<{ 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); @@ -814,8 +814,8 @@ const SideBySideStatementSection: React.FC<{ badgeByConnection[baselineId] = keyAligned.deleteCount + keyAligned.updateCount === 0 - ? 'baseline · key' - : `baseline · ${keyAligned.updateCount} edit · ${keyAligned.deleteCount} only-here`; + ? 'original · key' + : `original · ${keyAligned.updateCount} edit · ${keyAligned.deleteCount} only-here`; badgeByConnection[destId] = keyAligned.insertCount + keyAligned.updateCount === 0 ? 'match by key' @@ -909,14 +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(', ')}`); @@ -976,7 +976,7 @@ const SideBySideStatementSection: React.FC<{ )} {compareOn && (