feat(grid): add generated cell values
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user