diff --git a/README.md b/README.md new file mode 100644 index 0000000..47895f9 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Mongo Assert + +Stronger, more intuitive type checking and assertions for MongoDB. + +Mongo Assert extends the functionality of Zod, integrated with the MongoDB Node.js driver, to provide a more intuitive and powerful way to validate and assert MongoDB documents. + +Some of its features include: + +- Validation of unique fields +- Validation of the integrity of references to other collections +- Adherence to a Zod schema + +## But why? + +Mongo Assert is something I put together out of frustration with existing, heavy handed solutions for MongoDB schema management. Mongo Assert is intended to have a minimal footprint, add on to existing validation infrastructure, and plug in seamlessly to build-time integrity checks. + +## Usage + +Mongo Assert is available as an NPM package. To install it, run: + +```bash +npm install mongo-assert +pnpm install mongo-assert +yarn add mongo-assert +bun install mongo-assert +``` + +Mongo Assert supports Zod schemas out of the box, so any schemas you may already have defined can be used +with any MongoDB instance. + +## Examples + +### Getting started + +For all of the examples below, we'll be using the following schema: + +```ts +import { z } from 'zod'; +import { ObjectId } from "mongo-assert/lib/mongodb" + +export const userSchema = z.object({ + _id: ObjectId, + name: z.string(), + email: z.string().email(), +}); + +export type User = z.infer; +``` + +### Unique field validation + +Mongo Assert extends Zod schemas to check that your documents match the desired schema. For example: + +```ts +import MongoAssert, { type AssertUniqueType } from 'mongo-assert'; +import { userSchema, type User } from "./schemas"; + +export const UserEmailsUniqueValidator: AssertUniqueType = MongoAssert.unique.fromSchema(userSchema, ["email"]); +``` + +The returned object is a Zod schema that can be directly invoked to validate a result from MongoDB. This object +is of the type: + +```ts +type AssertUniqueType = z.ZodEffects>, T[], unknown>; +``` + +And you can use it like this: + +```ts +import { createMongoClient } from "./your-mongo-client"; +import { UserEmailsUniqueValidator } from "./schemas"; + +const client = await createMongoClient(url, options); + +async function main() { + const users: WithId[] = await client.db("db").collection("users").find().toArray(); + + try { + return UserEmailsUniqueValidator.parse(users) satisfies User[]; + } catch (e) { + console.error(e); + return; + } +} +``` + +### Reference validation + +Mongo Assert also supports validation of references to other collections. + +For example, suppose we have a collection of `Store`s that must be associated to an existing `User`. Let's define our schema first: + +```ts +import { z } from 'zod'; + +export const storeSchema = z.object({ + _id: ObjectId, + name: z.string(), + owner: z.string(), + ownerEmail: z.string().email(), +}); + +export type Store = z.infer; +``` + +Now, we can define a reference validator between these two collections: + +```ts +import MongoAssert from 'mongo-assert'; + +const StoresHaveOwnersValidator = new MongoAssert.relation({ + db: "your-db", + mainCollection: "users", + relationCollection: "stores", + mainSchema: userSchema, + relationSchema: storeSchema, + relations: { + "email": "ownerEmail" + } +}); +``` + +And then we can use it like this: + +```ts +import { createMongoClient } from "./your-mongo-client"; +import { StoresHaveOwnersValidator } from "./schemas"; + +const client = await createMongoClient(url, options); + +async function main() { + const stores: WithId[] = await client.db("db").collection("stores").find().toArray(); + + try { + return StoresHaveOwnersValidator.parse(stores) satisfies Store[]; + } catch (e) { + console.error(e); + return; + } +} +``` diff --git a/actions/relation.ts b/actions/relation.ts index 019aa39..aa43cd1 100644 --- a/actions/relation.ts +++ b/actions/relation.ts @@ -1,8 +1,12 @@ import { z } from "zod"; -import createMongoClient from "../lib/mongoClient"; +import { createMongoClient } from "../lib/mongodb"; +import { MongoClient, MongoClientOptions } from "mongodb"; +import { WeakDict } from "../lib/types"; +export type AssertRelationType = {}; export type AssertRelationConfig, R extends Record> = { db: string; + client?: MongoClient mainCollection: string; mainSchema: z.ZodSchema; relationCollection: string; @@ -10,7 +14,15 @@ export type AssertRelationConfig, R extends Re relations: { [key in keyof T]?: keyof R; } -} +} & ({ + connectionDetails?: undefined + client?: MongoClient +} | { + connectionDetails: { + url: string; + options: MongoClientOptions; + } +}) export class AssertRelation, R extends Record> { config: AssertRelationConfig; @@ -26,8 +38,12 @@ export class AssertRelation, R extends Record< async check() { try { - const client = await createMongoClient(); - if (!client) return null; + let client: MongoClient | undefined = this.config.client; + + if (!client && this.config.connectionDetails?.url) + client = await createMongoClient(this.config.connectionDetails.url, this.config.connectionDetails?.options); + + if (!client) return; const db = client.db(this.config.db); const collection = db.collection(this.config.mainCollection); @@ -61,7 +77,7 @@ export class AssertRelation, R extends Record< return i; } catch(e) { console.log(e); - return null; + return; } } } diff --git a/actions/unique.ts b/actions/unique.ts index c4a3c5f..f2204d6 100644 --- a/actions/unique.ts +++ b/actions/unique.ts @@ -1,19 +1,30 @@ import type { z } from 'zod'; -export type CollectionParameters = Partial> +export type AssertUniqueType> = z.ZodEffects>, T[], unknown>; -export const AssertUnique = (itemSchema: z.ZodSchema, parameters?: CollectionParameters) => - itemSchema.array().refine((val) => { - for (const key in parameters) { - const value = parameters[key as keyof CollectionParameters]; - const unique = value?.unique ?? false; +export class AssertUnique> { + itemSchema: z.ZodSchema; + uniqueValues?: (keyof T)[]; - if (unique) { + constructor(itemSchema: z.ZodSchema, uniqueValues?: (keyof T)[]) { + this.itemSchema = itemSchema; + this.uniqueValues = uniqueValues; + } + + fromSchema() { + return AssertUnique.fromSchema(this.itemSchema, this.uniqueValues) satisfies AssertUniqueType; + } + + static fromSchema>( + itemSchema: z.ZodSchema, uniqueValues?: (keyof T)[] + ) { + return itemSchema.array().refine((val) => { + for (const key of uniqueValues ?? []) { const values = val.map((item) => item[key as keyof T]); return values.length === new Set(values).size; } - } - }, { - message: "Encountered duplicate values in unique field" + }, { + message: "Encountered duplicate values in unique field" + }) satisfies AssertUniqueType; } -); +} diff --git a/examples/main.ts b/examples/main.ts new file mode 100644 index 0000000..6037b85 --- /dev/null +++ b/examples/main.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import MongoAssert from '..'; +import dotenv from 'dotenv'; +import path from 'path'; +import { createMongoClient, ObjectId } from '../lib/mongodb'; + +dotenv.config(); + +const MusicEntry = z.object({ + _id: ObjectId, + name: z.string(), + shortdescription: z.string(), + longdescription: z.string().optional(), + artist: z.string().optional(), + compositiondate: z.string().optional(), + pathtoentry: z.string(), + collection: z.string(), + slug: z.string(), +}) + +const MusicCollection = z.object({ + _id: ObjectId, + name: z.string(), + description: z.string().optional(), + collectionslug: z.string(), +}) + +type MusicEntryType = z.infer; +type MusicCollectionType = z.infer; + +async function main() { + try { + const url = process.env.MONGO_URL; + const cert = process.env.MONGO_CERT; + + if (!url || !cert) { + throw new Error('Missing environment variables'); + } + + const client = await createMongoClient(url, { + tlsCertificateKeyFile: path.resolve(__dirname, '..', cert) + }); + + if (!client) return null; + + const mdotdev = client.db('mikayladotdev'); + const music = mdotdev.collection('music'); + + const musicData = await music.find().toArray(); + const collectionData = await mdotdev.collection('music-collection').find().toArray(); + + type AssertUniqueType> = z.ZodEffects>, T[], unknown>; + + // validate against internal schema + const MusicEntryValidator = MongoAssert.unique.fromSchema(MusicEntry, ["slug"]) satisfies AssertUniqueType; + const MusicCollections = MongoAssert.unique.fromSchema(MusicCollection, ["collectionslug"]); + + const result: Promise = MongoAssert.relation.check({ + db: "mikayladotdev", + mainCollection: "music", + relationCollection: "music-collection", + mainSchema: MusicEntry, + relationSchema: MusicCollection, + relations: { + "collection": "collectionslug" + } + }); + + await Promise.allSettled([ + MusicEntryValidator.parseAsync(musicData) satisfies Promise, + MusicCollections.parseAsync(collectionData) satisfies Promise, + result + ]).then((results) => { + results.forEach((result) => { + if (result.status === 'rejected') { + console.log(result.reason); + } + }) + }) + .catch(console.error) + .finally(() => client.close()); + + } catch(e) { + console.log(e); + } +} + +main().catch(console.error); diff --git a/index.ts b/index.ts index 9307665..95ee22a 100644 --- a/index.ts +++ b/index.ts @@ -1,58 +1,12 @@ -import dotenv from 'dotenv'; -import { MusicCollection, MusicEntry } from './lib/schema'; import { AssertUnique } from './actions/unique'; import { AssertRelation } from './actions/relation'; -import { createMongoClient } from './lib/mongoClient'; +import { AssertConstrained } from './actions/constrained'; -dotenv.config(); +export type { AssertUniqueType } from "./actions/unique"; +export type { AssertRelationType } from "./actions/relation"; -async function main() { - try { - const client = await createMongoClient(); - if (!client) return null; - - const mdotdev = client.db('mikayladotdev'); - const music = mdotdev.collection('music'); - - const musicData = await music.find().toArray(); - const collectionData = await mdotdev.collection('music-collection').find().toArray(); - - // validate against internal schema - const MusicEntries = AssertUnique(MusicEntry, { - slug: { unique: true } - }); - - const MusicCollections = AssertUnique(MusicCollection, { - collectionslug: { unique: true } - }); - - const result = AssertRelation.check({ - db: "mikayladotdev", - mainCollection: "music", - relationCollection: "music-collection", - mainSchema: MusicEntry, - relationSchema: MusicCollection, - relations: { - "collection": "collectionslug" - } - }); - - await Promise.allSettled([ - MusicEntries.parseAsync(musicData), - MusicCollections.parseAsync(collectionData), - result - ]).then((results) => { - results.forEach((result) => { - if (result.status === 'rejected') { - console.log(result.reason); - } - }) - }).catch(console.error) - .finally(() => client.close()); - - } catch(e) { - console.log(e); - } +export default class MongoAssert { + static unique = AssertUnique; + static relation = AssertRelation; + static constrained = AssertConstrained; } - -main().catch(console.error); diff --git a/lib/mongoClient.ts b/lib/mongoClient.ts deleted file mode 100644 index 8bee0c8..0000000 --- a/lib/mongoClient.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MongoClient } from "mongodb"; -import path from "path"; - -export async function createMongoClient() { - const url = process.env.MONGO_URL; - const cert = process.env.MONGO_CERT; - - if (!url || !cert) { - throw new Error('Missing environment variables'); - } - - const pathToCert = path.resolve(__dirname, '..', cert); - - try { - const client = new MongoClient(url, { - tlsCertificateKeyFile: pathToCert - }); - - return client; - } catch(e) { - console.log(e); - return null; - } -} diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..0e8abc4 --- /dev/null +++ b/lib/mongodb.ts @@ -0,0 +1,14 @@ +import { MongoClient, MongoClientOptions } from "mongodb"; +import { z } from "zod"; + +// mongodb ObjectId as zod schema (we'll settle on our definition later) +export const ObjectId = z.any(); + +export async function createMongoClient(url: string, options?: MongoClientOptions) { + try { + return new MongoClient(url, options); + } catch(e) { + console.log(e); + return; + } +} diff --git a/lib/schema.ts b/lib/schema.ts deleted file mode 100644 index f7a8419..0000000 --- a/lib/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; - -// mongodb ObjectId as zod schema (we'll settle on our definition later) -export const ObjectId = z.any(); - -export const MusicEntry = z.object({ - _id: ObjectId, - name: z.string(), - shortdescription: z.string(), - longdescription: z.string().optional(), - artist: z.string().optional().default("Mikayla Dobson"), - compositiondate: z.string().optional(), - pathtoentry: z.string(), - collection: z.string(), - slug: z.string(), -}) - -export const MusicCollection = z.object({ - _id: ObjectId, - name: z.string(), - description: z.string().optional(), - collectionslug: z.string(), -}) - - -export type MusicEntry = z.infer; -export type MusicCollection = z.infer; diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..b4c80d1 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1 @@ +export type WeakDict = Record; diff --git a/package.json b/package.json index 61375ce..d069c3c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mongo-assert", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "index.ts", "scripts": { "start": "ts-node index.ts", "test": "echo \"Error: no test specified\" && exit 1" @@ -10,11 +10,13 @@ "keywords": [], "author": "", "license": "ISC", - "dependencies": { - "dotenv": "^16.4.1", + "peerDependencies": { "mongodb": "^6.3.0", - "ts-node": "^10.9.2", - "typescript": "^5.3.3", "zod": "^3.22.4" + }, + "devDependencies": { + "dotenv": "^16.4.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" } }