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