clean up api, documentation
This commit is contained in:
142
README.md
Normal file
142
README.md
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
88
examples/main.ts
Normal 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);
|
||||||
60
index.ts
60
index.ts
@@ -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);
|
|
||||||
|
|||||||
@@ -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
14
lib/mongodb.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1
lib/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type WeakDict = Record<string, unknown>;
|
||||||
12
package.json
12
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user