Thin MongoDB ODM built for Standard Schema
mongodb
zod
deno
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}