diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7afbcda8cd..0c781d044e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -230,6 +230,7 @@ import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDa import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme"; import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; +import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; @@ -568,6 +569,14 @@ let preservedDetailsOnNextResult: { rowDialog?: PersistedDataGridSelection; columnDialog?: PersistedDataGridSelection; } | null = null; +let preservedViewportAnchorOnNextResult: { + anchor: { + row?: PersistedDataGridSelection; + fallbackDisplayIndex: number; + offsetWithinRow: number; + }; + sourceResult: QueryResult; +} | null = null; watch( () => props.result, @@ -3506,6 +3515,7 @@ watch( if (!loading && prevLoading && isRefreshingData.value) { isRefreshingData.value = false; if (preservedSelectionOnNextResult?.sourceResult === props.result) preservedSelectionOnNextResult = null; + if (preservedViewportAnchorOnNextResult?.sourceResult === props.result) preservedViewportAnchorOnNextResult = null; const total = paginationTotalRowCount.value; if (!total || total <= 0) return; const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value)); @@ -3863,6 +3873,88 @@ type DisplayRowRef = status: RowStatus; }; +async function refreshSavedRows(request: { dirtyRows: ReadonlyMap>; columns: readonly string[]; rows: readonly (readonly CellValue[])[] }): Promise { + const tableMeta = props.tableMeta; + const connectionId = props.connectionId; + if (!tableMeta || !connectionId) return false; + + const planResult = buildDataGridSavedRowRefreshPlan({ + context: props.context, + infiniteScroll: infiniteScrollEnabled.value, + filterActive: !!currentWhereInput() || hasLocalColumnFilters.value || (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value), + orderActive: !!orderByInput.value.trim() || !!sortCol.value, + columns: request.columns, + sourceColumns: props.sourceColumns, + rows: request.rows, + primaryKeys: tableMeta.primaryKeys, + dirtyRows: request.dirtyRows, + }); + if (!planResult.eligible || planResult.plan.sourceIndexes.length === 0) return false; + + const identityConditions: string[] = []; + for (const sourceIndex of planResult.plan.sourceIndexes) { + const row = request.rows[sourceIndex]; + if (!row) return false; + const conditions: string[] = []; + for (let identityIndex = 0; identityIndex < planResult.plan.identityColumns.length; identityIndex++) { + const columnName = planResult.plan.identityColumns[identityIndex]!; + const value = row[planResult.plan.identityColumnIndexes[identityIndex]!] ?? null; + const condition = await buildDataGridContextFilterCondition({ + databaseType: resolvedDatabaseType.value, + identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId), + columnName, + columnInfo: tableMeta.columns.find((column) => column.name.toLocaleLowerCase() === columnName.toLocaleLowerCase()), + mode: value === null ? "is-null" : "equals", + value, + }); + if (!condition) return false; + conditions.push(`(${condition})`); + } + identityConditions.push(`(${conditions.join(" AND ")})`); + } + + const sql = await buildTableSelectSql({ + databaseType: resolvedDatabaseType.value, + driverProfile: connectionStore.getConfig(connectionId)?.driver_profile, + identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId), + catalog: tableMeta.catalog, + database: tableMeta.database, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + tableType: tableMeta.tableType, + columns: tableMeta.columns.map((column) => column.name), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, + primaryKeys: tableMeta.primaryKeys, + ...tableDataLargeValuePreviewOptions(resolvedDatabaseType.value, tableMeta.columns, tableMeta.primaryKeys, pageSize.value), + whereInput: identityConditions.join(" OR "), + limit: planResult.plan.sourceIndexes.length + 1, + includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + }); + const refreshed = await api.executeQuery(connectionId, props.executionDatabase ?? props.database ?? "", sql, tableMeta.schema ?? props.schema, undefined, { + maxRows: planResult.plan.sourceIndexes.length + 1, + fetchSize: planResult.plan.sourceIndexes.length + 1, + pageSize: planResult.plan.sourceIndexes.length + 1, + }); + const patches = dataGridSavedRowRefreshPatches(planResult.plan, request.columns, props.sourceColumns, refreshed.columns, refreshed.rows); + if (!patches) return false; + + const refreshedSourceIndexes = new Set(patches.map((patch) => patch.sourceIndex)); + for (const patch of patches) props.result.rows[patch.sourceIndex] = patch.row; + if (props.result.large_value_cells || refreshed.large_value_cells) { + props.result.large_value_cells = [ + ...(props.result.large_value_cells ?? []).filter((cell) => !refreshedSourceIndexes.has(cell.row_index)), + ...patches.flatMap((patch) => (refreshed.large_value_cells ?? []).filter((cell) => cell.row_index === patch.refreshedRowIndex).map((cell) => ({ ...cell, row_index: patch.sourceIndex }))), + ]; + } + if (props.result.spatial_values || refreshed.spatial_values) { + props.result.spatial_values ??= props.result.rows.map(() => []); + for (const patch of patches) props.result.spatial_values[patch.sourceIndex] = [...(refreshed.spatial_values?.[patch.refreshedRowIndex] ?? [])]; + } + clearCellFormatCache(); + scheduleCanvasDraw(); + return true; +} + const editor = useDataGridEditor({ result: computed(() => props.result), editable: computed(() => props.editable), @@ -3889,6 +3981,7 @@ const editor = useDataGridEditor({ currentPage, cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey), onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result), + refreshSavedRows, onCellValueChanged: invalidateVisibleLargeValuePreviewCell, prepareFullReload, emit, @@ -4341,11 +4434,13 @@ function resetInfiniteScrollState() { } function prepareFullReload() { + const viewportAnchor = captureViewportAnchorForRefresh(); if (infiniteScrollEnabled.value) { resetInfiniteScrollState(); } const selection = captureCurrentSelectionForRefresh(); preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null; + preservedViewportAnchorOnNextResult = viewportAnchor ? { anchor: viewportAnchor, sourceResult: props.result } : null; preservedDetailsOnNextResult = captureDetailsForRefresh(); preserveTransposeOnNextResult.value = showTranspose.value; isRefreshingData.value = true; @@ -4379,13 +4474,8 @@ async function onToolbarCommit() { } function onToolbarRollback() { - preserveTransposeOnNextResult.value = showTranspose.value; discardChanges(); - // Reset infinite scroll state on rollback - if (infiniteScrollEnabled.value) { - resetInfiniteScrollState(); - } - isRefreshingData.value = true; + prepareFullReload(); emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value); } @@ -5436,6 +5526,51 @@ function captureRowTargetForRefresh(rowId: number | null): PersistedDataGridSele ); } +function captureViewportAnchorForRefresh(): { row?: PersistedDataGridSelection; fallbackDisplayIndex: number; offsetWithinRow: number } | null { + if (showTranspose.value || displayItems.value.length === 0) return null; + const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement(); + if (!scroller) return null; + const rowHeight = useCanvasGridRows.value ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT; + const fallbackDisplayIndex = Math.max(0, Math.min(displayItems.value.length - 1, Math.floor(scroller.scrollTop / rowHeight))); + const item = displayItems.value[fallbackDisplayIndex]; + return { + row: item ? captureRowTargetForRefresh(item.id) : undefined, + fallbackDisplayIndex, + offsetWithinRow: Math.max(0, scroller.scrollTop - fallbackDisplayIndex * rowHeight), + }; +} + +function restoreViewportAnchorAfterRefresh(anchor: { row?: PersistedDataGridSelection; fallbackDisplayIndex: number; offsetWithinRow: number }) { + if (showTranspose.value || displayItems.value.length === 0) return; + let displayRowIndex = anchor.fallbackDisplayIndex; + if (anchor.row) { + const restored = restoreDataGridSelection({ + snapshot: anchor.row, + columns: props.result.columns, + sourceColumns: props.sourceColumns, + rows: props.result.rows, + visibleColumnIndexes: visibleColumnIndexes.value, + displayItems: displayItems.value, + }); + if (restored?.kind === "rows") displayRowIndex = restored.scrollRowIndex; + } + displayRowIndex = Math.max(0, Math.min(displayItems.value.length - 1, displayRowIndex)); + nextTick(() => { + requestAnimationFrame(() => { + const canvasMode = useCanvasGridRows.value; + const scroller = canvasMode ? canvasScrollerElement() : gridScrollerElement(); + if (!scroller) return; + const rowHeight = canvasMode ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT; + const nextScrollTop = Math.max(0, Math.min(displayRowIndex * rowHeight + anchor.offsetWithinRow, scroller.scrollHeight - scroller.clientHeight)); + const virtualScroller = scrollerRef.value; + if (!canvasMode && virtualScroller && !(virtualScroller instanceof HTMLElement)) virtualScroller.scrollToPosition?.(nextScrollTop); + else scroller.scrollTop = nextScrollTop; + if (canvasMode) syncCanvasViewport(); + else updateGridVerticalScrollbar(scroller); + }); + }); +} + function captureColumnTargetForRefresh(columnIndex: number | null): PersistedDataGridSelection | undefined { if (columnIndex === null) return undefined; const visibleColumnIndex = visibleColumnIndexes.value.indexOf(columnIndex); @@ -9870,6 +10005,8 @@ watch( (result, previousResult) => { const selectionSnapshot = preservedSelectionOnNextResult?.selection; preservedSelectionOnNextResult = null; + const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor; + preservedViewportAnchorOnNextResult = null; const detailsSnapshot = preservedDetailsOnNextResult; preservedDetailsOnNextResult = null; const shouldPreserveTranspose = preserveTransposeOnNextResult.value; @@ -9908,6 +10045,7 @@ watch( exitTransaction(); if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot); if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot); + if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot); }, ); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts index 96f77dff12..0aed91e6dc 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts @@ -15,17 +15,23 @@ describe("DataGrid save reload integration", () => { it("shares the toolbar full-reload preparation with editor saves", () => { const prepareSource = functionSource("prepareFullReload", "onToolbarRefresh"); const toolbarSource = functionSource("onToolbarRefresh", "setAutoRefreshInterval"); + const rollbackSource = functionSource("onToolbarRollback", "addRow"); expect(dataGridSource).toContain("prepareFullReload,\n emit,"); + expect(dataGridSource).toContain("refreshSavedRows,\n onCellValueChanged"); expect(toolbarSource).toContain("prepareFullReload();"); + expect(rollbackSource).toContain("prepareFullReload();"); expect(toolbarSource).toContain("const resetToFirstPage = hasPendingConditionInputs();"); expect(toolbarSource).toContain("resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value"); expect(prepareSource).toContain("resetInfiniteScrollState();"); + expect(prepareSource).toContain("captureViewportAnchorForRefresh();"); + expect(prepareSource).toContain("preservedViewportAnchorOnNextResult = viewportAnchor ? { anchor: viewportAnchor, sourceResult: props.result } : null;"); expect(prepareSource).toContain("captureCurrentSelectionForRefresh();"); expect(prepareSource).toContain("preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;"); expect(prepareSource).toContain("preservedDetailsOnNextResult = captureDetailsForRefresh();"); expect(prepareSource).toContain("preserveTransposeOnNextResult.value = showTranspose.value;"); expect(dataGridSource).toContain("if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);"); + expect(dataGridSource).toContain("if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);"); }); it("resets accumulated infinite-scroll pagination before reloading from offset zero", () => { diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 337aac67b6..8897b63f8d 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -117,6 +117,7 @@ export interface UseDataGridEditorOptions { cacheKey?: ComputedRef; /** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */ onResultPayloadMutated?: () => void; + refreshSavedRows?: (request: { dirtyRows: ReadonlyMap>; columns: readonly string[]; rows: readonly (readonly CellValue[])[] }) => Promise; onCellValueChanged?: (rowId: number, columnIndex: number) => void; prepareFullReload?: () => void; emit: (event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => void; @@ -1835,11 +1836,23 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } applyDirtyRowsToResult(snapshot); options.onResultPayloadMutated?.(); + let savedRowsRefreshed = false; + if (!shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) { + try { + savedRowsRefreshed = await options.refreshSavedRows({ + dirtyRows: snapshot.dirtyRows, + columns: result.value.columns, + rows: result.value.rows, + }); + } catch (error) { + console.warn("[DBX] failed to refresh saved data grid rows", error); + } + } snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row)); clearSavedPendingChanges(snapshot); if (!hasPendingChanges.value) exitTransaction(); clearPendingChangeHistory(); - if (shouldReloadAfterSqlSave) { + if (shouldReloadAfterSqlSave && !savedRowsRefreshed) { reloadCurrentData(); } await finishSaveChanges(snapshot); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSavedRowRefresh.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSavedRowRefresh.spec.ts new file mode 100644 index 0000000000..3fc76f9d61 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSavedRowRefresh.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh"; + +function dirtyRows(...entries: Array<[number, Array<[number, string | number | boolean | null]>]>): Map> { + return new Map(entries.map(([rowIndex, changes]) => [rowIndex, new Map(changes)])); +} + +describe("dataGridSavedRowRefresh", () => { + it("plans stable table-data row refreshes by primary key", () => { + const result = buildDataGridSavedRowRefreshPlan({ + context: "table-data", + infiniteScroll: false, + filterActive: false, + orderActive: false, + columns: ["id", "name"], + rows: [ + [1, "Ada"], + [2, "Grace"], + ], + primaryKeys: ["id"], + dirtyRows: dirtyRows([1, [[1, "Grace Hopper"]]]), + }); + + expect(result).toMatchObject({ + eligible: true, + plan: { + sourceIndexes: [1], + identityColumns: ["id"], + identityColumnIndexes: [0], + }, + }); + }); + + it.each([ + ["results context", { context: "results" as const }, "not-table-data"], + ["infinite scroll", { infiniteScroll: true }, "infinite-scroll"], + ["active filter", { filterActive: true }, "active-filter"], + ["active order", { orderActive: true }, "active-order"], + ["missing key", { primaryKeys: [] }, "missing-identity"], + ["edited key", { dirtyRows: dirtyRows([0, [[0, 2]]]) }, "identity-edited"], + ])("falls back for %s", (_label, overrides, reason) => { + const result = buildDataGridSavedRowRefreshPlan({ + context: "table-data", + infiniteScroll: false, + filterActive: false, + orderActive: false, + columns: ["id", "name"], + rows: [[1, "Ada"]], + primaryKeys: ["id"], + dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]]), + ...overrides, + }); + + expect(result).toEqual({ eligible: false, reason }); + }); + + it("maps refreshed rows back to their original source indexes", () => { + const planResult = buildDataGridSavedRowRefreshPlan({ + context: "table-data", + infiniteScroll: false, + filterActive: false, + orderActive: false, + columns: ["id", "name", "updated_at"], + rows: [ + [1, "Ada", "old-1"], + [2, "Grace", "old-2"], + ], + primaryKeys: ["id"], + dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]], [1, [[1, "Grace Hopper"]]]), + }); + expect(planResult.eligible).toBe(true); + if (!planResult.eligible) return; + + expect( + dataGridSavedRowRefreshPatches( + planResult.plan, + ["id", "name", "updated_at"], + undefined, + ["updated_at", "name", "id"], + [ + ["fresh-2", "Grace Hopper", 2], + ["fresh-1", "Ada Lovelace", 1], + ], + ), + ).toEqual([ + { sourceIndex: 0, refreshedRowIndex: 1, row: [1, "Ada Lovelace", "fresh-1"] }, + { sourceIndex: 1, refreshedRowIndex: 0, row: [2, "Grace Hopper", "fresh-2"] }, + ]); + }); + + it("rejects missing or duplicate refresh results", () => { + const planResult = buildDataGridSavedRowRefreshPlan({ + context: "table-data", + infiniteScroll: false, + filterActive: false, + orderActive: false, + columns: ["id", "name"], + rows: [[1, "Ada"]], + primaryKeys: ["id"], + dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]]), + }); + expect(planResult.eligible).toBe(true); + if (!planResult.eligible) return; + + expect(dataGridSavedRowRefreshPatches(planResult.plan, ["id", "name"], undefined, ["id", "name"], [])).toBeNull(); + expect( + dataGridSavedRowRefreshPatches( + planResult.plan, + ["id", "name"], + undefined, + ["id", "name"], + [ + [1, "Ada"], + [1, "Ada duplicate"], + ], + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridSavedRowRefresh.ts b/apps/desktop/src/lib/dataGrid/dataGridSavedRowRefresh.ts new file mode 100644 index 0000000000..84f295637f --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridSavedRowRefresh.ts @@ -0,0 +1,138 @@ +import type { CellValue } from "@/lib/dataGrid/cellValue"; + +export const DATA_GRID_SAVED_ROW_REFRESH_LIMIT = 100; + +export type DataGridSavedRowRefreshIneligibleReason = "not-table-data" | "infinite-scroll" | "active-filter" | "active-order" | "too-many-rows" | "missing-identity" | "missing-identity-column" | "identity-edited" | "missing-source-row" | "duplicate-identity"; + +export interface DataGridSavedRowRefreshPlan { + sourceIndexes: number[]; + identityColumns: string[]; + identityColumnIndexes: number[]; + identityKeys: string[]; +} + +export type DataGridSavedRowRefreshPlanResult = { eligible: true; plan: DataGridSavedRowRefreshPlan } | { eligible: false; reason: DataGridSavedRowRefreshIneligibleReason }; + +export interface BuildDataGridSavedRowRefreshPlanOptions { + context?: "results" | "table-data"; + infiniteScroll: boolean; + filterActive: boolean; + orderActive: boolean; + columns: readonly string[]; + sourceColumns?: readonly (string | undefined)[]; + rows: readonly (readonly CellValue[])[]; + primaryKeys: readonly string[]; + dirtyRows: ReadonlyMap>; + rowLimit?: number; +} + +export interface DataGridSavedRowRefreshPatch { + sourceIndex: number; + refreshedRowIndex: number; + row: CellValue[]; +} + +function normalizeColumnName(value: string | undefined): string { + return value?.trim().toLocaleLowerCase() ?? ""; +} + +function resolveColumnIndexes(columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, targets: readonly string[]): number[] | null { + const indexes = targets.map((target) => { + const normalizedTarget = normalizeColumnName(target); + const sourceIndex = sourceColumns?.findIndex((column) => normalizeColumnName(column) === normalizedTarget) ?? -1; + if (sourceIndex >= 0) return sourceIndex; + return columns.findIndex((column, index) => !normalizeColumnName(sourceColumns?.[index]) && normalizeColumnName(column) === normalizedTarget); + }); + return indexes.every((index) => index >= 0) ? indexes : null; +} + +function serializeCellValue(value: CellValue): string { + if (value === null) return "null"; + if (typeof value === "string") return `string:${value.length}:${value}`; + if (typeof value === "boolean") return value ? "boolean:1" : "boolean:0"; + if (Number.isNaN(value)) return "number:NaN"; + if (Object.is(value, -0)) return "number:-0"; + return `number:${String(value)}`; +} + +export function dataGridSavedRowIdentityKey(row: readonly CellValue[], identityColumnIndexes: readonly number[]): string { + return identityColumnIndexes.map((index) => serializeCellValue(row[index] ?? null)).join("\u0000"); +} + +export function buildDataGridSavedRowRefreshPlan(options: BuildDataGridSavedRowRefreshPlanOptions): DataGridSavedRowRefreshPlanResult { + if (options.context !== "table-data") return { eligible: false, reason: "not-table-data" }; + if (options.infiniteScroll) return { eligible: false, reason: "infinite-scroll" }; + if (options.filterActive) return { eligible: false, reason: "active-filter" }; + if (options.orderActive) return { eligible: false, reason: "active-order" }; + if (options.dirtyRows.size > (options.rowLimit ?? DATA_GRID_SAVED_ROW_REFRESH_LIMIT)) return { eligible: false, reason: "too-many-rows" }; + if (options.primaryKeys.length === 0) return { eligible: false, reason: "missing-identity" }; + + const identityColumnIndexes = resolveColumnIndexes(options.columns, options.sourceColumns, options.primaryKeys); + if (!identityColumnIndexes) return { eligible: false, reason: "missing-identity-column" }; + const identityIndexSet = new Set(identityColumnIndexes); + const sourceIndexes: number[] = []; + const identityKeys: string[] = []; + const seenIdentityKeys = new Set(); + + for (const [sourceIndex, changes] of options.dirtyRows) { + if ([...changes.keys()].some((columnIndex) => identityIndexSet.has(columnIndex))) return { eligible: false, reason: "identity-edited" }; + const row = options.rows[sourceIndex]; + if (!row) return { eligible: false, reason: "missing-source-row" }; + const identityKey = dataGridSavedRowIdentityKey(row, identityColumnIndexes); + if (seenIdentityKeys.has(identityKey)) return { eligible: false, reason: "duplicate-identity" }; + seenIdentityKeys.add(identityKey); + sourceIndexes.push(sourceIndex); + identityKeys.push(identityKey); + } + + return { + eligible: true, + plan: { + sourceIndexes, + identityColumns: [...options.primaryKeys], + identityColumnIndexes, + identityKeys, + }, + }; +} + +function refreshedColumnMapping(columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, refreshedColumns: readonly string[]): number[] | null { + if (columns.length === refreshedColumns.length && columns.every((column, index) => normalizeColumnName(sourceColumns?.[index] ?? column) === normalizeColumnName(refreshedColumns[index]))) { + return columns.map((_, index) => index); + } + + const usedIndexes = new Set(); + const mapping = columns.map((column, index) => { + const target = normalizeColumnName(sourceColumns?.[index] ?? column); + const refreshedIndex = refreshedColumns.findIndex((candidate, candidateIndex) => !usedIndexes.has(candidateIndex) && normalizeColumnName(candidate) === target); + if (refreshedIndex >= 0) usedIndexes.add(refreshedIndex); + return refreshedIndex; + }); + return mapping.every((index) => index >= 0) ? mapping : null; +} + +export function dataGridSavedRowRefreshPatches(plan: DataGridSavedRowRefreshPlan, columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, refreshedColumns: readonly string[], refreshedRows: readonly (readonly CellValue[])[]): DataGridSavedRowRefreshPatch[] | null { + const refreshedIdentityIndexes = resolveColumnIndexes(refreshedColumns, undefined, plan.identityColumns); + const columnMapping = refreshedColumnMapping(columns, sourceColumns, refreshedColumns); + if (!refreshedIdentityIndexes || !columnMapping) return null; + + const refreshedRowsByIdentity = new Map(); + for (const [refreshedRowIndex, row] of refreshedRows.entries()) { + const identityKey = dataGridSavedRowIdentityKey(row, refreshedIdentityIndexes); + if (refreshedRowsByIdentity.has(identityKey)) return null; + refreshedRowsByIdentity.set(identityKey, { refreshedRowIndex, row }); + } + if (refreshedRowsByIdentity.size !== plan.identityKeys.length) return null; + + const patches: DataGridSavedRowRefreshPatch[] = []; + for (let index = 0; index < plan.identityKeys.length; index++) { + const refreshed = refreshedRowsByIdentity.get(plan.identityKeys[index]!); + if (!refreshed) return null; + patches.push({ + sourceIndex: plan.sourceIndexes[index]!, + refreshedRowIndex: refreshed.refreshedRowIndex, + row: columnMapping.map((columnIndex) => refreshed.row[columnIndex] ?? null), + }); + } + return patches; +} diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index 6ed3fd58bd..ed6f6008f9 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -624,6 +624,7 @@ test("saving inserted rows reloads current table data", async () => { const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all"); const emitted: unknown[][] = []; const executedSql: string[] = []; + let refreshCalls = 0; const editor = useDataGridEditor({ result, @@ -650,6 +651,10 @@ test("saving inserted rows reloads current table data", async () => { pageSize: ref(50), currentPage: ref(2), getRowItem: () => undefined, + refreshSavedRows: async () => { + refreshCalls++; + return true; + }, emit: (...args) => { emitted.push(args); }, @@ -659,6 +664,7 @@ test("saving inserted rows reloads current table data", async () => { await editor.saveChanges(); assert.deepEqual(executedSql, [`INSERT INTO "public"."people" ("id", "name") VALUES (2, 'Linus');`]); + assert.equal(refreshCalls, 0); assert.deepEqual(emitted, [["reload", "SELECT id, name FROM people", "linus", "name ILIKE '%l%'", "id DESC", 50, 50]]); }); @@ -763,6 +769,114 @@ test("saving edited rows without deletes does not reload table data", async () = assert.deepEqual(result.value.rows[0], [1, "Ada Lovelace"]); }); +test("SQL row updates refresh saved rows in place and skip a full reload", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "name", "updated_at"], + rows: [[1, "Ada", "old"] as CellValue[]], + })); + const emitted: unknown[][] = []; + let refreshCalls = 0; + let editor: ReturnType; + editor = useDataGridEditor({ + result, + editable: computed(() => true), + databaseType: computed(() => "postgres"), + connectionId: computed(() => undefined), + database: computed(() => undefined), + tableMeta: computed(() => ({ + tableName: "people", + columns: [column("id", true), column("name"), column("updated_at")], + primaryKeys: ["id"], + })), + onExecuteSql: computed(() => async () => {}), + customSaveHandler: computed(() => undefined), + sql: computed(() => "SELECT id, name, updated_at FROM people"), + searchText: ref(""), + whereFilterInput: ref(""), + orderByInput: ref(""), + currentWhereInput: computed(() => undefined), + rowStatusFilter: ref("all"), + pageSize: ref(50), + currentPage: ref(1), + getRowItem: (rowId) => { + if (rowId !== 0) return undefined; + return { + id: 0, + sourceIndex: 0, + data: editor.rowDataWithChanges(result.value.rows[0], 0), + isNew: false, + isDeleted: false, + isDirtyCol: [false, true, false], + status: "edited", + }; + }, + refreshSavedRows: async ({ dirtyRows }) => { + refreshCalls++; + assert.deepEqual([...dirtyRows.get(0)!.entries()], [[1, "Ada Lovelace"]]); + result.value.rows[0] = [1, "Ada Lovelace", "fresh-from-trigger"]; + return true; + }, + emit: (...args) => emitted.push(args), + }); + + editor.applyCellValue(0, 1, "Ada Lovelace"); + await editor.saveChanges(); + + assert.equal(refreshCalls, 1); + assert.deepEqual(emitted, []); + assert.deepEqual(result.value.rows[0], [1, "Ada Lovelace", "fresh-from-trigger"]); + assert.equal(editor.dirtyRows.value.size, 0); +}); + +test("SQL row refresh failure falls back to a full reload", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ columns: ["id", "name"], rows: [[1, "Ada"] as CellValue[]] })); + const emitted: unknown[][] = []; + let editor: ReturnType; + editor = useDataGridEditor({ + result, + editable: computed(() => true), + databaseType: computed(() => "postgres"), + connectionId: computed(() => undefined), + database: computed(() => undefined), + tableMeta: computed(() => ({ tableName: "people", columns: [column("id", true), column("name")], primaryKeys: ["id"] })), + onExecuteSql: computed(() => async () => {}), + customSaveHandler: computed(() => undefined), + sql: computed(() => "SELECT id, name FROM people"), + searchText: ref("ada"), + whereFilterInput: ref(""), + orderByInput: ref("id DESC"), + currentWhereInput: computed(() => undefined), + rowStatusFilter: ref("all"), + pageSize: ref(50), + currentPage: ref(2), + getRowItem: (rowId) => { + if (rowId !== 0) return undefined; + return { + id: 0, + sourceIndex: 0, + data: editor.rowDataWithChanges(result.value.rows[0], 0), + isNew: false, + isDeleted: false, + isDirtyCol: [false, true], + status: "edited", + }; + }, + refreshSavedRows: async () => false, + emit: (...args) => emitted.push(args), + }); + + editor.applyCellValue(0, 1, "Ada Lovelace"); + await editor.saveChanges(); + + assert.deepEqual(emitted, [["reload", "SELECT id, name FROM people", "ada", undefined, "id DESC", 50, 50]]); +}); + test("undo and redo restore pending cell edits before save", () => { setActivePinia(createPinia()); installBrowserTestGlobals();