feat(grid): add generated cell values

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 18:43:49 +08:00
1 parent 9efdf79ad9
commit 97d15b3e2d
16 files changed
+376 -13

No files matched your search

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createSnowflakeIdGenerator, generateCellValues } from "@/lib/dataGrid/cellValueGeneration";
describe("generateCellValues", () => {
it("generates empty strings and nulls", () => {
expect(generateCellValues("empty", 2)).toEqual(["", ""]);
expect(generateCellValues("null", 2)).toEqual([null, null]);
});
it("uses one local timestamp for the whole selection", () => {
const now = new Date(2026, 6, 16, 9, 8, 7);
expect(generateCellValues("datetime", 2, { now })).toEqual(["2026-07-16 09:08:07", "2026-07-16 09:08:07"]);
expect(generateCellValues("date", 2, { now })).toEqual(["2026-07-16", "2026-07-16"]);
});
it("generates one UUID per cell", () => {
let index = 0;
expect(generateCellValues("uuid", 3, { uuidFactory: () => `uuid-${++index}` })).toEqual(["uuid-1", "uuid-2", "uuid-3"]);
});
it("increments values without losing bigint precision", () => {
expect(generateCellValues("increment", 3, { startValue: 9_007_199_254_740_993n })).toEqual(["9007199254740993", "9007199254740994", "9007199254740995"]);
});
it("keeps snowflake IDs unique and ordered beyond one sequence window", () => {
const generator = createSnowflakeIdGenerator({ workerId: 7, now: () => 1_800_000_000_000 });
const values = generateCellValues("snowflake", 5000, { now: new Date(1_800_000_000_000), snowflakeGenerator: generator }) as string[];
expect(new Set(values).size).toBe(values.length);
expect(values.every((value, index) => index === 0 || BigInt(value) > BigInt(values[index - 1]))).toBe(true);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { dataGridCellDisplayText } from "@/lib/dataGrid/dataGridCellCoercion";
import { coerceDataGridCellValue, dataGridCellDisplayText } from "@/lib/dataGrid/dataGridCellCoercion";
describe("dataGridCellDisplayText", () => {
it("formats Oracle DATE values without RFC3339 separators", () => {
@@ -32,3 +32,17 @@ describe("dataGridCellDisplayText", () => {
).toBeUndefined();
});
});
describe("coerceDataGridCellValue", () => {
it("preserves an explicitly generated empty string for a null cell", () => {
const options = {
value: "",
oldValue: null,
databaseType: "mysql" as const,
columnInfo: { data_type: "varchar(255)" },
};
expect(coerceDataGridCellValue(options)).toBeNull();
expect(coerceDataGridCellValue({ ...options, preserveEmptyString: true })).toBe("");
});
});
@@ -0,0 +1,77 @@
import { uuid } from "@/lib/common/utils";
export type CellValueGenerationKind = "empty" | "null" | "datetime" | "date" | "uuid" | "increment" | "snowflake";
export interface SnowflakeIdGenerator {
next(nowMs?: number): string;
}
const SNOWFLAKE_EPOCH_MS = 1_609_459_200_000;
const SNOWFLAKE_MAX_SEQUENCE = 4095n;
export function createSnowflakeIdGenerator(options: { workerId?: number; now?: () => number } = {}): SnowflakeIdGenerator {
const workerId = options.workerId ?? Math.floor(Math.random() * 1024);
if (!Number.isInteger(workerId) || workerId < 0 || workerId > 1023) throw new RangeError("Snowflake workerId must be between 0 and 1023");
const now = options.now ?? Date.now;
let lastTimestampMs = -1;
let sequence = 0n;
return {
next(nowMs = now()) {
let timestampMs = Math.max(Math.trunc(nowMs), SNOWFLAKE_EPOCH_MS);
if (timestampMs < lastTimestampMs) timestampMs = lastTimestampMs;
if (timestampMs === lastTimestampMs) {
sequence += 1n;
if (sequence > SNOWFLAKE_MAX_SEQUENCE) {
timestampMs += 1;
sequence = 0n;
}
} else {
sequence = 0n;
}
lastTimestampMs = timestampMs;
return (((BigInt(timestampMs) - BigInt(SNOWFLAKE_EPOCH_MS)) << 22n) | (BigInt(workerId) << 12n) | sequence).toString();
},
};
}
const defaultSnowflakeIdGenerator = createSnowflakeIdGenerator();
export function generateCellValues(
kind: CellValueGenerationKind,
count: number,
options: {
now?: Date;
startValue?: bigint;
uuidFactory?: () => string;
snowflakeGenerator?: SnowflakeIdGenerator;
} = {},
): Array<string | null> {
const size = Math.max(0, Math.trunc(count));
const now = options.now ?? new Date();
const uuidFactory = options.uuidFactory ?? uuid;
const snowflakeGenerator = options.snowflakeGenerator ?? defaultSnowflakeIdGenerator;
return Array.from({ length: size }, (_, index) => {
if (kind === "null") return null;
if (kind === "empty") return "";
if (kind === "datetime") return localDateTimeText(now);
if (kind === "date") return localDateText(now);
if (kind === "uuid") return uuidFactory();
if (kind === "increment") return String((options.startValue ?? 1n) + BigInt(index));
return snowflakeGenerator.next(now.getTime());
});
}
function padDatePart(value: number): string {
return String(value).padStart(2, "0");
}
function localDateTimeText(date: Date): string {
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}`;
}
function localDateText(date: Date): string {
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`;
}
@@ -6,12 +6,13 @@ export interface CoerceDataGridCellValueOptions {
oldValue: GridCellValue | undefined;
databaseType: DatabaseType | undefined;
columnInfo: Pick<ColumnInfo, "data_type"> | undefined;
preserveEmptyString?: boolean;
}
export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue {
const { value, oldValue } = options;
if (value.toUpperCase() === "NULL") return null;
if (value === "" && oldValue === null) return null;
if (value === "" && oldValue === null && !options.preserveEmptyString) return null;
const postgresArrayValue = coercePostgresArrayValue(options);
if (postgresArrayValue !== undefined) return postgresArrayValue;
if (typeof oldValue === "number") {
@@ -116,6 +116,7 @@ export function createDataGridCellContextMenuItems(options: {
downloadItem?: DataGridContextMenuItem | null;
copySubmenu: DataGridContextMenuItem;
selectionSubmenu: DataGridContextMenuItem;
generateSubmenu?: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
if (options.hasCell) {
@@ -130,6 +131,7 @@ export function createDataGridCellContextMenuItems(options: {
if (options.editable && options.hasCellSelection) {
if (!options.headerColumn) items.push({ label: options.labels.setNull, action: options.actions.setNull, disabled: !options.hasEditableSelection, icon: options.icons.setNull });
items.push({ label: options.labels.bulkEdit, action: options.actions.bulkEdit, disabled: !options.hasEditableSelection, icon: options.icons.bulkEdit });
if (options.generateSubmenu) items.push(options.generateSubmenu);
}
if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose });
if (options.hasSelection) items.push(options.selectionSubmenu);