feat(grid): display referenced values for foreign key columns

This commit is contained in:
zipg authored and GitHub committed 2026-08-15 23:53:32 +08:00
1 parent e97a5a5b8f
commit 36ee06bbf8
15 files changed
+689 -13

No files matched your search

@@ -20,13 +20,28 @@ describe("dataGridContextMenu", () => {
it("omits unavailable server actions and disables unavailable formatter", () => {
const items = createDataGridCompactColumnActionItems({
labels: { formatter: "formatter", localFilter: "local", serverFilter: "server" },
icons: { formatter: icon, filter: icon, database: icon },
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
icons: { formatter: icon, clearFormatter: icon, filter: icon, database: icon },
formatterAvailable: false,
formatterActive: false,
serverFilterAvailable: false,
});
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter"]);
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter", "clearFormatter"]);
expect(items[0]?.disabled).toBe(true);
expect(items[0]?.checked).toBe(false);
expect(items.at(-1)).toMatchObject({ disabled: true, separatorBefore: true });
});
it("marks an active formatter and enables the compact clear action", () => {
const items = createDataGridCompactColumnActionItems({
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
icons: { formatter: icon, clearFormatter: icon, filter: icon, database: icon },
formatterAvailable: true,
formatterActive: true,
serverFilterAvailable: true,
});
expect(items[0]).toMatchObject({ value: "formatter", checked: true, disabled: false });
expect(items.at(-1)).toMatchObject({ value: "clearFormatter", disabled: false, separatorBefore: true });
});
it("builds typed column, cell, and row capability groups", () => {
@@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import {
collectForeignKeyDisplayValues,
createForeignKeyDisplayRequestCoordinator,
foreignKeyDisplayConfigMatches,
foreignKeyDisplayLookupRequestKey,
foreignKeyDisplayMapFromResult,
formatForeignKeyDisplayValue,
singleColumnForeignKey,
splitForeignKeyDisplayValues,
} from "@/lib/dataGrid/dataGridForeignKeyDisplay";
import { buildColumnForeignKeyMap } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
import type { QueryResult } from "@/types/database";
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("dataGridForeignKeyDisplay", () => {
it("only enables dictionary display for a single-column foreign key", () => {
const single = buildColumnForeignKeyMap([{ name: "fk_user", column: "user_id", ref_schema: "public", ref_table: "users", ref_column: "id" }]).get("user_id");
const composite = buildColumnForeignKeyMap([
{ name: "fk_item", column: "order_id", ref_table: "items", ref_column: "order_id" },
{ name: "fk_item", column: "line_no", ref_table: "items", ref_column: "line_no" },
]).get("order_id");
expect(singleColumnForeignKey(single)?.ref_table).toBe("users");
expect(singleColumnForeignKey(composite)).toBeUndefined();
});
it("matches a saved configuration against current foreign-key metadata", () => {
const config = { kind: "foreign-key-display" as const, refSchema: "public", refTable: "users", refColumn: "id", displayColumn: "name" };
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "PUBLIC", ref_table: "USERS", ref_column: "ID" })).toBe(true);
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "public", ref_table: "accounts", ref_column: "id" })).toBe(false);
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "archive", ref_table: "users", ref_column: "id" })).toBe(false);
});
it("deduplicates current-page keys with type-safe identities and bounded batches", () => {
const rows = [[100], [100], ["100"], [null], [101], [{ id: 1 }]] as QueryResult["rows"];
const values = collectForeignKeyDisplayValues(rows, 0);
expect(values).toEqual([100, "100", 101]);
expect(splitForeignKeyDisplayValues(values, 2)).toEqual([[100, "100"], [101]]);
});
it("caps a large page at 2000 unique values and four 500-value batches", () => {
const rows = Array.from({ length: 2500 }, (_, index) => [index]) as QueryResult["rows"];
const values = collectForeignKeyDisplayValues(rows, 0);
const batches = splitForeignKeyDisplayValues(values);
expect(values).toHaveLength(2000);
expect(batches).toHaveLength(4);
expect(batches.every((batch) => batch.length === 500)).toBe(true);
});
it("builds labels from query results and preserves raw values when no useful label exists", () => {
const result = {
columns: ["id", "name", "code"],
rows: [
[100, "张三", "U100"],
[101, "李四", "U101"],
[102, null, "U102"],
[103, " ", "U103"],
[100, "重复", "U100-duplicate"],
],
} as QueryResult;
const labels = foreignKeyDisplayMapFromResult(result);
const codeLabels = foreignKeyDisplayMapFromResult(result, "ID", "CODE");
expect(formatForeignKeyDisplayValue(100, labels)).toBe("100 (张三)");
expect(formatForeignKeyDisplayValue(102, labels)).toBe("102");
expect(formatForeignKeyDisplayValue(103, labels)).toBe("103");
expect(formatForeignKeyDisplayValue(null, labels)).toBe("NULL");
expect(formatForeignKeyDisplayValue("same", new Map([["string\u0000same", "same"]]))).toBe("same");
expect(formatForeignKeyDisplayValue(100, codeLabels)).toBe("100 (U100)");
expect(foreignKeyDisplayMapFromResult(result, "missing", "code")).toEqual(new Map());
});
it("deduplicates in-flight requests and reuses the bounded cache across generations", async () => {
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
const gate = deferred<number>();
const generation = coordinator.beginGeneration();
let calls = 0;
const task = () => {
calls += 1;
return gate.promise;
};
const requestScope = { connectionId: "c1", database: "db", schema: "public", table: "users", refColumn: "id", displayColumn: "name" };
const firstKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: [100, "100"] });
const reorderedKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: ["100", 100] });
expect(reorderedKey).toBe(firstKey);
const first = coordinator.request(generation, firstKey, task);
const duplicate = coordinator.request(generation, reorderedKey, task);
expect(calls).toBe(1);
gate.resolve(7);
await expect(Promise.all([first, duplicate])).resolves.toEqual([7, 7]);
const nextGeneration = coordinator.beginGeneration();
await expect(coordinator.request(nextGeneration, firstKey, async () => 8)).resolves.toBe(7);
expect(calls).toBe(1);
coordinator.dispose();
});
it("enforces one concurrency limit across many configured-column batches", async () => {
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
const generation = coordinator.beginGeneration();
let active = 0;
let maxActive = 0;
let calls = 0;
const requests = Array.from({ length: 12 }, (_, index) =>
coordinator.request(generation, `column-batch-${index}`, async () => {
calls += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await Promise.resolve();
active -= 1;
return index;
}),
);
await expect(Promise.all(requests)).resolves.toEqual(Array.from({ length: 12 }, (_, index) => index));
expect(calls).toBe(12);
expect(maxActive).toBe(2);
coordinator.dispose();
});
it("cancels stale queued work and prevents old-page results after rapid page switches", async () => {
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
const oldGate = deferred<void>();
const oldGeneration = coordinator.beginGeneration();
let oldStarted = 0;
let newStarted = 0;
let active = 0;
let maxActive = 0;
const oldRequests = Array.from({ length: 8 }, (_, index) =>
coordinator.request(oldGeneration, `old-${index}`, async () => {
oldStarted += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await oldGate.promise;
active -= 1;
return `old-${index}`;
}),
);
await Promise.resolve();
expect(oldStarted).toBe(2);
const newGeneration = coordinator.beginGeneration();
const newRequests = Array.from({ length: 8 }, (_, index) =>
coordinator.request(newGeneration, `new-${index}`, async () => {
newStarted += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await Promise.resolve();
active -= 1;
return `new-${index}`;
}),
);
oldGate.resolve();
const [oldResults, newResults] = await Promise.all([Promise.all(oldRequests), Promise.all(newRequests)]);
expect(oldStarted).toBe(2);
expect(newStarted).toBe(8);
expect(maxActive).toBe(2);
expect(oldResults).toEqual(Array(8).fill(undefined));
expect(newResults).toEqual(Array.from({ length: 8 }, (_, index) => `new-${index}`));
coordinator.dispose();
});
});
@@ -103,6 +103,7 @@ export type ColumnFormatterConfig =
| { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string; timezone: string | undefined }
| { kind: "json-path"; path: string }
| { kind: "mask"; prefix: number; suffix: number }
| { kind: "foreign-key-display"; refSchema?: string; refTable: string; refColumn: string; displayColumn: string }
| { kind: "custom-template"; template: string }
| { kind: "custom-ref"; formatterId: string };
@@ -143,6 +144,19 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number };
}
if (config.kind === "foreign-key-display") {
if (typeof config.refTable !== "string" || !config.refTable.trim()) return undefined;
if (typeof config.refColumn !== "string" || !config.refColumn.trim()) return undefined;
if (typeof config.displayColumn !== "string" || !config.displayColumn.trim()) return undefined;
return {
kind: "foreign-key-display",
refSchema: typeof config.refSchema === "string" && config.refSchema.trim() ? config.refSchema.trim() : undefined,
refTable: config.refTable.trim(),
refColumn: config.refColumn.trim(),
displayColumn: config.displayColumn.trim(),
};
}
if (config.kind === "custom-template") {
return typeof config.template === "string" && config.template.trim() ? { kind: "custom-template", template: config.template.slice(0, 500) } : undefined;
}
@@ -223,15 +223,17 @@ export function createDataGridSortMenuItems(options: { column: string; columnInd
}
export function createDataGridCompactColumnActionItems(options: {
labels: { formatter: string; localFilter: string; serverFilter: string };
icons: { formatter: Component; filter: Component; database: Component };
labels: { formatter: string; clearFormatter: string; localFilter: string; serverFilter: string };
icons: { formatter: Component; clearFormatter: Component; filter: Component; database: Component };
formatterAvailable: boolean;
formatterActive: boolean;
serverFilterAvailable: boolean;
}): DataGridColumnMenuItem[] {
const { labels, icons } = options;
return [
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable },
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable, checked: options.formatterActive },
{ label: labels.localFilter, value: "localFilter", icon: icons.filter },
...(options.serverFilterAvailable ? [{ label: labels.serverFilter, value: "serverFilter", icon: icons.database }] : []),
{ label: labels.clearFormatter, value: "clearFormatter", icon: icons.clearFormatter, disabled: !options.formatterActive, separatorBefore: true },
];
}
@@ -0,0 +1,214 @@
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import type { ColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
import type { ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
import type { ForeignKeyInfo, QueryResult } from "@/types/database";
export type ForeignKeyDisplayConfig = Extract<ColumnFormatterConfig, { kind: "foreign-key-display" }>;
export const FOREIGN_KEY_DISPLAY_BATCH_SIZE = 500;
export const FOREIGN_KEY_DISPLAY_MAX_VALUES = 2000;
export const FOREIGN_KEY_DISPLAY_REQUEST_CONCURRENCY = 2;
export const FOREIGN_KEY_DISPLAY_REQUEST_CACHE_TTL_MS = 30_000;
export const FOREIGN_KEY_DISPLAY_REQUEST_CACHE_MAX_ENTRIES = 128;
export interface ForeignKeyDisplayRequestCoordinatorOptions {
concurrency?: number;
cacheTtlMs?: number;
maxCacheEntries?: number;
now?: () => number;
}
interface ForeignKeyDisplayRequestEntry {
generation: number;
state: "queued" | "running" | "resolved" | "cancelled";
expiresAt: number;
promise: Promise<unknown | undefined>;
resolve: (value: unknown | undefined) => void;
reject: (reason: unknown) => void;
task: () => Promise<unknown>;
}
export interface ForeignKeyDisplayRequestCoordinator {
beginGeneration(): number;
isCurrent(generation: number): boolean;
request<T>(generation: number, key: string, task: () => Promise<T>): Promise<T | undefined>;
dispose(): void;
}
export function createForeignKeyDisplayRequestCoordinator(options: ForeignKeyDisplayRequestCoordinatorOptions = {}): ForeignKeyDisplayRequestCoordinator {
const concurrency = Math.max(1, Math.floor(options.concurrency ?? FOREIGN_KEY_DISPLAY_REQUEST_CONCURRENCY));
const cacheTtlMs = Math.max(0, options.cacheTtlMs ?? FOREIGN_KEY_DISPLAY_REQUEST_CACHE_TTL_MS);
const maxCacheEntries = Math.max(1, Math.floor(options.maxCacheEntries ?? FOREIGN_KEY_DISPLAY_REQUEST_CACHE_MAX_ENTRIES));
const now = options.now ?? Date.now;
const cache = new Map<string, ForeignKeyDisplayRequestEntry>();
const queue: Array<{ key: string; entry: ForeignKeyDisplayRequestEntry }> = [];
let generation = 0;
let active = 0;
let disposed = false;
const isCurrent = (requestGeneration: number) => !disposed && requestGeneration === generation;
const removeEntry = (key: string, entry: ForeignKeyDisplayRequestEntry) => {
if (cache.get(key) === entry) cache.delete(key);
};
const cancelQueuedEntry = (key: string, entry: ForeignKeyDisplayRequestEntry) => {
if (entry.state !== "queued") return;
entry.state = "cancelled";
removeEntry(key, entry);
entry.resolve(undefined);
};
const pruneCache = () => {
const timestamp = now();
for (const [key, entry] of cache) {
if (entry.state === "resolved" && entry.expiresAt <= timestamp) cache.delete(key);
}
if (cache.size <= maxCacheEntries) return;
for (const [key, entry] of cache) {
if (cache.size <= maxCacheEntries) break;
if (entry.state === "resolved") cache.delete(key);
}
};
const drain = () => {
while (!disposed && active < concurrency && queue.length) {
const queued = queue.shift()!;
const { key, entry } = queued;
if (entry.state !== "queued") continue;
if (entry.generation !== generation) {
cancelQueuedEntry(key, entry);
continue;
}
entry.state = "running";
active += 1;
void entry
.task()
.then((value) => {
entry.state = "resolved";
entry.expiresAt = now() + cacheTtlMs;
entry.resolve(value);
pruneCache();
})
.catch((error) => {
removeEntry(key, entry);
entry.reject(error);
})
.finally(() => {
active -= 1;
drain();
});
}
};
return {
beginGeneration() {
generation += 1;
for (let index = queue.length - 1; index >= 0; index -= 1) {
const { key, entry } = queue[index];
if (entry.generation === generation) continue;
queue.splice(index, 1);
cancelQueuedEntry(key, entry);
}
drain();
return generation;
},
isCurrent,
request<T>(requestGeneration: number, key: string, task: () => Promise<T>): Promise<T | undefined> {
if (disposed || requestGeneration !== generation) return Promise.resolve(undefined);
pruneCache();
const cached = cache.get(key);
if (cached) {
cache.delete(key);
cache.set(key, cached);
return cached.promise.then((value) => (isCurrent(requestGeneration) ? (value as T | undefined) : undefined));
}
let resolve!: (value: unknown | undefined) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<unknown | undefined>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const entry: ForeignKeyDisplayRequestEntry = {
generation: requestGeneration,
state: "queued",
expiresAt: Number.POSITIVE_INFINITY,
promise,
resolve,
reject,
task,
};
cache.set(key, entry);
queue.push({ key, entry });
drain();
return promise.then((value) => (isCurrent(requestGeneration) ? (value as T | undefined) : undefined));
},
dispose() {
if (disposed) return;
disposed = true;
generation += 1;
for (const { key, entry } of queue) cancelQueuedEntry(key, entry);
queue.length = 0;
cache.clear();
},
};
}
export function singleColumnForeignKey(association: ForeignKeyAssociation | null | undefined): ForeignKeyInfo | undefined {
return association?.columnPairs.length === 1 ? association.foreignKey : undefined;
}
export function foreignKeyDisplayConfigMatches(config: ForeignKeyDisplayConfig, foreignKey: ForeignKeyInfo, currentSchema?: string): boolean {
const expectedSchema = config.refSchema || currentSchema || "";
const actualSchema = foreignKey.ref_schema || currentSchema || "";
return config.refTable.toLowerCase() === foreignKey.ref_table.toLowerCase() && config.refColumn.toLowerCase() === foreignKey.ref_column.toLowerCase() && expectedSchema.toLowerCase() === actualSchema.toLowerCase();
}
export function foreignKeyDisplayValueKey(value: CellValue | undefined): string | undefined {
if (value === null || value === undefined || typeof value === "object") return undefined;
return `${typeof value}\u0000${String(value)}`;
}
export function foreignKeyDisplayLookupRequestKey(options: { connectionId: string; database?: string; catalog?: string; schema?: string; table: string; refColumn: string; displayColumn: string; values: readonly CellValue[] }): string {
const valueKeys = options.values.map((value) => foreignKeyDisplayValueKey(value) ?? "").sort();
return JSON.stringify(["lookup", options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.table, options.refColumn, options.displayColumn, valueKeys]);
}
export function collectForeignKeyDisplayValues(rows: QueryResult["rows"], columnIndex: number, maxValues = FOREIGN_KEY_DISPLAY_MAX_VALUES): CellValue[] {
const values: CellValue[] = [];
const seen = new Set<string>();
for (const row of rows) {
const value = row[columnIndex] as CellValue | undefined;
const key = foreignKeyDisplayValueKey(value);
if (!key || seen.has(key)) continue;
seen.add(key);
values.push(value!);
if (values.length >= maxValues) break;
}
return values;
}
export function splitForeignKeyDisplayValues(values: readonly CellValue[], batchSize = FOREIGN_KEY_DISPLAY_BATCH_SIZE): CellValue[][] {
if (batchSize <= 0) return [];
const batches: CellValue[][] = [];
for (let index = 0; index < values.length; index += batchSize) batches.push(values.slice(index, index + batchSize));
return batches;
}
export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1]): Map<string, string> {
const map = new Map<string, string>();
const keyIndex = result.columns.findIndex((column) => column.toLowerCase() === keyColumn?.toLowerCase());
const displayIndex = result.columns.findIndex((column) => column.toLowerCase() === displayColumn?.toLowerCase());
if (keyIndex < 0 || displayIndex < 0) return map;
for (const row of result.rows) {
const key = foreignKeyDisplayValueKey(row[keyIndex] as CellValue | undefined);
const labelValue = row[displayIndex] as CellValue | undefined;
if (!key || labelValue === null || labelValue === undefined || map.has(key)) continue;
map.set(key, displayCellValue(labelValue));
}
return map;
}
export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap<string, string> | undefined): string {
const raw = displayCellValue(value);
const key = foreignKeyDisplayValueKey(value);
const label = key ? labels?.get(key) : undefined;
if (label === undefined || label === raw || !label.trim()) return raw;
return `${raw} (${label})`;
}