Thin MongoDB ODM built for Standard Schema
mongodb zod deno
at main 9.2 kB view raw
1import type { z } from "@zod/zod"; 2import type { Infer, Input, Schema } from "../types.ts"; 3import { AsyncValidationError, ValidationError } from "../errors.ts"; 4import type { Document, Filter, UpdateFilter } from "mongodb"; 5 6// Cache frequently reused schema transformations to avoid repeated allocations 7const partialSchemaCache = new WeakMap<Schema, z.ZodTypeAny>(); 8const defaultsCache = new WeakMap<Schema, Record<string, unknown>>(); 9const updateOperators = [ 10 "$set", 11 "$unset", 12 "$inc", 13 "$mul", 14 "$rename", 15 "$min", 16 "$max", 17 "$currentDate", 18 "$push", 19 "$pull", 20 "$addToSet", 21 "$pop", 22 "$bit", 23 "$setOnInsert", 24]; 25 26function getPartialSchema(schema: Schema): z.ZodTypeAny { 27 const cached = partialSchemaCache.get(schema); 28 if (cached) return cached; 29 const partial = schema.partial(); 30 partialSchemaCache.set(schema, partial); 31 return partial; 32} 33 34/** 35 * Validate data for insert operations using Zod schema 36 * 37 * @param schema - Zod schema to validate against 38 * @param data - Data to validate 39 * @returns Validated and typed data 40 * @throws {ValidationError} If validation fails 41 * @throws {AsyncValidationError} If async validation is detected 42 */ 43export function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 44 const result = schema.safeParse(data); 45 46 // Check for async validation 47 if (result instanceof Promise) { 48 throw new AsyncValidationError(); 49 } 50 51 if (!result.success) { 52 throw new ValidationError(result.error.issues, "insert"); 53 } 54 return result.data as Infer<T>; 55} 56 57/** 58 * Validate partial data for update operations using Zod schema 59 * 60 * Important: This function only validates the fields that are provided in the data object. 61 * Unlike parse(), this function does NOT apply defaults for missing fields because 62 * in an update context, missing fields should remain unchanged in the database. 63 * 64 * @param schema - Zod schema to validate against 65 * @param data - Partial data to validate 66 * @returns Validated and typed partial data (only fields present in input) 67 * @throws {ValidationError} If validation fails 68 * @throws {AsyncValidationError} If async validation is detected 69 */ 70export function parsePartial<T extends Schema>( 71 schema: T, 72 data: Partial<z.infer<T>>, 73): Partial<z.infer<T>> { 74 if (!data || Object.keys(data).length === 0) { 75 return {}; 76 } 77 78 // Get the list of fields actually provided in the input 79 const inputKeys = Object.keys(data); 80 81 const result = getPartialSchema(schema).safeParse(data); 82 83 // Check for async validation 84 if (result instanceof Promise) { 85 throw new AsyncValidationError(); 86 } 87 88 if (!result.success) { 89 throw new ValidationError(result.error.issues, "update"); 90 } 91 92 // Filter the result to only include fields that were in the input 93 // This prevents defaults from being applied to fields that weren't provided 94 const filtered: Record<string, unknown> = {}; 95 for (const key of inputKeys) { 96 if (key in (result.data as Record<string, unknown>)) { 97 filtered[key] = (result.data as Record<string, unknown>)[key]; 98 } 99 } 100 101 return filtered as Partial<z.infer<T>>; 102} 103 104/** 105 * Validate data for replace operations using Zod schema 106 * 107 * @param schema - Zod schema to validate against 108 * @param data - Data to validate 109 * @returns Validated and typed data 110 * @throws {ValidationError} If validation fails 111 * @throws {AsyncValidationError} If async validation is detected 112 */ 113export function parseReplace<T extends Schema>( 114 schema: T, 115 data: Input<T>, 116): Infer<T> { 117 const result = schema.safeParse(data); 118 119 // Check for async validation 120 if (result instanceof Promise) { 121 throw new AsyncValidationError(); 122 } 123 124 if (!result.success) { 125 throw new ValidationError(result.error.issues, "replace"); 126 } 127 return result.data as Infer<T>; 128} 129 130/** 131 * Extract default values from a Zod schema 132 * This parses an empty object through the schema to get all defaults applied 133 * 134 * @param schema - Zod schema to extract defaults from 135 * @returns Object containing all default values from the schema 136 */ 137export function extractDefaults<T extends Schema>( 138 schema: T, 139): Partial<Infer<T>> { 140 const cached = defaultsCache.get(schema); 141 if (cached) { 142 return cached as Partial<Infer<T>>; 143 } 144 145 try { 146 // Make all fields optional, then parse empty object to trigger defaults 147 // This allows us to see which fields get default values 148 const partialSchema = getPartialSchema(schema); 149 const result = partialSchema.safeParse({}); 150 151 if (result instanceof Promise) { 152 // Cannot extract defaults from async schemas 153 return {}; 154 } 155 156 // If successful, the result contains all fields that have defaults 157 // Only include fields that were actually added (have values) 158 if (!result.success) { 159 return {}; 160 } 161 162 // Filter to only include fields that got values from defaults 163 // (not undefined, which indicates no default) 164 const defaults: Record<string, unknown> = {}; 165 const data = result.data as Record<string, unknown>; 166 167 for (const [key, value] of Object.entries(data)) { 168 if (value !== undefined) { 169 defaults[key] = value; 170 } 171 } 172 defaultsCache.set(schema, defaults as Partial<Infer<Schema>>); 173 return defaults as Partial<Infer<T>>; 174 } catch { 175 return {}; 176 } 177} 178 179/** 180 * Get all field paths mentioned in an update filter object 181 * This includes fields in $set, $unset, $inc, $push, etc. 182 * 183 * @param update - MongoDB update filter 184 * @returns Set of field paths that are being modified 185 */ 186function getModifiedFields(update: UpdateFilter<Document>): Set<string> { 187 const fields = new Set<string>(); 188 189 for (const op of updateOperators) { 190 if (update[op] && typeof update[op] === "object") { 191 // Add all field names from this operator 192 for (const field of Object.keys(update[op] as Document)) { 193 fields.add(field); 194 } 195 } 196 } 197 198 return fields; 199} 200 201/** 202 * Get field paths that are fixed by equality clauses in a query filter. 203 * Only equality-style predicates become part of the inserted document during upsert. 204 */ 205function getEqualityFields(filter: Filter<Document>): Set<string> { 206 const fields = new Set<string>(); 207 208 const collect = (node: Record<string, unknown>) => { 209 for (const [key, value] of Object.entries(node)) { 210 if (key.startsWith("$")) { 211 if (Array.isArray(value)) { 212 for (const item of value) { 213 if (item && typeof item === "object" && !Array.isArray(item)) { 214 collect(item as Record<string, unknown>); 215 } 216 } 217 } else if (value && typeof value === "object") { 218 collect(value as Record<string, unknown>); 219 } 220 continue; 221 } 222 223 if (value && typeof value === "object" && !Array.isArray(value)) { 224 const objectValue = value as Record<string, unknown>; 225 const keys = Object.keys(objectValue); 226 const hasOperator = keys.some((k) => k.startsWith("$")); 227 228 if (hasOperator) { 229 if (Object.prototype.hasOwnProperty.call(objectValue, "$eq")) { 230 fields.add(key); 231 } 232 } else { 233 fields.add(key); 234 } 235 } else { 236 fields.add(key); 237 } 238 } 239 }; 240 241 collect(filter as Record<string, unknown>); 242 return fields; 243} 244 245/** 246 * Apply schema defaults to an update operation using $setOnInsert 247 * 248 * This is used for upsert operations to ensure defaults are applied when 249 * a new document is created, but not when updating an existing document. 250 * 251 * For each default field: 252 * - If the field is NOT mentioned in any update operator ($set, $inc, etc.) 253 * - If the field is NOT fixed by an equality clause in the query filter 254 * - Add it to $setOnInsert so it's only applied on insert 255 * 256 * @param schema - Zod schema with defaults 257 * @param query - MongoDB query filter 258 * @param update - MongoDB update filter 259 * @returns Modified update filter with defaults in $setOnInsert 260 */ 261export function applyDefaultsForUpsert<T extends Schema>( 262 schema: T, 263 query: Filter<Infer<T>>, 264 update: UpdateFilter<Infer<T>>, 265): UpdateFilter<Infer<T>> { 266 // Extract defaults from schema 267 const defaults = extractDefaults(schema); 268 269 // If no defaults, return update unchanged 270 if (Object.keys(defaults).length === 0) { 271 return update; 272 } 273 274 // Get fields that are already being modified 275 const modifiedFields = getModifiedFields(update as UpdateFilter<Document>); 276 const filterEqualityFields = getEqualityFields(query as Filter<Document>); 277 278 // Build $setOnInsert with defaults for unmodified fields 279 const setOnInsert: Partial<Infer<T>> = {}; 280 281 for (const [field, value] of Object.entries(defaults)) { 282 // Only add default if field is not already being modified or fixed by filter equality 283 if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) { 284 setOnInsert[field as keyof Infer<T>] = value as Infer<T>[keyof Infer<T>]; 285 } 286 } 287 288 // If there are defaults to add, merge them into $setOnInsert 289 if (Object.keys(setOnInsert).length > 0) { 290 return { 291 ...update, 292 $setOnInsert: { 293 ...(update.$setOnInsert || {}), 294 ...setOnInsert, 295 } as Partial<Infer<T>>, 296 }; 297 } 298 299 return update; 300}