clean up api, documentation

This commit is contained in:
2024-01-29 10:18:33 -06:00
parent ac3714f05e
commit 971a288794
10 changed files with 302 additions and 125 deletions

142
README.md Normal file
View File

@@ -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<typeof userSchema>;
```
### 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<User> = 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<T> = z.ZodEffects<z.ZodArray<z.ZodType<T>>, 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<Document>[] = 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<typeof storeSchema>;
```
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<Document>[] = await client.db("db").collection("stores").find().toArray();
try {
return StoresHaveOwnersValidator.parse(stores) satisfies Store[];
} catch (e) {
console.error(e);
return;
}
}
```

View File

@@ -1,8 +1,12 @@
import { z } from "zod"; 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<T extends WeakDict, R extends WeakDict> = {};
export type AssertRelationConfig<T extends Record<string, unknown>, R extends Record<string, unknown>> = { export type AssertRelationConfig<T extends Record<string, unknown>, R extends Record<string, unknown>> = {
db: string; db: string;
client?: MongoClient
mainCollection: string; mainCollection: string;
mainSchema: z.ZodSchema<T>; mainSchema: z.ZodSchema<T>;
relationCollection: string; relationCollection: string;
@@ -10,7 +14,15 @@ export type AssertRelationConfig<T extends Record<string, unknown>, R extends Re
relations: { relations: {
[key in keyof T]?: keyof R; [key in keyof T]?: keyof R;
} }
} } & ({
connectionDetails?: undefined
client?: MongoClient
} | {
connectionDetails: {
url: string;
options: MongoClientOptions;
}
})
export class AssertRelation<T extends Record<string, unknown>, R extends Record<string, unknown>> { export class AssertRelation<T extends Record<string, unknown>, R extends Record<string, unknown>> {
config: AssertRelationConfig<T, R>; config: AssertRelationConfig<T, R>;
@@ -26,8 +38,12 @@ export class AssertRelation<T extends Record<string, unknown>, R extends Record<
async check() { async check() {
try { try {
const client = await createMongoClient(); let client: MongoClient | undefined = this.config.client;
if (!client) return null;
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 db = client.db(this.config.db);
const collection = db.collection(this.config.mainCollection); const collection = db.collection(this.config.mainCollection);
@@ -61,7 +77,7 @@ export class AssertRelation<T extends Record<string, unknown>, R extends Record<
return i; return i;
} catch(e) { } catch(e) {
console.log(e); console.log(e);
return null; return;
} }
} }
} }

View File

@@ -1,19 +1,30 @@
import type { z } from 'zod'; import type { z } from 'zod';
export type CollectionParameters<T> = Partial<Record<keyof T, { unique?: boolean }>> export type AssertUniqueType<T extends Record<string, unknown>> = z.ZodEffects<z.ZodArray<z.ZodType<T>>, T[], unknown>;
export const AssertUnique = <T>(itemSchema: z.ZodSchema<T>, parameters?: CollectionParameters<T>) => export class AssertUnique<T extends Record<string, unknown>> {
itemSchema.array().refine((val) => { itemSchema: z.ZodSchema<T>;
for (const key in parameters) { uniqueValues?: (keyof T)[];
const value = parameters[key as keyof CollectionParameters<T>];
const unique = value?.unique ?? false;
if (unique) { constructor(itemSchema: z.ZodSchema<T>, uniqueValues?: (keyof T)[]) {
this.itemSchema = itemSchema;
this.uniqueValues = uniqueValues;
}
fromSchema() {
return AssertUnique.fromSchema(this.itemSchema, this.uniqueValues) satisfies AssertUniqueType<T>;
}
static fromSchema<T extends Record<string, unknown>>(
itemSchema: z.ZodSchema<T>, uniqueValues?: (keyof T)[]
) {
return itemSchema.array().refine((val) => {
for (const key of uniqueValues ?? []) {
const values = val.map((item) => item[key as keyof T]); const values = val.map((item) => item[key as keyof T]);
return values.length === new Set(values).size; return values.length === new Set(values).size;
} }
}
}, { }, {
message: "Encountered duplicate values in unique field" message: "Encountered duplicate values in unique field"
}) satisfies AssertUniqueType<T>;
} }
); }

88
examples/main.ts Normal file
View File

@@ -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<typeof MusicEntry>;
type MusicCollectionType = z.infer<typeof MusicCollection>;
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<T extends Record<string, unknown>> = z.ZodEffects<z.ZodArray<z.ZodType<T>>, T[], unknown>;
// validate against internal schema
const MusicEntryValidator = MongoAssert.unique.fromSchema(MusicEntry, ["slug"]) satisfies AssertUniqueType<MusicEntryType>;
const MusicCollections = MongoAssert.unique.fromSchema(MusicCollection, ["collectionslug"]);
const result: Promise<number | undefined> = 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<MusicEntryType[]>,
MusicCollections.parseAsync(collectionData) satisfies Promise<MusicCollectionType[]>,
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);

View File

@@ -1,58 +1,12 @@
import dotenv from 'dotenv';
import { MusicCollection, MusicEntry } from './lib/schema';
import { AssertUnique } from './actions/unique'; import { AssertUnique } from './actions/unique';
import { AssertRelation } from './actions/relation'; 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() { export default class MongoAssert {
try { static unique = AssertUnique;
const client = await createMongoClient(); static relation = AssertRelation;
if (!client) return null; static constrained = AssertConstrained;
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);
}
} }
main().catch(console.error);

View File

@@ -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;
}
}

14
lib/mongodb.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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<typeof MusicEntry>;
export type MusicCollection = z.infer<typeof MusicCollection>;

1
lib/types.ts Normal file
View File

@@ -0,0 +1 @@
export type WeakDict = Record<string, unknown>;

View File

@@ -2,7 +2,7 @@
"name": "mongo-assert", "name": "mongo-assert",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.ts",
"scripts": { "scripts": {
"start": "ts-node index.ts", "start": "ts-node index.ts",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
@@ -10,11 +10,13 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "peerDependencies": {
"dotenv": "^16.4.1",
"mongodb": "^6.3.0", "mongodb": "^6.3.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"zod": "^3.22.4" "zod": "^3.22.4"
},
"devDependencies": {
"dotenv": "^16.4.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
} }
} }