Thin MongoDB ODM built for Standard Schema
mongodb zod deno
1import type { z } from "@zod/zod"; 2import type { 3 Collection, 4 CreateIndexesOptions, 5 DeleteResult, 6 Document, 7 DropIndexesOptions, 8 Filter, 9 IndexDescription, 10 IndexSpecification, 11 InsertManyResult, 12 InsertOneResult, 13 InsertOneOptions, 14 FindOptions, 15 UpdateOptions, 16 ReplaceOptions, 17 DeleteOptions, 18 CountDocumentsOptions, 19 AggregateOptions, 20 ListIndexesOptions, 21 OptionalUnlessRequiredId, 22 UpdateResult, 23 WithId, 24 BulkWriteOptions, 25} from "mongodb"; 26import { ObjectId } from "mongodb"; 27import { getDb } from "./client.ts"; 28import { ValidationError, AsyncValidationError } from "./errors.ts"; 29 30// Type alias for cleaner code - Zod schema 31type Schema = z.ZodObject; 32type Infer<T extends Schema> = z.infer<T> & Document; 33type Input<T extends Schema> = z.input<T>; 34 35// Helper function to validate data using Zod 36function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 37 const result = schema.safeParse(data); 38 39 // Check for async validation 40 if (result instanceof Promise) { 41 throw new AsyncValidationError(); 42 } 43 44 if (!result.success) { 45 throw new ValidationError(result.error.issues, "insert"); 46 } 47 return result.data as Infer<T>; 48} 49 50// Helper function to validate partial update data using Zod's partial() 51function parsePartial<T extends Schema>( 52 schema: T, 53 data: Partial<z.infer<T>>, 54): Partial<z.infer<T>> { 55 const result = schema.partial().safeParse(data); 56 57 // Check for async validation 58 if (result instanceof Promise) { 59 throw new AsyncValidationError(); 60 } 61 62 if (!result.success) { 63 throw new ValidationError(result.error.issues, "update"); 64 } 65 return result.data as Partial<z.infer<T>>; 66} 67 68// Helper function to validate replace data using Zod 69function parseReplace<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 70 const result = schema.safeParse(data); 71 72 // Check for async validation 73 if (result instanceof Promise) { 74 throw new AsyncValidationError(); 75 } 76 77 if (!result.success) { 78 throw new ValidationError(result.error.issues, "replace"); 79 } 80 return result.data as Infer<T>; 81} 82 83export class Model<T extends Schema> { 84 private collection: Collection<Infer<T>>; 85 private schema: T; 86 87 constructor(collectionName: string, schema: T) { 88 this.collection = getDb().collection<Infer<T>>(collectionName); 89 this.schema = schema; 90 } 91 92 async insertOne( 93 data: Input<T>, 94 options?: InsertOneOptions 95 ): Promise<InsertOneResult<Infer<T>>> { 96 const validatedData = parse(this.schema, data); 97 return await this.collection.insertOne( 98 validatedData as OptionalUnlessRequiredId<Infer<T>>, 99 options 100 ); 101 } 102 103 async insertMany( 104 data: Input<T>[], 105 options?: BulkWriteOptions 106 ): Promise<InsertManyResult<Infer<T>>> { 107 const validatedData = data.map((item) => parse(this.schema, item)); 108 return await this.collection.insertMany( 109 validatedData as OptionalUnlessRequiredId<Infer<T>>[], 110 options 111 ); 112 } 113 114 async find( 115 query: Filter<Infer<T>>, 116 options?: FindOptions 117 ): Promise<(WithId<Infer<T>>)[]> { 118 return await this.collection.find(query, options).toArray(); 119 } 120 121 async findOne( 122 query: Filter<Infer<T>>, 123 options?: FindOptions 124 ): Promise<WithId<Infer<T>> | null> { 125 return await this.collection.findOne(query, options); 126 } 127 128 async findById( 129 id: string | ObjectId, 130 options?: FindOptions 131 ): Promise<WithId<Infer<T>> | null> { 132 const objectId = typeof id === "string" ? new ObjectId(id) : id; 133 return await this.findOne({ _id: objectId } as Filter<Infer<T>>, options); 134 } 135 136 async update( 137 query: Filter<Infer<T>>, 138 data: Partial<z.infer<T>>, 139 options?: UpdateOptions 140 ): Promise<UpdateResult<Infer<T>>> { 141 const validatedData = parsePartial(this.schema, data); 142 return await this.collection.updateMany( 143 query, 144 { $set: validatedData as Partial<Infer<T>> }, 145 options 146 ); 147 } 148 149 async updateOne( 150 query: Filter<Infer<T>>, 151 data: Partial<z.infer<T>>, 152 options?: UpdateOptions 153 ): Promise<UpdateResult<Infer<T>>> { 154 const validatedData = parsePartial(this.schema, data); 155 return await this.collection.updateOne( 156 query, 157 { $set: validatedData as Partial<Infer<T>> }, 158 options 159 ); 160 } 161 162 async replaceOne( 163 query: Filter<Infer<T>>, 164 data: Input<T>, 165 options?: ReplaceOptions 166 ): Promise<UpdateResult<Infer<T>>> { 167 const validatedData = parseReplace(this.schema, data); 168 // Remove _id from validatedData for replaceOne (it will use the query's _id) 169 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown }; 170 return await this.collection.replaceOne( 171 query, 172 withoutId as Infer<T>, 173 options 174 ); 175 } 176 177 async delete( 178 query: Filter<Infer<T>>, 179 options?: DeleteOptions 180 ): Promise<DeleteResult> { 181 return await this.collection.deleteMany(query, options); 182 } 183 184 async deleteOne( 185 query: Filter<Infer<T>>, 186 options?: DeleteOptions 187 ): Promise<DeleteResult> { 188 return await this.collection.deleteOne(query, options); 189 } 190 191 async count( 192 query: Filter<Infer<T>>, 193 options?: CountDocumentsOptions 194 ): Promise<number> { 195 return await this.collection.countDocuments(query, options); 196 } 197 198 async aggregate( 199 pipeline: Document[], 200 options?: AggregateOptions 201 ): Promise<Document[]> { 202 return await this.collection.aggregate(pipeline, options).toArray(); 203 } 204 205 // Pagination support for find 206 async findPaginated( 207 query: Filter<Infer<T>>, 208 options: { skip?: number; limit?: number; sort?: Document } = {}, 209 ): Promise<(WithId<Infer<T>>)[]> { 210 return await this.collection 211 .find(query) 212 .skip(options.skip ?? 0) 213 .limit(options.limit ?? 10) 214 .sort(options.sort ?? {}) 215 .toArray(); 216 } 217 218 // Index Management Methods 219 220 /** 221 * Create a single index on the collection 222 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" }) 223 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.) 224 * @returns The name of the created index 225 */ 226 async createIndex( 227 keys: IndexSpecification, 228 options?: CreateIndexesOptions, 229 ): Promise<string> { 230 return await this.collection.createIndex(keys, options); 231 } 232 233 /** 234 * Create multiple indexes on the collection 235 * @param indexes - Array of index descriptions 236 * @param options - Index creation options 237 * @returns Array of index names created 238 */ 239 async createIndexes( 240 indexes: IndexDescription[], 241 options?: CreateIndexesOptions, 242 ): Promise<string[]> { 243 return await this.collection.createIndexes(indexes, options); 244 } 245 246 /** 247 * Drop a single index from the collection 248 * @param index - Index name or specification 249 * @param options - Drop index options 250 */ 251 async dropIndex( 252 index: string | IndexSpecification, 253 options?: DropIndexesOptions, 254 ): Promise<void> { 255 // MongoDB driver accepts string or IndexSpecification 256 await this.collection.dropIndex(index as string, options); 257 } 258 259 /** 260 * Drop all indexes from the collection (except _id index) 261 * @param options - Drop index options 262 */ 263 async dropIndexes(options?: DropIndexesOptions): Promise<void> { 264 await this.collection.dropIndexes(options); 265 } 266 267 /** 268 * List all indexes on the collection 269 * @param options - List indexes options 270 * @returns Array of index information 271 */ 272 async listIndexes( 273 options?: ListIndexesOptions, 274 ): Promise<IndexDescription[]> { 275 const indexes = await this.collection.listIndexes(options).toArray(); 276 return indexes as IndexDescription[]; 277 } 278 279 /** 280 * Get index information by name 281 * @param indexName - Name of the index 282 * @returns Index description or null if not found 283 */ 284 async getIndex(indexName: string): Promise<IndexDescription | null> { 285 const indexes = await this.listIndexes(); 286 return indexes.find((idx) => idx.name === indexName) || null; 287 } 288 289 /** 290 * Check if an index exists 291 * @param indexName - Name of the index 292 * @returns True if index exists, false otherwise 293 */ 294 async indexExists(indexName: string): Promise<boolean> { 295 const index = await this.getIndex(indexName); 296 return index !== null; 297 } 298 299 /** 300 * Synchronize indexes - create indexes if they don't exist, update if they differ 301 * This is useful for ensuring indexes match your schema definition 302 * @param indexes - Array of index descriptions to synchronize 303 * @param options - Options for index creation 304 */ 305 async syncIndexes( 306 indexes: IndexDescription[], 307 options?: CreateIndexesOptions, 308 ): Promise<string[]> { 309 const existingIndexes = await this.listIndexes(); 310 311 const indexesToCreate: IndexDescription[] = []; 312 313 for (const index of indexes) { 314 const indexName = index.name || this._generateIndexName(index.key); 315 const existingIndex = existingIndexes.find( 316 (idx) => idx.name === indexName, 317 ); 318 319 if (!existingIndex) { 320 indexesToCreate.push(index); 321 } else if ( 322 JSON.stringify(existingIndex.key) !== JSON.stringify(index.key) 323 ) { 324 // Index exists but keys differ - drop and recreate 325 await this.dropIndex(indexName); 326 indexesToCreate.push(index); 327 } 328 // If index exists and matches, skip it 329 } 330 331 const created: string[] = []; 332 if (indexesToCreate.length > 0) { 333 const names = await this.createIndexes(indexesToCreate, options); 334 created.push(...names); 335 } 336 337 return created; 338 } 339 340 /** 341 * Helper method to generate index name from key specification 342 */ 343 private _generateIndexName(keys: IndexSpecification): string { 344 if (typeof keys === "string") { 345 return keys; 346 } 347 const entries = Object.entries(keys as Record<string, number | string>); 348 return entries.map(([field, direction]) => `${field}_${direction}`).join("_"); 349 } 350}