import type { z } from "@zod/zod"; import type { Infer, Input, Schema } from "../types.ts"; import { AsyncValidationError, ValidationError } from "../errors.ts"; import type { Document, Filter, UpdateFilter } from "mongodb"; // Cache frequently reused schema transformations to avoid repeated allocations const partialSchemaCache = new WeakMap(); const defaultsCache = new WeakMap>(); const updateOperators = [ "$set", "$unset", "$inc", "$mul", "$rename", "$min", "$max", "$currentDate", "$push", "$pull", "$addToSet", "$pop", "$bit", "$setOnInsert", ]; function getPartialSchema(schema: Schema): z.ZodTypeAny { const cached = partialSchemaCache.get(schema); if (cached) return cached; const partial = schema.partial(); partialSchemaCache.set(schema, partial); return partial; } /** * Validate data for insert operations using Zod schema * * @param schema - Zod schema to validate against * @param data - Data to validate * @returns Validated and typed data * @throws {ValidationError} If validation fails * @throws {AsyncValidationError} If async validation is detected */ export function parse(schema: T, data: Input): Infer { const result = schema.safeParse(data); // Check for async validation if (result instanceof Promise) { throw new AsyncValidationError(); } if (!result.success) { throw new ValidationError(result.error.issues, "insert"); } return result.data as Infer; } /** * Validate partial data for update operations using Zod schema * * Important: This function only validates the fields that are provided in the data object. * Unlike parse(), this function does NOT apply defaults for missing fields because * in an update context, missing fields should remain unchanged in the database. * * @param schema - Zod schema to validate against * @param data - Partial data to validate * @returns Validated and typed partial data (only fields present in input) * @throws {ValidationError} If validation fails * @throws {AsyncValidationError} If async validation is detected */ export function parsePartial( schema: T, data: Partial>, ): Partial> { if (!data || Object.keys(data).length === 0) { return {}; } // Get the list of fields actually provided in the input const inputKeys = Object.keys(data); const result = getPartialSchema(schema).safeParse(data); // Check for async validation if (result instanceof Promise) { throw new AsyncValidationError(); } if (!result.success) { throw new ValidationError(result.error.issues, "update"); } // Filter the result to only include fields that were in the input // This prevents defaults from being applied to fields that weren't provided const filtered: Record = {}; for (const key of inputKeys) { if (key in (result.data as Record)) { filtered[key] = (result.data as Record)[key]; } } return filtered as Partial>; } /** * Validate data for replace operations using Zod schema * * @param schema - Zod schema to validate against * @param data - Data to validate * @returns Validated and typed data * @throws {ValidationError} If validation fails * @throws {AsyncValidationError} If async validation is detected */ export function parseReplace( schema: T, data: Input, ): Infer { const result = schema.safeParse(data); // Check for async validation if (result instanceof Promise) { throw new AsyncValidationError(); } if (!result.success) { throw new ValidationError(result.error.issues, "replace"); } return result.data as Infer; } /** * Extract default values from a Zod schema * This parses an empty object through the schema to get all defaults applied * * @param schema - Zod schema to extract defaults from * @returns Object containing all default values from the schema */ export function extractDefaults( schema: T, ): Partial> { const cached = defaultsCache.get(schema); if (cached) { return cached as Partial>; } try { // Make all fields optional, then parse empty object to trigger defaults // This allows us to see which fields get default values const partialSchema = getPartialSchema(schema); const result = partialSchema.safeParse({}); if (result instanceof Promise) { // Cannot extract defaults from async schemas return {}; } // If successful, the result contains all fields that have defaults // Only include fields that were actually added (have values) if (!result.success) { return {}; } // Filter to only include fields that got values from defaults // (not undefined, which indicates no default) const defaults: Record = {}; const data = result.data as Record; for (const [key, value] of Object.entries(data)) { if (value !== undefined) { defaults[key] = value; } } defaultsCache.set(schema, defaults as Partial>); return defaults as Partial>; } catch { return {}; } } /** * Get all field paths mentioned in an update filter object * This includes fields in $set, $unset, $inc, $push, etc. * * @param update - MongoDB update filter * @returns Set of field paths that are being modified */ function getModifiedFields(update: UpdateFilter): Set { const fields = new Set(); for (const op of updateOperators) { if (update[op] && typeof update[op] === "object") { // Add all field names from this operator for (const field of Object.keys(update[op] as Document)) { fields.add(field); } } } return fields; } /** * Get field paths that are fixed by equality clauses in a query filter. * Only equality-style predicates become part of the inserted document during upsert. */ function getEqualityFields(filter: Filter): Set { const fields = new Set(); const collect = (node: Record) => { for (const [key, value] of Object.entries(node)) { if (key.startsWith("$")) { if (Array.isArray(value)) { for (const item of value) { if (item && typeof item === "object" && !Array.isArray(item)) { collect(item as Record); } } } else if (value && typeof value === "object") { collect(value as Record); } continue; } if (value && typeof value === "object" && !Array.isArray(value)) { const objectValue = value as Record; const keys = Object.keys(objectValue); const hasOperator = keys.some((k) => k.startsWith("$")); if (hasOperator) { if (Object.prototype.hasOwnProperty.call(objectValue, "$eq")) { fields.add(key); } } else { fields.add(key); } } else { fields.add(key); } } }; collect(filter as Record); return fields; } /** * Apply schema defaults to an update operation using $setOnInsert * * This is used for upsert operations to ensure defaults are applied when * a new document is created, but not when updating an existing document. * * For each default field: * - If the field is NOT mentioned in any update operator ($set, $inc, etc.) * - If the field is NOT fixed by an equality clause in the query filter * - Add it to $setOnInsert so it's only applied on insert * * @param schema - Zod schema with defaults * @param query - MongoDB query filter * @param update - MongoDB update filter * @returns Modified update filter with defaults in $setOnInsert */ export function applyDefaultsForUpsert( schema: T, query: Filter>, update: UpdateFilter>, ): UpdateFilter> { // Extract defaults from schema const defaults = extractDefaults(schema); // If no defaults, return update unchanged if (Object.keys(defaults).length === 0) { return update; } // Get fields that are already being modified const modifiedFields = getModifiedFields(update as UpdateFilter); const filterEqualityFields = getEqualityFields(query as Filter); // Build $setOnInsert with defaults for unmodified fields const setOnInsert: Partial> = {}; for (const [field, value] of Object.entries(defaults)) { // Only add default if field is not already being modified or fixed by filter equality if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) { setOnInsert[field as keyof Infer] = value as Infer[keyof Infer]; } } // If there are defaults to add, merge them into $setOnInsert if (Object.keys(setOnInsert).length > 0) { return { ...update, $setOnInsert: { ...(update.$setOnInsert || {}), ...setOnInsert, } as Partial>, }; } return update; }