feat(structure): persist local column order

This commit is contained in:
zipg authored and GitHub committed 2026-07-12 12:06:49 +08:00
1 parent c3ec7fceef
commit 0650428d1c
13 files changed
+278 -9

No files matched your search

@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
});
}
describe("table data grid column order storage", () => {
beforeEach(installLocalStorage);
afterEach(() => vi.unstubAllGlobals());
it("stores an order independently from the current result column signature", () => {
const scopeKey = tableDataGridColumnOrderScopeKey({
connectionId: "sqlserver-1",
database: "sales",
schema: "core",
tableName: "products",
});
const order = ["name\u00000", "id\u00000"];
saveTableDataGridColumnOrder(scopeKey, order);
expect(loadTableDataGridColumnOrder(scopeKey)).toEqual(order);
});
it("isolates tables and removes a saved order", () => {
const products = tableDataGridColumnOrderScopeKey({ connectionId: "sqlserver-1", database: "sales", schema: "core", tableName: "products" });
const orders = tableDataGridColumnOrderScopeKey({ connectionId: "sqlserver-1", database: "sales", schema: "core", tableName: "orders" });
saveTableDataGridColumnOrder(products, ["name\u00000", "id\u00000"]);
expect(loadTableDataGridColumnOrder(orders)).toEqual([]);
removeTableDataGridColumnOrder(products);
expect(loadTableDataGridColumnOrder(products)).toEqual([]);
});
it("normalizes a missing schema to the database namespace", () => {
const withoutSchema = tableDataGridColumnOrderScopeKey({ connectionId: "sqlite-1", database: "main", tableName: "products" });
const explicitMainSchema = tableDataGridColumnOrderScopeKey({ connectionId: "sqlite-1", database: "main", schema: "main", tableName: "products" });
expect(withoutSchema).toBe(explicitMainSchema);
});
it("notifies other open views when a table order changes", () => {
const dispatchEvent = vi.fn();
vi.stubGlobal("window", { dispatchEvent });
notifyTableDataGridColumnOrderChanged("table-scope");
expect(dispatchEvent).toHaveBeenCalledOnce();
const event = dispatchEvent.mock.calls[0]?.[0] as CustomEvent;
expect(event.type).toBe(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT);
expect(event.detail).toEqual({ scopeKey: "table-scope" });
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
describe("tableStructureCapabilities", () => {
it("uses table rebuilds only for native SQLite connections", () => {
@@ -27,4 +27,39 @@ describe("tableStructureCapabilities", () => {
expect(getTableStructureCapabilities("mysql", "mysql").alterStrategy).toBe("direct");
expect(getTableStructureCapabilities("postgres", "postgres").alterStrategy).toBe("direct");
});
it("uses local-only column reordering for editable databases without physical reorder support", () => {
for (const databaseType of ["sqlserver", "postgres", "sqlite", "oracle", "dameng", "duckdb", "informix"] as const) {
expect(supportsLocalTableColumnReorder(databaseType, databaseType)).toBe(true);
}
for (const databaseType of ["mysql", "gbase", "clickhouse"] as const) {
expect(supportsLocalTableColumnReorder(databaseType, databaseType)).toBe(false);
}
expect(supportsLocalTableColumnReorder("influxdb", "influxdb")).toBe(false);
});
it("does not treat local-only reordering as a database structure change", () => {
expect(isPhysicalTableColumnOrderChange("sqlserver", "sqlserver", 0, 2)).toBe(false);
expect(isPhysicalTableColumnOrderChange("postgres", "postgres", 0, 2)).toBe(false);
expect(isPhysicalTableColumnOrderChange("mysql", "mysql", 0, 2)).toBe(true);
});
it("detects local order changes including newly added columns", () => {
const first = { original: {}, originalPosition: 0 };
const second = { original: {}, originalPosition: 1 };
const added = {};
expect(hasLocalTableColumnOrderChange([first, second, added])).toBe(false);
expect(hasLocalTableColumnOrderChange([first, added, second])).toBe(true);
expect(hasLocalTableColumnOrderChange([second, first, added])).toBe(true);
});
it("ignores dropped columns when comparing local order", () => {
const first = { original: {}, originalPosition: 0 };
const dropped = { original: {}, originalPosition: 1, markedForDrop: true };
const third = { original: {}, originalPosition: 2 };
expect(hasLocalTableColumnOrderChange([first, dropped, third])).toBe(false);
});
});
@@ -1,8 +1,15 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
const TABLE_STORAGE_PREFIX = "dbx-data-grid-table-column-order:";
const STORAGE_VERSION = 1;
export const TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT = "dbx:table-data-grid-column-order-changed";
export interface TableDataGridColumnOrderChangedDetail {
scopeKey: string;
}
export interface DataGridColumnLayoutScope {
connectionId?: string;
database?: string;
@@ -21,6 +28,18 @@ interface StoredDataGridColumnLayout {
order: string[];
}
export interface TableDataGridColumnOrderScope {
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
interface StoredTableDataGridColumnOrder {
version: number;
order: string[];
}
function normalizeSql(sql?: string): string {
return (sql ?? "").replace(/\s+/g, " ").trim();
}
@@ -56,3 +75,37 @@ export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly s
export function removeDataGridColumnOrder(scopeKey: string) {
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
}
export function tableDataGridColumnOrderScopeKey(scope: TableDataGridColumnOrderScope): string {
const namespace = scope.schema?.trim() || scope.database;
return [scope.connectionId, scope.database, namespace, scope.tableName].join("\u0001");
}
export function loadTableDataGridColumnOrder(scopeKey: string): string[] {
const raw = safeLocalStorageGet(`${TABLE_STORAGE_PREFIX}${scopeKey}`);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<StoredTableDataGridColumnOrder>;
if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return [];
return parsed.order.filter((key): key is string => typeof key === "string");
} catch {
return [];
}
}
export function saveTableDataGridColumnOrder(scopeKey: string, order: readonly string[]) {
const payload: StoredTableDataGridColumnOrder = {
version: STORAGE_VERSION,
order: [...order],
};
safeLocalStorageSet(`${TABLE_STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
}
export function removeTableDataGridColumnOrder(scopeKey: string) {
safeLocalStorageRemove(`${TABLE_STORAGE_PREFIX}${scopeKey}`);
}
export function notifyTableDataGridColumnOrderChanged(scopeKey: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<TableDataGridColumnOrderChangedDetail>(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, { detail: { scopeKey } }));
}
@@ -339,6 +339,23 @@ export function canEditTableStructure(dbType?: DatabaseType): boolean {
return caps.createTable || caps.addColumn || caps.alterExistingColumn || caps.createIndex || caps.dropIndex;
}
export function supportsLocalTableColumnReorder(dbType?: DatabaseType, connectionDbType?: DatabaseType): boolean {
const caps = getTableStructureCapabilities(dbType, connectionDbType);
return canEditTableStructure(dbType) && !caps.reorderColumn;
}
export function isPhysicalTableColumnOrderChange(dbType: DatabaseType | undefined, connectionDbType: DatabaseType | undefined, originalPosition: number | undefined, currentPosition: number): boolean {
return getTableStructureCapabilities(dbType, connectionDbType).reorderColumn && originalPosition !== currentPosition;
}
export function hasLocalTableColumnOrderChange(columns: readonly { originalPosition?: number; original?: unknown; markedForDrop?: boolean }[]): boolean {
const activeColumns = columns.filter((column) => !column.markedForDrop);
// Databases without physical reorder support keep existing columns in ordinal order
// and append newly added columns, so compare against that post-save layout.
const databaseOrder = [...activeColumns.filter((column) => column.original).sort((left, right) => (left.originalPosition ?? Number.MAX_SAFE_INTEGER) - (right.originalPosition ?? Number.MAX_SAFE_INTEGER)), ...activeColumns.filter((column) => !column.original)];
return activeColumns.some((column, index) => column !== databaseOrder[index]);
}
export function canAddTableStructureColumn(dbType: DatabaseType | undefined, isCreateMode: boolean): boolean {
const caps = getTableStructureCapabilities(dbType);
return isCreateMode ? caps.createTable : caps.addColumn;