289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
export type MongoInputValue = string | number | boolean | null;
|
|
|
|
const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/;
|
|
const MONGO_SHELL_NUMBER_LONG_PATTERN = /^NumberLong\(\s*(["'])(-?\d+)\1\s*\)$/;
|
|
const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
|
|
const MONGO_INTEGER_PATTERN = /^-?\d+$/;
|
|
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
const MIN_BSON_INT64 = -9223372036854775808n;
|
|
const MAX_BSON_INT64 = 9223372036854775807n;
|
|
const MONGO_EXTENDED_JSON_VALUE_KEYS = new Set(["$binary", "$code", "$date", "$dbPointer", "$maxKey", "$minKey", "$numberDecimal", "$numberDouble", "$numberInt", "$numberLong", "$oid", "$regularExpression", "$symbol", "$timestamp", "$undefined", "$uuid"]);
|
|
const MONGO_EXTENDED_JSON_NUMERIC_TYPES = new Map([
|
|
["$numberInt", "int32"],
|
|
["$numberLong", "int64"],
|
|
["$numberDouble", "double"],
|
|
["$numberDecimal", "decimal128"],
|
|
] as const);
|
|
|
|
function mongoDocumentNumericValueType(value: unknown): string | undefined {
|
|
if (typeof value === "number") return "number";
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
|
|
const object = value as Record<string, unknown>;
|
|
const keys = Object.keys(object);
|
|
if (keys.length !== 1) return undefined;
|
|
const key = keys[0] as "$numberInt" | "$numberLong" | "$numberDouble" | "$numberDecimal";
|
|
return typeof object[key] === "string" ? MONGO_EXTENDED_JSON_NUMERIC_TYPES.get(key) : undefined;
|
|
}
|
|
|
|
export function mongoDocumentGridColumnTypes(documents: readonly Record<string, unknown>[], columns: readonly string[]): string[] {
|
|
return columns.map((column) => {
|
|
let inferredType: string | undefined;
|
|
for (const document of documents) {
|
|
const value = document[column];
|
|
if (value === undefined || value === null) continue;
|
|
const numericType = mongoDocumentNumericValueType(value);
|
|
if (!numericType) return "";
|
|
inferredType = inferredType && inferredType !== numericType ? "number" : numericType;
|
|
}
|
|
return inferredType ?? "";
|
|
});
|
|
}
|
|
|
|
export function mongoShellDateToExtendedJson(value: unknown): unknown {
|
|
if (typeof value !== "string") return value;
|
|
const match = value.trim().match(MONGO_SHELL_DATE_PATTERN);
|
|
if (!match) return value;
|
|
return { $date: match[2] };
|
|
}
|
|
|
|
export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown {
|
|
if (raw === null || typeof raw === "number" || typeof raw === "boolean") return raw;
|
|
|
|
const trimmed = raw.trim();
|
|
if (trimmed === "NULL") return null;
|
|
if (/^true$/i.test(trimmed)) return true;
|
|
if (/^false$/i.test(trimmed)) return false;
|
|
if (/^null$/i.test(trimmed)) return null;
|
|
|
|
const shellDate = mongoShellDateToExtendedJson(trimmed);
|
|
if (shellDate !== trimmed) return shellDate;
|
|
const shellNumberLong = mongoShellNumberLongToExtendedJson(trimmed);
|
|
if (shellNumberLong !== trimmed) return shellNumberLong;
|
|
|
|
if (MONGO_INTEGER_PATTERN.test(trimmed)) {
|
|
const integer = BigInt(trimmed);
|
|
if (integer > MAX_SAFE_BIGINT || integer < -MAX_SAFE_BIGINT) {
|
|
return integer >= MIN_BSON_INT64 && integer <= MAX_BSON_INT64 ? { $numberLong: trimmed } : trimmed;
|
|
}
|
|
return Number(trimmed);
|
|
}
|
|
if (/^-?\d+\.\d+$/.test(trimmed)) return Number(trimmed);
|
|
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) {
|
|
try {
|
|
return mongoShellDateToExtendedJson(JSON.parse(trimmed));
|
|
} catch {
|
|
// JSON-shaped text is still valid user data when it is not valid JSON.
|
|
return raw;
|
|
}
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export function mongoDocumentDisplayValue(value: unknown): unknown {
|
|
if (value === null) return "NULL";
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
const object = value as Record<string, unknown>;
|
|
if (Object.keys(object).length === 1 && typeof object.$numberLong === "string") return `NumberLong(${JSON.stringify(object.$numberLong)})`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseMongoExistingFieldInputValue(raw: Exclude<MongoInputValue, null>, originalValue: unknown): unknown {
|
|
// Objects and arrays are serialized into grid text too, so the raw document
|
|
// is the only reliable way to distinguish them from JSON-shaped BSON strings.
|
|
if (typeof originalValue === "string") {
|
|
return typeof raw === "string" ? raw : String(raw);
|
|
}
|
|
return parseMongoDocumentInputValue(raw);
|
|
}
|
|
|
|
function mongoDocumentFieldValue(document: unknown, field: string): unknown {
|
|
if (!document || typeof document !== "object" || Array.isArray(document)) return undefined;
|
|
return (document as Record<string, unknown>)[field];
|
|
}
|
|
|
|
function mongoShellNumberLongToExtendedJson(value: string): unknown {
|
|
const match = value.match(MONGO_SHELL_NUMBER_LONG_PATTERN);
|
|
return match ? { $numberLong: match[2] } : value;
|
|
}
|
|
|
|
export function buildMongoUpdateDocument(changes: Map<number, MongoInputValue>, columns: string[], originalDocument?: unknown): Record<string, unknown> {
|
|
const setFields: Record<string, unknown> = {};
|
|
const unsetFields: Record<string, unknown> = {};
|
|
for (const [colIdx, newVal] of changes) {
|
|
const col = columns[colIdx];
|
|
if (!col || col === "_id") continue;
|
|
if (newVal === null) {
|
|
unsetFields[col] = "";
|
|
} else {
|
|
setFields[col] = parseMongoExistingFieldInputValue(newVal, mongoDocumentFieldValue(originalDocument, col));
|
|
}
|
|
}
|
|
const doc: Record<string, unknown> = {};
|
|
if (Object.keys(setFields).length > 0) doc.$set = setFields;
|
|
if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields;
|
|
return doc;
|
|
}
|
|
|
|
export function buildMongoCopyUpdateDocument(row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], originalDocument?: unknown, idColumn = "_id"): Record<string, unknown> | null {
|
|
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) return null;
|
|
|
|
const source = originalDocument as Record<string, unknown>;
|
|
const setFields: Record<string, unknown> = {};
|
|
const unsetFields: Record<string, unknown> = {};
|
|
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
|
|
const column = columns[columnIndex];
|
|
if (!column || column === idColumn) continue;
|
|
|
|
const value = row[columnIndex] ?? null;
|
|
if (dirtyColumns[columnIndex]) {
|
|
if (value === null) {
|
|
unsetFields[column] = "";
|
|
} else {
|
|
setFields[column] = parseMongoExistingFieldInputValue(value, source[column]);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(source, column)) {
|
|
setFields[column] = source[column];
|
|
} else {
|
|
unsetFields[column] = "";
|
|
}
|
|
}
|
|
|
|
const update: Record<string, unknown> = {};
|
|
if (Object.keys(setFields).length > 0) update.$set = setFields;
|
|
if (Object.keys(unsetFields).length > 0) update.$unset = unsetFields;
|
|
return Object.keys(update).length > 0 ? update : null;
|
|
}
|
|
|
|
export function applyMongoGridChangesToDocument(document: unknown, changes: Map<number, MongoInputValue>, columns: string[]): unknown {
|
|
if (!document || typeof document !== "object" || Array.isArray(document)) return document;
|
|
|
|
const updated = { ...(document as Record<string, unknown>) };
|
|
for (const [colIdx, newVal] of changes) {
|
|
const column = columns[colIdx];
|
|
if (!column || column === "_id") continue;
|
|
if (newVal === null) {
|
|
delete updated[column];
|
|
} else {
|
|
updated[column] = parseMongoExistingFieldInputValue(newVal, updated[column]);
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
function mongoDocumentIdentityKey(document: unknown): string | undefined {
|
|
if (!document || typeof document !== "object" || Array.isArray(document)) return undefined;
|
|
const object = document as Record<string, unknown>;
|
|
if (!Object.prototype.hasOwnProperty.call(object, "_id")) return undefined;
|
|
return JSON.stringify(object._id);
|
|
}
|
|
|
|
export function applyMongoGridChangesToDocumentBaseline(baselineDocuments: unknown[], currentDocuments: unknown[], dirtyRows: Map<number, Map<number, MongoInputValue>>, columns: string[]): unknown[] {
|
|
const changesByDocumentId = new Map<string, Map<number, MongoInputValue>>();
|
|
for (const [rowIndex, changes] of dirtyRows) {
|
|
const identityKey = mongoDocumentIdentityKey(currentDocuments[rowIndex]);
|
|
if (identityKey !== undefined) changesByDocumentId.set(identityKey, changes);
|
|
}
|
|
return baselineDocuments.map((document) => {
|
|
const identityKey = mongoDocumentIdentityKey(document);
|
|
const changes = identityKey === undefined ? undefined : changesByDocumentId.get(identityKey);
|
|
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
|
|
});
|
|
}
|
|
|
|
export function buildMongoInsertDocument(row: MongoInputValue[], columns: string[]): Record<string, unknown> {
|
|
const doc: Record<string, unknown> = {};
|
|
for (let ci = 0; ci < columns.length; ci++) {
|
|
const col = columns[ci];
|
|
if (!col || col === "_id") continue;
|
|
const val = row[ci];
|
|
if (val === null) continue;
|
|
doc[col] = parseMongoDocumentInputValue(val);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: string[], options: { excludePrimaryKeys?: boolean } = {}): Record<string, unknown> {
|
|
const doc: Record<string, unknown> = {};
|
|
for (let ci = 0; ci < columns.length; ci++) {
|
|
const col = columns[ci];
|
|
if (!col || (options.excludePrimaryKeys && col === "_id")) continue;
|
|
const val = row[ci];
|
|
if (val === null) continue;
|
|
if (col === "_id" && typeof val === "string" && MONGO_OBJECT_ID_PATTERN.test(val)) {
|
|
doc[col] = { $oid: val };
|
|
continue;
|
|
}
|
|
doc[col] = parseMongoDocumentInputValue(val);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
export function buildMongoCopyDocumentFromOriginal(original: unknown, row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], options: { excludePrimaryKeys?: boolean } = {}): Record<string, unknown> | null {
|
|
if (!original || typeof original !== "object" || Array.isArray(original)) return null;
|
|
|
|
const source = original as Record<string, unknown>;
|
|
const document: Record<string, unknown> = {};
|
|
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
|
|
const column = columns[columnIndex];
|
|
if (!column || (options.excludePrimaryKeys && column === "_id")) continue;
|
|
|
|
// Display strings are ambiguous, so only explicitly edited cells may replace original BSON values.
|
|
if (dirtyColumns[columnIndex]) {
|
|
const value = row[columnIndex];
|
|
if (value !== null) document[column] = parseMongoDocumentInputValue(value);
|
|
continue;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(source, column)) document[column] = source[column];
|
|
}
|
|
return document;
|
|
}
|
|
|
|
export function formatMongoShellLiteral(value: unknown): string {
|
|
if (value === null || value === undefined) return "null";
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(formatMongoShellLiteral).join(",")}]`;
|
|
if (typeof value === "object") {
|
|
const object = value as Record<string, unknown>;
|
|
const keys = Object.keys(object);
|
|
if (keys.length === 1 && typeof object.$date === "string") {
|
|
return `ISODate(${JSON.stringify(object.$date)})`;
|
|
}
|
|
if (keys.length === 1 && typeof object.$oid === "string" && MONGO_OBJECT_ID_PATTERN.test(object.$oid)) {
|
|
return `ObjectId(${JSON.stringify(object.$oid)})`;
|
|
}
|
|
if (keys.length === 1 && typeof object.$numberLong === "string") {
|
|
return `NumberLong(${JSON.stringify(object.$numberLong)})`;
|
|
}
|
|
if ((keys.length === 1 && MONGO_EXTENDED_JSON_VALUE_KEYS.has(keys[0] ?? "")) || (keys.length === 2 && keys.includes("$code") && keys.includes("$scope"))) {
|
|
return `EJSON.deserialize(${JSON.stringify(object)})`;
|
|
}
|
|
return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
export function serializeMongoDocumentId(value: unknown): string {
|
|
if (typeof value === "string") return `__dbx_mongo_string_id__${JSON.stringify(value)}`;
|
|
if (isMongoExtendedJsonId(value)) return JSON.stringify(value);
|
|
return String(value);
|
|
}
|
|
|
|
export function mongoDocumentIdForGrid(value: unknown): MongoInputValue {
|
|
if (isMongoExtendedJsonId(value)) return String(value.$numberLong ?? value.$oid);
|
|
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isMongoExtendedJsonId(value: unknown): value is Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const object = value as Record<string, unknown>;
|
|
const keys = Object.keys(object);
|
|
return keys.length === 1 && (typeof object.$numberLong === "string" || typeof object.$oid === "string");
|
|
}
|