feat(grid): load large-value previews by viewport

This commit is contained in:
zipg authored and GitHub committed 2026-08-21 19:05:44 +08:00
1 parent 31a50fb460
commit e50bd26141
6 files changed
+721 -16

No files matched your search

+277 -4
View File
@@ -181,7 +181,20 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowSho
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { createResultScopedPendingRequests, largeValueCellKey, largeValueCellMap, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
import {
createResultScopedPendingRequests,
createResultScopedRowCache,
isTableDataVisiblePreviewColumn,
largeValueCellKey,
largeValueCellMap,
TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES,
TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS,
TABLE_DATA_VISIBLE_PREVIEW_SIZE,
tableDataLargeValuePreviewOptions,
tableDataVisiblePreviewContentBytes,
tableDataVisiblePreviewRowRange,
type ResultScopedRowCache,
} from "@/lib/dataGrid/dataGridLargeValues";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
@@ -2899,6 +2912,7 @@ function refreshGridScrollerMetrics() {
headerRef.value.scrollLeft = scrollerEl.scrollLeft;
}
observeGridHorizontalScrollbarScroller();
scheduleVisibleLargeValuePreviewHydration();
}
let columnLayoutRefreshFrame = 0;
@@ -2943,6 +2957,7 @@ function markGridScrolling() {
const scroller = gridScrollerElement();
if (scroller) updateDomGridVisibleItemsDuringScroll(scroller);
isScrolling.value = false;
scheduleVisibleLargeValuePreviewHydration();
}, 120);
}
@@ -3582,6 +3597,7 @@ const editor = useDataGridEditor({
currentPage,
cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey),
onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result),
onCellValueChanged: invalidateVisibleLargeValuePreviewCell,
prepareFullReload,
emit,
});
@@ -4330,7 +4346,7 @@ const displayRowIndexByIdLookup = computed(() => {
});
function rowItemFromDisplayRef(ref: DisplayRowRef): RowItem {
largeValueResolutionVersion.value;
void largeValueResolutionVersion.value;
if (ref.isNew) {
return {
...ref,
@@ -4523,6 +4539,19 @@ const LARGE_VALUE_FETCH_MAX_ROWS = 200;
const LARGE_VALUE_FETCH_TARGET_BYTES = 64 * 1024 * 1024;
const pendingLargeValueHydrations = createResultScopedPendingRequests<boolean>();
const largeValueCellsByKey = computed(() => largeValueCellMap(props.result));
type VisibleLargeValuePreviewRequest = {
item: RowItem;
sourceIndex: number;
initialValues: Map<number, { value: CellValue; revision: number }>;
};
const visibleLargeValuePreviewCaches = new WeakMap<QueryResult, ResultScopedRowCache<CellValue>>();
const visibleLargeValuePreviewCellRevisions = new WeakMap<QueryResult, Map<string, number>>();
const failedVisibleLargeValuePreviewResults = new WeakSet<QueryResult>();
const visibleLargeValuePreviewVersion = ref(0);
let visibleLargeValuePreviewTimer = 0;
let visibleLargeValuePreviewRequestedGeneration = 0;
let visibleLargeValuePreviewActive = true;
const visibleLargeValuePreviewExecutionIds = new Map<number, string>();
function isLargeValuePreview(item: RowItem | undefined, columnIndex: number): boolean {
if (!item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return false;
@@ -4535,7 +4564,8 @@ function largeValueOriginalBytes(item: Pick<RowItem, "sourceIndex" | "isNew" | "
}
function formatGridItemCell(item: RowItem, columnIndex: number): string {
return formatCellCached(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex));
void visibleLargeValuePreviewVersion.value;
return formatCellCached(visibleLargeValuePreviewValue(item, columnIndex, item.data[columnIndex] ?? null), columnIndex, largeValueOriginalBytes(item, columnIndex));
}
function formatGridItemCellForConfirmation(item: RowItem, columnIndex: number): string {
@@ -4556,6 +4586,248 @@ function largeValueSourceColumnIndex(columnName: string): number {
return resultSourceColumns.value.findIndex((column) => column.toLocaleLowerCase() === normalized);
}
function visibleLargeValuePreviewCache(result: QueryResult): ResultScopedRowCache<CellValue> {
let cache = visibleLargeValuePreviewCaches.get(result);
if (!cache) {
cache = createResultScopedRowCache(TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS, {
maxBytes: TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES,
sizeOf: tableDataVisiblePreviewContentBytes,
});
visibleLargeValuePreviewCaches.set(result, cache);
}
return cache;
}
function visibleLargeValuePreviewCellRevisionKey(sourceIndex: number, columnIndex: number): string {
return `${sourceIndex}:${columnIndex}`;
}
function visibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number): number {
return visibleLargeValuePreviewCellRevisions.get(result)?.get(visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex)) ?? 0;
}
function bumpVisibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number) {
let revisions = visibleLargeValuePreviewCellRevisions.get(result);
if (!revisions) {
revisions = new Map<string, number>();
visibleLargeValuePreviewCellRevisions.set(result, revisions);
}
const key = visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex);
revisions.set(key, (revisions.get(key) ?? 0) + 1);
}
function invalidateVisibleLargeValuePreviewCell(rowId: number, columnIndex: number) {
const item = getRowItem(rowId);
const sourceIndex = item?.sourceIndex ?? (rowId >= 0 ? rowId : undefined);
if (sourceIndex === undefined) return;
bumpVisibleLargeValuePreviewCellRevision(props.result, sourceIndex, columnIndex);
const cache = visibleLargeValuePreviewCaches.get(props.result);
if (!cache?.has(sourceIndex, columnIndex)) return;
cache.forget(sourceIndex, columnIndex);
visibleLargeValuePreviewVersion.value += 1;
scheduleCanvasDraw();
}
function visibleLargeValuePreviewValue(item: Pick<RowItem, "sourceIndex"> | undefined, columnIndex: number, fallback: CellValue): CellValue {
if (item?.sourceIndex === undefined) return fallback;
const preview = visibleLargeValuePreviewCaches.get(props.result)?.get(item.sourceIndex, columnIndex);
return preview === undefined ? fallback : preview;
}
function visibleLargeValuePreviewColumnIndexes(): number[] {
const rendered = renderedGridColumns.value.map((column) => column.actualColIdx);
return [...new Set(rendered)].filter((columnIndex) => {
const dataType = tableColumnForGridColumn(columnIndex)?.data_type ?? allColumnTypes.value[columnIndex] ?? "";
return isTableDataVisiblePreviewColumn(resolvedDatabaseType.value, dataType);
});
}
function evictVisibleLargeValuePreviews(cache: ResultScopedRowCache<CellValue>, protectedRows: ReadonlySet<number>): boolean {
return cache.evict(protectedRows).length > 0;
}
async function hydrateVisibleLargeValuePreviews(generation: number) {
const sourceResult = props.result;
const tableMeta = props.tableMeta;
const scroller = gridScrollerElement();
if (
!visibleLargeValuePreviewActive ||
generation !== visibleLargeValuePreviewRequestedGeneration ||
failedVisibleLargeValuePreviewResults.has(sourceResult) ||
showTranspose.value ||
(resolvedDatabaseType.value !== "mysql" && resolvedDatabaseType.value !== "postgres") ||
!props.connectionId ||
!tableMeta?.tableName ||
tableMeta.primaryKeys.length === 0 ||
!scroller
)
return;
const range = tableDataVisiblePreviewRowRange(scroller.scrollTop, scroller.clientHeight, CANVAS_DATA_GRID_ROW_HEIGHT, displayRowCount.value);
if (!range) return;
const cache = visibleLargeValuePreviewCache(sourceResult);
const activeSourceRows = new Set<number>();
const requests = new Map<number, VisibleLargeValuePreviewRequest>();
const targetColumnIndexes = visibleLargeValuePreviewColumnIndexes();
for (let displayIndex = range.start; displayIndex < range.end; displayIndex++) {
const item = displayItemAt(displayIndex);
if (!item || item.sourceIndex === undefined || item.isNew || item.isDraft) continue;
activeSourceRows.add(item.sourceIndex);
cache.touch(item.sourceIndex);
const initialValues = new Map<number, { value: CellValue; revision: number }>();
for (const columnIndex of targetColumnIndexes) {
if (!isLargeValuePreview(item, columnIndex) || cache.has(item.sourceIndex, columnIndex)) continue;
initialValues.set(columnIndex, {
value: item.data[columnIndex] ?? null,
revision: visibleLargeValuePreviewCellRevision(sourceResult, item.sourceIndex, columnIndex),
});
}
if (initialValues.size > 0) requests.set(item.sourceIndex, { item, sourceIndex: item.sourceIndex, initialValues });
}
if (requests.size === 0) {
if (evictVisibleLargeValuePreviews(cache, activeSourceRows)) {
visibleLargeValuePreviewVersion.value += 1;
scheduleCanvasDraw();
}
return;
}
const primaryKeyIndexes = tableMeta.primaryKeys.map(largeValueSourceColumnIndex);
if (primaryKeyIndexes.some((index) => index < 0)) return;
const requestedColumnIndexes = [...new Set([...requests.values()].flatMap((request) => [...request.initialValues.keys()]))];
const selectedColumns = [...tableMeta.primaryKeys];
for (const columnIndex of requestedColumnIndexes) {
const sourceColumn = resultSourceColumns.value[columnIndex];
if (sourceColumn && !selectedColumns.some((column) => column.toLocaleLowerCase() === sourceColumn.toLocaleLowerCase())) selectedColumns.push(sourceColumn);
}
const selectedColumnTypes = selectedColumns.map((column) => tableMeta.columns.find((candidate) => candidate.name.toLocaleLowerCase() === column.toLocaleLowerCase())?.data_type ?? "");
const predicates = await Promise.all([...requests.values()].map((request) => largeValueRowPredicate(request.item, primaryKeyIndexes)));
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
const sql = await buildTableSelectSql({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
catalog: tableMeta.catalog,
columns: selectedColumns,
columnTypes: selectedColumnTypes,
largeValuePreviewSize: TABLE_DATA_VISIBLE_PREVIEW_SIZE,
primaryKeys: tableMeta.primaryKeys,
whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "),
limit: requests.size,
offset: 0,
});
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
const connection = connectionStore.getConfig(props.connectionId);
const executionId = uuid();
visibleLargeValuePreviewExecutionIds.set(generation, executionId);
let results: QueryResult[];
try {
results = await api.executeMulti(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, undefined, executionId, {
maxRows: requests.size,
fetchSize: requests.size,
resultKeyColumns: tableMeta.primaryKeys,
tableDataPreview: true,
timeoutSecs: queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs),
});
} finally {
if (visibleLargeValuePreviewExecutionIds.get(generation) === executionId) visibleLargeValuePreviewExecutionIds.delete(generation);
}
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
const result = results[0];
if (!result || result.execution_error) {
throw new Error(result?.error ? translateBackendError(t, result.error) : String(result?.rows?.[0]?.[0] ?? t("grid.largeValueLoadFailed")));
}
const resultColumnIndexes = selectedColumns.map((column) => {
const normalized = column.toLocaleLowerCase();
return result.columns.findIndex((resultColumn) => resultColumn.toLocaleLowerCase() === normalized);
});
if (resultColumnIndexes.some((index) => index < 0)) throw new Error(t("grid.largeValueColumnUnavailable"));
const resultPrimaryKeyIndexes = resultColumnIndexes.slice(0, tableMeta.primaryKeys.length);
const valuesByIdentity = new Map(result.rows.map((row) => [largeValueIdentityKey(row, resultPrimaryKeyIndexes), row]));
const currentLargeValueCells = largeValueCellMap(sourceResult);
let changed = false;
for (const request of requests.values()) {
const currentRow = sourceResult.rows[request.sourceIndex];
if (!currentRow) continue;
const identity = largeValueIdentityKey(currentRow, primaryKeyIndexes);
const resolvedRow = valuesByIdentity.get(identity);
if (!resolvedRow) continue;
for (const [columnIndex, initialValue] of request.initialValues) {
if (!currentLargeValueCells.has(largeValueCellKey(request.sourceIndex, columnIndex)) || currentRow[columnIndex] !== initialValue.value || visibleLargeValuePreviewCellRevision(sourceResult, request.sourceIndex, columnIndex) !== initialValue.revision) continue;
const sourceColumn = resultSourceColumns.value[columnIndex];
const selectedIndex = selectedColumns.findIndex((column) => column.toLocaleLowerCase() === sourceColumn?.toLocaleLowerCase());
const resultIndex = resultColumnIndexes[selectedIndex];
if (resultIndex === undefined || resultIndex < 0) continue;
const previewValue = resolvedRow[resultIndex] ?? null;
cache.remember(request.sourceIndex, columnIndex, previewValue);
changed = true;
}
}
if (evictVisibleLargeValuePreviews(cache, activeSourceRows)) changed = true;
if (!changed) return;
visibleLargeValuePreviewVersion.value += 1;
scheduleCanvasDraw();
}
async function runVisibleLargeValuePreviewHydration(generation: number) {
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration) return;
const sourceResult = props.result;
try {
await hydrateVisibleLargeValuePreviews(generation);
} catch (error) {
if (props.result === sourceResult && generation === visibleLargeValuePreviewRequestedGeneration && !failedVisibleLargeValuePreviewResults.has(sourceResult)) {
failedVisibleLargeValuePreviewResults.add(sourceResult);
appendDebugLog("warn", "[DBX][DataGrid:visible-large-value-preview] disabled for result", error);
}
}
}
function cancelVisibleLargeValuePreviewHydrations() {
for (const [generation, executionId] of visibleLargeValuePreviewExecutionIds) {
visibleLargeValuePreviewExecutionIds.delete(generation);
void api.cancelQuery(executionId).catch((error) => appendDebugLog("warn", "[DBX][DataGrid:visible-large-value-preview] cancel failed", error));
}
}
function scheduleVisibleLargeValuePreviewHydration(delay = 150) {
if (!visibleLargeValuePreviewActive) return;
const generation = ++visibleLargeValuePreviewRequestedGeneration;
cancelVisibleLargeValuePreviewHydrations();
window.clearTimeout(visibleLargeValuePreviewTimer);
visibleLargeValuePreviewTimer = window.setTimeout(() => {
visibleLargeValuePreviewTimer = 0;
void runVisibleLargeValuePreviewHydration(generation);
}, delay);
}
function pauseVisibleLargeValuePreviewHydration() {
visibleLargeValuePreviewActive = false;
visibleLargeValuePreviewRequestedGeneration += 1;
cancelVisibleLargeValuePreviewHydrations();
window.clearTimeout(visibleLargeValuePreviewTimer);
visibleLargeValuePreviewTimer = 0;
}
function resumeVisibleLargeValuePreviewHydration() {
visibleLargeValuePreviewActive = true;
scheduleVisibleLargeValuePreviewHydration();
}
watch(
() => [props.result, props.result.rows.length, renderedGridColumns.value.map((column) => column.actualColIdx).join(","), showTranspose.value] as const,
() => {
nextTick(() => scheduleVisibleLargeValuePreviewHydration());
},
{ immediate: true },
);
onActivated(resumeVisibleLargeValuePreviewHydration);
onDeactivated(pauseVisibleLargeValuePreviewHydration);
dataGridRuntimeScope.addCleanup(pauseVisibleLargeValuePreviewHydration);
function chunkLargeValueRequests(requests: LargeValueCellRequest[]): LargeValueCellRequest[][] {
const chunks: LargeValueCellRequest[][] = [];
let chunk: LargeValueCellRequest[] = [];
@@ -4717,6 +4989,7 @@ async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promis
const rows = sourceResult.rows.slice();
rows[item.sourceIndex!] = row;
sourceResult.rows = rows;
visibleLargeValuePreviewCaches.get(sourceResult)?.forget(item.sourceIndex!, columnIndex);
sourceResult.large_value_cells = sourceResult.large_value_cells?.filter((cell) => cell.row_index !== item.sourceIndex || cell.column_index !== columnIndex);
largeValueResolutionVersion.value += 1;
clearCellFormatCache();
@@ -6816,7 +7089,7 @@ function drawCanvasGrid() {
editingCell: editingCell.value,
searchMatchKeys: searchMatchSet.value,
currentSearchMatch: currentSearchMatch.value,
formatCell: (value, columnIndex, row) => formatCellCached(value, columnIndex, largeValueOriginalBytes(row, columnIndex)),
formatCell: (value, columnIndex, row) => formatCellCached(visibleLargeValuePreviewValue(row, columnIndex, value), columnIndex, largeValueOriginalBytes(row, columnIndex)),
draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"),
isRowActive,
rowCellsUseSelectionVisual,
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
buildDataGridContextFilterCondition: vi.fn(async ({ columnName, value }: { columnName: string; value: unknown }) => `\`${columnName}\` = '${String(value)}'`),
buildTableSelectSql: vi.fn(async ({ whereInput }: { whereInput?: string }) => `SELECT payload FROM events${whereInput ? ` WHERE ${whereInput}` : ""}`),
executeMulti: vi.fn(),
cancelQuery: vi.fn(),
toast: vi.fn(),
}));
@@ -18,6 +19,7 @@ vi.mock("@/lib/backend/api", () => ({
buildDataGridContextFilterCondition: mocks.buildDataGridContextFilterCondition,
buildTableSelectSql: mocks.buildTableSelectSql,
executeMulti: mocks.executeMulti,
cancelQuery: mocks.cancelQuery,
}));
vi.mock("@/composables/useToast", () => ({
@@ -84,10 +86,44 @@ function hydratedResult(id: number, value: string): QueryResult {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve };
return { promise, resolve, reject };
}
function mockDeferredFullHydration(hydration: Promise<QueryResult[]>) {
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
const options = args[5] as { tableDataPreview?: boolean } | undefined;
return options?.tableDataPreview ? Promise.resolve([hydratedResult(1, "visible preview")]) : hydration;
});
}
function fullHydrationCallCount() {
return mocks.executeMulti.mock.calls.filter((call) => !(call[5] as { tableDataPreview?: boolean } | undefined)?.tableDataPreview).length;
}
function visibleHydrationCalls() {
return mocks.executeMulti.mock.calls.filter((call) => (call[5] as { tableDataPreview?: boolean } | undefined)?.tableDataPreview);
}
function gridCell(host: HTMLElement): HTMLElement {
const cell = host.querySelector<HTMLElement>('[data-row-index="0"] [data-visible-col-index="1"]');
if (!cell) throw new Error("Large-value grid cell not found");
return cell;
}
function pasteGridCell(host: HTMLElement, value: string) {
const cell = gridCell(host);
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { getData: () => value } });
const gridRoot = host.querySelector<HTMLElement>("[data-grid-root]");
if (!gridRoot) throw new Error("Data grid root not found");
gridRoot.dispatchEvent(paste);
}
function mountGrid(initialResult = largeValueResult()) {
@@ -137,8 +173,19 @@ function mountGrid(initialResult = largeValueResult()) {
// Mount once with the production-default Canvas mode so DataGrid finishes
// setting up its column-width dependencies, then exercise the DOM grid.
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
mountedApps.push({ app, host });
return { host, onExecuteSql, replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)) };
const mounted = { app, host };
mountedApps.push(mounted);
return {
host,
onExecuteSql,
replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)),
unmount: () => {
const index = mountedApps.indexOf(mounted);
if (index >= 0) mountedApps.splice(index, 1);
app.unmount();
host.remove();
},
};
}
async function settle() {
@@ -166,6 +213,7 @@ async function startEqualsFilter(host: HTMLElement) {
beforeEach(() => {
vi.clearAllMocks();
mocks.cancelQuery.mockResolvedValue(true);
});
afterEach(() => {
@@ -179,11 +227,11 @@ afterEach(() => {
describe("DataGrid context filter lifecycle", () => {
it("keeps the right-click target through menu close and large-value hydration", async () => {
const hydration = deferred<QueryResult[]>();
mocks.executeMulti.mockReturnValue(hydration.promise);
mockDeferredFullHydration(hydration.promise);
const { host, onExecuteSql } = mountGrid();
await startEqualsFilter(host);
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fullHydrationCallCount()).toBe(1));
expect(document.querySelector("[data-dbx-context-menu]")).toBeNull();
hydration.resolve([hydratedResult(1, "full original value")]);
@@ -196,11 +244,11 @@ describe("DataGrid context filter lifecycle", () => {
it("does not apply a completed hydration from a previous result set", async () => {
const hydration = deferred<QueryResult[]>();
mocks.executeMulti.mockReturnValue(hydration.promise);
mockDeferredFullHydration(hydration.promise);
const { host, onExecuteSql, replaceResult } = mountGrid();
await startEqualsFilter(host);
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fullHydrationCallCount()).toBe(1));
replaceResult(largeValueResult(2, "new preview"));
await settle();
hydration.resolve([hydratedResult(1, "old value")]);
@@ -224,3 +272,127 @@ describe("DataGrid context filter lifecycle", () => {
expect(onExecuteSql).not.toHaveBeenCalled();
});
});
describe("DataGrid visible large-value preview lifecycle", () => {
it("invalidates a hydrated preview when paste edits the selected cell", async () => {
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
const options = args[5] as { tableDataPreview?: boolean } | undefined;
return Promise.resolve([hydratedResult(1, options?.tableDataPreview ? "visible preview" : "full value")]);
});
const { host } = mountGrid();
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("visible preview"));
pasteGridCell(host, "edited value");
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
});
it("cancels a stale viewport request and starts the newest generation", async () => {
const firstHydration = deferred<QueryResult[]>();
let visibleRequestCount = 0;
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
const options = args[5] as { tableDataPreview?: boolean } | undefined;
if (!options?.tableDataPreview) return Promise.resolve([hydratedResult(1, "full value")]);
visibleRequestCount += 1;
return visibleRequestCount === 1 ? firstHydration.promise : Promise.resolve([hydratedResult(1, "latest preview")]);
});
mocks.cancelQuery.mockImplementation(async () => {
firstHydration.reject(new Error("cancelled"));
return true;
});
const { host } = mountGrid();
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
const firstExecutionId = visibleHydrationCalls()[0]?.[4];
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
if (!scroller) throw new Error("Data grid scroller not found");
scroller.scrollTop = 26;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await vi.waitFor(() => expect(mocks.cancelQuery).toHaveBeenCalledWith(firstExecutionId));
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(2));
expect(visibleHydrationCalls()[1]?.[4]).not.toBe(firstExecutionId);
});
it("does not cache a visible preview completed after the cell is edited", async () => {
const hydration = deferred<QueryResult[]>();
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
const options = args[5] as { tableDataPreview?: boolean } | undefined;
return options?.tableDataPreview ? hydration.promise : Promise.resolve([hydratedResult(1, "full value")]);
});
const { host } = mountGrid();
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
pasteGridCell(host, "edited during hydration");
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited during hydration"));
hydration.resolve([hydratedResult(1, "stale visible preview")]);
await settle();
expect(gridCell(host).textContent).toContain("edited during hydration");
expect(gridCell(host).textContent).not.toContain("stale visible preview");
});
it("invalidates hydrated previews across undo, scroll, and redo", async () => {
let visibleRequestCount = 0;
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
const options = args[5] as { tableDataPreview?: boolean } | undefined;
if (!options?.tableDataPreview) return Promise.resolve([hydratedResult(1, "full value")]);
visibleRequestCount += 1;
return Promise.resolve([hydratedResult(1, visibleRequestCount === 1 ? "initial visible preview" : "undo visible preview")]);
});
const { host } = mountGrid();
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("initial visible preview"));
pasteGridCell(host, "edited value");
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
const gridRoot = host.querySelector<HTMLElement>("[data-grid-root]");
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
if (!gridRoot || !scroller) throw new Error("Data grid controls not found");
gridRoot.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "z" }));
await settle();
scroller.scrollTop = 26;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("undo visible preview"));
gridRoot.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, shiftKey: true, key: "z" }));
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
expect(gridCell(host).textContent).not.toContain("undo visible preview");
});
it("does not start a stale query after deferred SQL construction and scrolling", async () => {
const firstBuild = deferred<string>();
mocks.buildTableSelectSql.mockImplementationOnce(() => firstBuild.promise).mockResolvedValueOnce("SELECT latest preview");
mocks.executeMulti.mockResolvedValue([hydratedResult(1, "latest visible preview")]);
const { host } = mountGrid();
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
if (!scroller) throw new Error("Data grid scroller not found");
scroller.scrollTop = 26;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
firstBuild.resolve("SELECT stale preview");
await settle();
expect(visibleHydrationCalls()).toHaveLength(1);
expect(visibleHydrationCalls()[0]?.[2]).toBe("SELECT latest preview");
});
it("does not start a stale query after deferred SQL construction and unmount", async () => {
const build = deferred<string>();
mocks.buildTableSelectSql.mockImplementationOnce(() => build.promise);
const { unmount } = mountGrid();
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
unmount();
build.resolve("SELECT stale preview");
await settle();
expect(visibleHydrationCalls()).toHaveLength(0);
});
});
@@ -30,11 +30,11 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[]) {
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[], existingRows: CellValue[][] = [], onCellValueChanged?: (rowId: number, columnIndex: number) => void) {
let editor: ReturnType<typeof useDataGridEditor>;
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
columns: ["first", "hidden", "last"],
rows: [],
rows: existingRows,
});
editor = useDataGridEditor({
@@ -65,6 +65,7 @@ function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerou
pageSize: ref(100),
currentPage: ref(1),
cacheKey: computed(() => cacheKey),
onCellValueChanged,
getRowItem: (rowId) => {
if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) {
return {
@@ -77,6 +78,20 @@ function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerou
status: "draft",
};
}
if (rowId >= 0) {
const row = result.value.rows[rowId];
if (!row) return undefined;
const changes = editor.dirtyRows.value.get(rowId);
return {
id: rowId,
sourceIndex: rowId,
data: row.map((value, columnIndex) => (changes?.has(columnIndex) ? (changes.get(columnIndex) ?? null) : value)),
isNew: false,
isDeleted: false,
isDirtyCol: row.map((_, columnIndex) => changes?.has(columnIndex) ?? false),
status: changes?.size ? "edited" : "normal",
};
}
const newIndex = -rowId - 1;
const row = editor.newRows.value[newIndex];
if (!row) return undefined;
@@ -210,6 +225,40 @@ describe("useDataGridEditor row deletion confirmation", () => {
});
});
describe("useDataGridEditor cell mutation notifications", () => {
it("notifies cache owners for batched paste, NULL, and restore changes", () => {
const onCellValueChanged = vi.fn();
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]], onCellValueChanged);
editor.beginBatch();
editor.applyCellValue(0, 1, "pasted");
editor.applyCellValue(0, 1, null);
editor.commitBatch();
editor.restoreCellValue(0, 1);
expect(onCellValueChanged.mock.calls).toEqual([
[0, 1],
[0, 1],
[0, 1],
]);
});
it("notifies cache owners when undo and redo replace dirty cells", () => {
const onCellValueChanged = vi.fn();
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]], onCellValueChanged);
editor.applyCellValue(0, 1, "edited");
onCellValueChanged.mockClear();
editor.undoPendingChange();
editor.redoPendingChange();
expect(onCellValueChanged.mock.calls).toEqual([
[0, 1],
[0, 1],
]);
});
});
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
beforeEach(() => {
mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" });
@@ -117,6 +117,7 @@ export interface UseDataGridEditorOptions {
cacheKey?: ComputedRef<string | undefined>;
/** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */
onResultPayloadMutated?: () => void;
onCellValueChanged?: (rowId: number, columnIndex: number) => void;
prepareFullReload?: () => void;
emit: {
(event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number): void;
@@ -229,6 +230,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
pageSize,
currentPage,
cacheKey,
onCellValueChanged,
} = options;
const editingCell = ref<{ rowId: number; col: number } | null>(null);
@@ -405,14 +407,27 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function restorePendingChangesSnapshot(snapshot: PendingChangesHistorySnapshot) {
const previousDirtyRows = dirtyRows.value;
const restoredDirtyRows = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
newRows.value = snapshot.newRows.map((row) => [...row]);
restoreNewRowMeta(snapshot.newRowMeta ?? []);
quickEntryDraftRow.value = snapshot.quickEntryDraftRow ? [...snapshot.quickEntryDraftRow] : emptyDraftRow();
dirtyRows.value = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
dirtyRows.value = restoredDirtyRows;
deletedRows.value = new Set(snapshot.deletedRows);
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
queuedAutoSaveChanges.clear();
editingCell.value = null;
for (const rowIndex of new Set([...previousDirtyRows.keys(), ...restoredDirtyRows.keys()])) {
const previousChanges = previousDirtyRows.get(rowIndex);
const restoredChanges = restoredDirtyRows.get(rowIndex);
for (const columnIndex of new Set([...(previousChanges?.keys() ?? []), ...(restoredChanges?.keys() ?? [])])) {
const previousHasValue = previousChanges?.has(columnIndex) ?? false;
const restoredHasValue = restoredChanges?.has(columnIndex) ?? false;
if (previousHasValue !== restoredHasValue || previousChanges?.get(columnIndex) !== restoredChanges?.get(columnIndex)) {
onCellValueChanged?.(rowIndex, columnIndex);
}
}
}
touchPendingChanges();
}
@@ -838,6 +853,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (dataGridQuickEntryEnabled.value && isSaving.value && changed) {
rememberQueuedAutoSaveChange(item.sourceIndex, col, newVal);
}
if (changed) onCellValueChanged?.(rowId, col);
return changed ? { changed: true, rowKind: "existing" } : { changed: false, rowKind: "existing" };
}
@@ -956,6 +972,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
}
onCellValueChanged?.(rowId, col);
}
function restoreCellValue(rowId: number, col: number) {
@@ -996,6 +1013,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (rowChanges.size === 0) dirtyRows.value.delete(item.sourceIndex);
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
onCellValueChanged?.(rowId, col);
}
function cancelEdit() {
@@ -1,5 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { appendLargeValueCells, canUseTableDataLargeValuePreview, createResultScopedPendingRequests, largeValueCellMap, remapLargeValueCells, TABLE_DATA_CELL_PREVIEW_SIZE, TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
import {
appendLargeValueCells,
canUseTableDataLargeValuePreview,
createResultScopedPendingRequests,
createResultScopedRowCache,
isTableDataVisiblePreviewColumn,
largeValueCellMap,
remapLargeValueCells,
TABLE_DATA_CELL_PREVIEW_SIZE,
TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES,
tableDataLargeValuePreviewOptions,
tableDataVisiblePreviewContentBytes,
tableDataVisiblePreviewRowRange,
} from "@/lib/dataGrid/dataGridLargeValues";
import { buildDataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import type { ColumnInfo } from "@/types/database";
@@ -66,6 +79,59 @@ describe("data grid large-value metadata", () => {
}
});
it("selects the visible rows plus a bounded viewport buffer", () => {
expect(tableDataVisiblePreviewRowRange(26 * 50, 26 * 20, 26, 10_000)).toEqual({ start: 30, end: 90 });
expect(tableDataVisiblePreviewRowRange(0, 26 * 20, 26, 10_000)).toEqual({ start: 0, end: 60 });
expect(tableDataVisiblePreviewRowRange(26 * 9_990, 26 * 20, 26, 10_000)).toEqual({ start: 9_940, end: 10_000 });
expect(tableDataVisiblePreviewRowRange(0, 0, 26, 0)).toBeNull();
});
it("hydrates only textual large-value types in the visible area", () => {
expect(isTableDataVisiblePreviewColumn("mysql", "longtext")).toBe(true);
expect(isTableDataVisiblePreviewColumn("mysql", "varchar(8000)")).toBe(true);
expect(isTableDataVisiblePreviewColumn("mysql", "longblob")).toBe(false);
expect(isTableDataVisiblePreviewColumn("postgres", "character varying(8000)")).toBe(true);
expect(isTableDataVisiblePreviewColumn("postgres", "jsonb")).toBe(true);
expect(isTableDataVisiblePreviewColumn("postgres", "text[]")).toBe(false);
expect(isTableDataVisiblePreviewColumn("postgres", "vector(1536)")).toBe(false);
expect(isTableDataVisiblePreviewColumn("oracle", "clob")).toBe(false);
});
it("evicts old preview rows while retaining the active viewport", () => {
const cache = createResultScopedRowCache<string>(2);
cache.remember(1, 2, "one");
cache.remember(2, 2, "two");
cache.remember(3, 2, "three");
expect(cache.get(1, 2)).toBe("one");
expect(cache.evict(new Set([1]))).toEqual([{ rowIndex: 2, columnIndex: 2, value: "two" }]);
expect(cache.has(1, 2)).toBe(true);
expect(cache.has(2, 2)).toBe(false);
expect(cache.has(3, 2)).toBe(true);
cache.forget(1, 2);
expect(cache.has(1, 2)).toBe(false);
});
it("bounds cached preview content while preserving the newest active rows", () => {
const cache = createResultScopedRowCache<string>(10, { maxBytes: 6, sizeOf: (value) => value.length });
cache.remember(1, 1, "one");
cache.remember(2, 1, "two");
cache.remember(3, 1, "four");
expect(cache.evict(new Set([3]))).toEqual([
{ rowIndex: 1, columnIndex: 1, value: "one" },
{ rowIndex: 2, columnIndex: 1, value: "two" },
]);
expect(cache.has(3, 1)).toBe(true);
const oversizedActive = createResultScopedRowCache<string>(10, { maxBytes: 3, sizeOf: (value) => value.length });
oversizedActive.remember(1, 1, "four");
expect(oversizedActive.evict(new Set([1]))).toEqual([{ rowIndex: 1, columnIndex: 1, value: "four" }]);
expect(oversizedActive.has(1, 1)).toBe(false);
expect(tableDataVisiblePreviewContentBytes("🙂")).toBe(4);
});
it("uses the active MySQL page budget to select bounded preview columns", () => {
const columns = [column("id", "int", true), column("image_mime", "varchar(64)"), column("image_data", "longblob"), column("image_url", "varchar(512)"), column("image_note", "varchar(255)")];
@@ -1,16 +1,143 @@
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
import type { CellValue } from "@/lib/dataGrid/cellValue";
export const TABLE_DATA_RESULT_MAX_BYTES = 32 * 1024 * 1024;
export const TABLE_DATA_CELL_PREVIEW_MIN_SIZE = 1;
export const TABLE_DATA_CELL_PREVIEW_SIZE = 8 * 1024;
export const TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES = 24 * 1024 * 1024;
export const TABLE_DATA_TEXT_SERIALIZED_BYTES_PER_CHARACTER = 6;
export const TABLE_DATA_VISIBLE_PREVIEW_SIZE = 512;
export const TABLE_DATA_VISIBLE_PREVIEW_MAX_ROWS = 100;
export const TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS = 2_000;
export const TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES = 16 * 1024 * 1024;
const TABLE_DATA_LARGE_VALUE_MARKER_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
export interface TableDataVisiblePreviewRowRange {
start: number;
end: number;
}
export interface ResultScopedRowCache<T> {
has(rowIndex: number, columnIndex: number): boolean;
get(rowIndex: number, columnIndex: number): T | undefined;
touch(rowIndex: number): void;
remember(rowIndex: number, columnIndex: number, value: T): void;
forget(rowIndex: number, columnIndex: number): void;
evict(protectedRows?: ReadonlySet<number>): Array<{ rowIndex: number; columnIndex: number; value: T }>;
}
export function tableDataVisiblePreviewRowRange(scrollTop: number, viewportHeight: number, rowHeight: number, rowCount: number, maxRows = TABLE_DATA_VISIBLE_PREVIEW_MAX_ROWS): TableDataVisiblePreviewRowRange | null {
if (rowHeight <= 0 || rowCount <= 0 || maxRows <= 0) return null;
const firstVisible = Math.max(0, Math.min(rowCount - 1, Math.floor(Math.max(0, scrollTop) / rowHeight)));
const visibleRows = Math.max(1, Math.ceil(Math.max(rowHeight, viewportHeight) / rowHeight));
const targetRows = Math.min(rowCount, maxRows, visibleRows * 3);
const rowsBefore = Math.floor(Math.max(0, targetRows - visibleRows) / 2);
let start = Math.max(0, firstVisible - rowsBefore);
const end = Math.min(rowCount, start + targetRows);
start = Math.max(0, end - targetRows);
return { start, end };
}
export function tableDataVisiblePreviewContentBytes(value: CellValue): number {
if (typeof value === "string") return value.length * 2;
if (typeof value === "number") return 8;
if (typeof value === "boolean") return 4;
return 0;
}
export function createResultScopedRowCache<T>(maxRows: number, options: { maxBytes?: number; sizeOf?: (value: T) => number } = {}): ResultScopedRowCache<T> {
const rows = new Map<number, Map<number, T>>();
const rowBytes = new Map<number, number>();
const maxCachedRows = Math.max(0, maxRows);
const maxCachedBytes = Math.max(0, options.maxBytes ?? Number.POSITIVE_INFINITY);
const sizeOf = options.sizeOf ?? (() => 0);
let cachedBytes = 0;
function touch(rowIndex: number, values: Map<number, T>) {
rows.delete(rowIndex);
rows.set(rowIndex, values);
}
function removeRow(rowIndex: number, values: Map<number, T>, evicted: Array<{ rowIndex: number; columnIndex: number; value: T }>) {
rows.delete(rowIndex);
cachedBytes = Math.max(0, cachedBytes - (rowBytes.get(rowIndex) ?? 0));
rowBytes.delete(rowIndex);
for (const [columnIndex, value] of values) evicted.push({ rowIndex, columnIndex, value });
}
function exceedsLimit(): boolean {
return rows.size > maxCachedRows || cachedBytes > maxCachedBytes;
}
return {
has(rowIndex, columnIndex) {
return rows.get(rowIndex)?.has(columnIndex) ?? false;
},
get(rowIndex, columnIndex) {
return rows.get(rowIndex)?.get(columnIndex);
},
touch(rowIndex) {
const values = rows.get(rowIndex);
if (values) touch(rowIndex, values);
},
remember(rowIndex, columnIndex, value) {
const values = rows.get(rowIndex) ?? new Map<number, T>();
if (!values.has(columnIndex)) {
values.set(columnIndex, value);
const valueBytes = Math.max(0, sizeOf(value));
cachedBytes += valueBytes;
rowBytes.set(rowIndex, (rowBytes.get(rowIndex) ?? 0) + valueBytes);
}
touch(rowIndex, values);
},
forget(rowIndex, columnIndex) {
const values = rows.get(rowIndex);
if (!values) return;
const value = values.get(columnIndex);
if (value === undefined && !values.has(columnIndex)) return;
values.delete(columnIndex);
const valueBytes = Math.max(0, sizeOf(value as T));
cachedBytes = Math.max(0, cachedBytes - valueBytes);
const remainingRowBytes = Math.max(0, (rowBytes.get(rowIndex) ?? 0) - valueBytes);
if (values.size === 0) {
rows.delete(rowIndex);
rowBytes.delete(rowIndex);
} else {
rowBytes.set(rowIndex, remainingRowBytes);
}
},
evict(protectedRows = new Set<number>()) {
const evicted: Array<{ rowIndex: number; columnIndex: number; value: T }> = [];
for (const [rowIndex, values] of rows) {
if (!exceedsLimit()) break;
if (protectedRows.has(rowIndex)) continue;
removeRow(rowIndex, values, evicted);
}
for (const [rowIndex, values] of rows) {
if (!exceedsLimit()) break;
removeRow(rowIndex, values, evicted);
}
return evicted;
},
};
}
function normalizedDataTypeBase(dataType: string): string {
return dataType.trim().split(/[([]/, 1)[0]?.trim().toLocaleLowerCase() ?? "";
}
export function isTableDataVisiblePreviewColumn(databaseType: DatabaseType | undefined, dataType: string): boolean {
const normalized = dataType.trim().toLocaleLowerCase();
const base = normalizedDataTypeBase(dataType);
if (databaseType === "mysql") {
return base === "text" || base === "tinytext" || base === "mediumtext" || base === "longtext" || base === "varchar" || base === "json";
}
if (databaseType === "postgres") {
return !normalized.includes("[") && (base === "char" || base === "character" || base === "varchar" || base === "text" || base === "citext" || base === "name" || base === "xml" || base === "json" || base === "jsonb" || base === "tsvector" || normalized.startsWith("character varying"));
}
return false;
}
function declaredDataTypeLength(dataType: string): number | undefined {
const match = dataType.match(/\(\s*(\d+)/);
if (!match?.[1]) return undefined;