Thin MongoDB ODM built for Standard Schema
mongodb
zod
deno
1import type { z } from "@zod/zod";
2import type {
3 Collection,
4 DeleteResult,
5 Document,
6 Filter,
7 InsertManyResult,
8 InsertOneResult,
9 OptionalUnlessRequiredId,
10 UpdateResult,
11 WithId,
12} from "mongodb";
13import { ObjectId } from "mongodb";
14import { getDb } from "./client.ts";
15
16// Type alias for cleaner code - Zod schema
17type Schema = z.ZodObject;
18type Infer<T extends Schema> = z.infer<T> & Document;
19type Input<T extends Schema> = z.input<T>;
20
21// Helper function to validate data using Zod
22function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> {
23 const result = schema.safeParse(data);
24 if (!result.success) {
25 throw new Error(`Validation failed: ${JSON.stringify(result.error.issues)}`);
26 }
27 return result.data as Infer<T>;
28}
29
30// Helper function to validate partial update data using Zod's partial()
31function parsePartial<T extends Schema>(
32 schema: T,
33 data: Partial<z.infer<T>>,
34): Partial<z.infer<T>> {
35 const result = schema.partial().safeParse(data);
36 if (!result.success) {
37 throw new Error(`Update validation failed: ${JSON.stringify(result.error.issues)}`);
38 }
39 return result.data as Partial<z.infer<T>>;
40}
41
42export class Model<T extends Schema> {
43 private collection: Collection<Infer<T>>;
44 private schema: T;
45
46 constructor(collectionName: string, schema: T) {
47 this.collection = getDb().collection<Infer<T>>(collectionName);
48 this.schema = schema;
49 }
50
51 async insertOne(data: Input<T>): Promise<InsertOneResult<Infer<T>>> {
52 const validatedData = parse(this.schema, data);
53 return await this.collection.insertOne(
54 validatedData as OptionalUnlessRequiredId<Infer<T>>,
55 );
56 }
57
58 async insertMany(data: Input<T>[]): Promise<InsertManyResult<Infer<T>>> {
59 const validatedData = data.map((item) => parse(this.schema, item));
60 return await this.collection.insertMany(
61 validatedData as OptionalUnlessRequiredId<Infer<T>>[],
62 );
63 }
64
65 async find(query: Filter<Infer<T>>): Promise<(WithId<Infer<T>>)[]> {
66 return await this.collection.find(query).toArray();
67 }
68
69 async findOne(query: Filter<Infer<T>>): Promise<WithId<Infer<T>> | null> {
70 return await this.collection.findOne(query);
71 }
72
73 async findById(id: string | ObjectId): Promise<WithId<Infer<T>> | null> {
74 const objectId = typeof id === "string" ? new ObjectId(id) : id;
75 return await this.findOne({ _id: objectId } as Filter<Infer<T>>);
76 }
77
78 async update(
79 query: Filter<Infer<T>>,
80 data: Partial<z.infer<T>>,
81 ): Promise<UpdateResult<Infer<T>>> {
82 const validatedData = parsePartial(this.schema, data);
83 return await this.collection.updateMany(query, { $set: validatedData as Partial<Infer<T>> });
84 }
85
86 async updateOne(
87 query: Filter<Infer<T>>,
88 data: Partial<z.infer<T>>,
89 ): Promise<UpdateResult<Infer<T>>> {
90 const validatedData = parsePartial(this.schema, data);
91 return await this.collection.updateOne(query, { $set: validatedData as Partial<Infer<T>> });
92 }
93
94 async replaceOne(
95 query: Filter<Infer<T>>,
96 data: Input<T>,
97 ): Promise<UpdateResult<Infer<T>>> {
98 const validatedData = parse(this.schema, data);
99 // Remove _id from validatedData for replaceOne (it will use the query's _id)
100 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
101 return await this.collection.replaceOne(
102 query,
103 withoutId as Infer<T>,
104 );
105 }
106
107 async delete(query: Filter<Infer<T>>): Promise<DeleteResult> {
108 return await this.collection.deleteMany(query);
109 }
110
111 async deleteOne(query: Filter<Infer<T>>): Promise<DeleteResult> {
112 return await this.collection.deleteOne(query);
113 }
114
115 async count(query: Filter<Infer<T>>): Promise<number> {
116 return await this.collection.countDocuments(query);
117 }
118
119 async aggregate(pipeline: Document[]): Promise<Document[]> {
120 return await this.collection.aggregate(pipeline).toArray();
121 }
122
123 // Pagination support for find
124 async findPaginated(
125 query: Filter<Infer<T>>,
126 options: { skip?: number; limit?: number; sort?: Document } = {},
127 ): Promise<(WithId<Infer<T>>)[]> {
128 return await this.collection
129 .find(query)
130 .skip(options.skip ?? 0)
131 .limit(options.limit ?? 10)
132 .sort(options.sort ?? {})
133 .toArray();
134 }
135}