UNPKG

papr

Version:

MongoDB TypeScript-aware Models

711 lines (710 loc) 26.7 kB
import { MongoError, ObjectId } from 'mongodb'; import { serializeArguments } from "./hooks.js"; import { cleanSetOnInsert, getDefaultValues, getTimestampProperty, timestampBulkWriteOperation, timestampUpdateFilter, } from "./utils.js"; function abstractMethod() { throw new Error('Collection is not initialized!'); } export function abstract(schema) { return { aggregate: abstractMethod, bulkWrite: abstractMethod, countDocuments: abstractMethod, deleteMany: abstractMethod, deleteOne: abstractMethod, find: abstractMethod, findById: abstractMethod, findCursor: abstractMethod, findOne: abstractMethod, findOneAndDelete: abstractMethod, findOneAndUpdate: abstractMethod, insertMany: abstractMethod, insertOne: abstractMethod, schema, updateMany: abstractMethod, updateOne: abstractMethod, upsert: abstractMethod, }; } function wrap( // eslint-disable-next-line @typescript-eslint/no-explicit-any model, method) { return async function modelWrapped(...args) { const { collectionName } = model.collection; const { after = [], before = [] } = model.options?.hooks || {}; const context = {}; for (const hook of before) { await hook({ args, collectionName, context, // @ts-expect-error We can't get the proper method name type here methodName: method.name, }); } let result; try { result = await method(...args); for (const hook of after) { await hook({ args, collectionName, context, // @ts-expect-error We can't get the proper method name type here methodName: method.name, result, }); } } catch (err) { if (err instanceof Error) { // MaxTimeMSExpired error if (err instanceof MongoError && err.code === 50) { err.message = `Query exceeded maxTime: ${collectionName}.${method.name}(${serializeArguments(args, false)})`; } for (const hook of after) { await hook({ args, collectionName, context, error: err, // @ts-expect-error We can't get the proper method name type here methodName: method.name, }); } } throw err; } return result; }; } /** * @module intro * @description * * A model is the public interface in `papr` for working with a MongoDB collection. * * All the examples here are using the following schema and model: * * ```js * const userSchema = schema({ * active: types.boolean(), * age: types.number(), * firstName: types.string({ required: true }), * lastName: types.string({ required: true }), * }); * const User = papr.model('users', userSchema); * ``` */ export function build(schema, model, collection, options) { // Sanity check for already built models // @ts-expect-error Ignore type mismatch error if (model.collection && model.aggregate !== abstractMethod) { return; } // @ts-expect-error We're accessing runtime property on the schema // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment model.defaults = schema.$defaults; model.collection = collection; // @ts-expect-error We're accessing runtime property on the schema // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment model.timestamps = schema.$timestamps; model.options = options; model.defaultOptions = { ignoreUndefined: true, ...(options && 'maxTime' in options && { maxTimeMS: options.maxTime, }), }; // @ts-expect-error We're storing the runtime schema object model.schema = schema; /** * @description * * Calls the MongoDB [`aggregate()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#aggregate) method. * * The MongoDB aggregation pipeline syntax is very rich and powerful, however providing full typed support for the results is out of the scope of `papr`. * * We provide a generic type to this method `TAggregate`, defaulted to the `TSchema` of the model, which can be used to customize the return type of the results. * * @param pipeline {Array<Record<string, unknown>>} * @param [options] {AggregateOptions} * * @returns {Promise<Array<TAggregate>>} A custom data type based on the pipeline steps * * @example * // The default results type is UserDocument * const results = await User.aggregate([ * { $sortByCount: '$age' }, * { $limit: 5 } * ]); * * // Use custom results type * const results = await User.aggregate<{ age: number; }>([ * { $sortByCount: '$age' }, * { $projection: { age: 1 } } * ]); */ // prettier-ignore // @ts-expect-error :shrug: not sure why TS sees this as an error - probably due to the generic TResult type model.aggregate = wrap(model, async function aggregate(pipeline, options) { // We're casing to `unknown` and then to `TResult`, because `TResult` can be very different than `TSchema`, // due to all the possible aggregation operations that can be applied return model.collection .aggregate(pipeline, { ...model.defaultOptions, ...options, }) .toArray(); }); /** * @description * * Calls the MongoDB [`bulkWrite()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#bulkWrite) method. * * If no operations are provided this method acts as a no-op and returns * nothing. * * @param operations {Array<BulkWriteOperation<TSchema, TOptions>>} * @param [options] {BulkWriteOptions} * * @returns {Promise<BulkWriteResult | void>} https://mongodb.github.io/node-mongodb-native/5.0/classes/BulkWriteResult.html * * @example * const results = await User.bulkWrite([ * { * insertOne: { * document: { * firstName: 'John', * lastName: 'Wick' * }, * }, * }, * { * updateOne: { * filter: { lastName: 'Wick' }, * update: { * $set: { age: 40 }, * }, * }, * }, * ]); */ model.bulkWrite = wrap(model, async function bulkWrite(operations, options) { if (operations.length === 0) { return; } const finalOperations = await Promise.all(operations.map(async (op) => { let operation = op; if ('insertOne' in op) { operation = { insertOne: { document: { ...(await getDefaultValues(model.defaults)), ...op.insertOne.document, }, }, }; } else if ('updateOne' in op && op.updateOne.upsert && !Array.isArray(op.updateOne.update)) { const { update } = op.updateOne; operation = { updateOne: { ...op.updateOne, update: { ...update, $setOnInsert: cleanSetOnInsert({ ...(await getDefaultValues(model.defaults)), ...update.$setOnInsert, }, update), }, }, }; } return model.timestamps ? timestampBulkWriteOperation(operation, model.timestamps) : operation; })); const result = await model.collection.bulkWrite(finalOperations, { ...model.defaultOptions, ...options, }); return result; }); /** * @description * Calls the MongoDB [`countDocuments()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#countDocuments) method. * * @param filter {PaprFilter<TSchema>} * @param [options] {CountDocumentsOptions} * * @returns {Promise<number>} * * @example * const countAll = await User.countDocuments({}); * const countWicks = await User.countDocuments({ lastName: 'Wick' }); */ model.countDocuments = wrap(model, async function countDocuments(filter, options) { return model.collection.countDocuments(filter, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`deleteMany()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#deleteMany) method. * * @param filter {PaprFilter<TSchema>} * @param [options] {DeleteOptions} * * @returns {Promise<DeleteResult>} https://mongodb.github.io/node-mongodb-native/5.0/interfaces/DeleteResult.html * * @example * await User.deleteMany({ lastName: 'Wick' }); */ model.deleteMany = wrap(model, async function deleteMany(filter, options) { return model.collection.deleteMany(filter, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`deleteOne()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#deleteOne) method. * * @param filter {PaprFilter<TSchema>} * @param [options] {DeleteOptions} * * @returns {Promise<DeleteResult>} https://mongodb.github.io/node-mongodb-native/5.0/interfaces/DeleteResult.html * * @example * await User.deleteOne({ lastName: 'Wick' }); */ model.deleteOne = wrap(model, async function deleteOne(filter, options) { return model.collection.deleteOne(filter, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`distinct()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#distinct) method. * * @param key {"keyof TSchema"} * @param [filter] {PaprFilter<TSchema>} * @param [options] {DistinctOptions} * * @returns {Promise<Array<TValue>>} `TValue` is the type of the `key` field in the schema * * @example * const ages = await User.distinct('age'); */ // prettier-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore error TS2589: Type instantiation is excessively deep and possibly infinite. model.distinct = wrap(model, // @ts-expect-error Ignore error due to `wrap` arguments async function distinct(key, filter, options) { return model.collection.distinct(key, filter, { ...model.defaultOptions, ...options, }); }); /** * @description * Performs an optimized `find` to test for the existence of any document matching the filter criteria. * * @param filter {PaprFilter<TSchema>} * @param [options] {Omit<FindOptions, "projection" | "limit" | "sort" | "skip">} * * @returns {Promise<boolean>} * * @example * const isAlreadyActive = await User.exists({ * firstName: 'John', * lastName: 'Wick', * active: true * }); */ model.exists = async function exists(filter, options) { // If there are any entries in the filter, we project out the value from // only one of them. In this way, if there is an index that spans all the // parts of the filter, this can be a "covered" query. // @see https://www.mongodb.com/docs/manual/core/query-optimization/#covered-query // // Note that we must explicitly remove `_id` from the projection; it is often not // present in compound indexes, and mongo will automatically include it in the // result unless you explicitly exclude it from the projection. // // If you don't pass any filter option, we instead project out the primary // key, `_id` (which will override the earlier exclusion). // // @ts-expect-error Ignore `string` type mismatched to `keyof TSchema` const key = Object.keys(filter)[0] || '_id'; const result = await model.findOne(filter, { projection: { // @ts-expect-error `_id` is not found in the projection type _id: 0, [key]: 1, }, ...options, }); return !!result; }; /** * @description * Calls the MongoDB [`find()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#find) method. * * The result type (`TProjected`) takes into account the projection for this query and reduces the original `TSchema` type accordingly. See also [`ProjectionType`](api/utils.md#ProjectionType). * * @param filter {PaprFilter<TSchema>} * @param [options] {FindOptions} * * @returns {Promise<Array<TProjected>>} * * @example * const users = await User.find({ firstName: 'John' }); * users[0]?.firstName; // valid * users[0]?.lastName; // valid * * const usersProjected = await User.find( * { firstName: 'John' }, * { projection: { lastName: 1 } } * ); * usersProjected[0]?.firstName; // TypeScript error * usersProjected[0]?.lastName; // valid * */ // prettier-ignore model.find = wrap(model, async function find(filter, options) { return model.collection .find(filter, { ...model.defaultOptions, ...options, }) .toArray(); }); /** * @description * Calls the MongoDB [`findOne()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#findOne) method. * * The result type (`TProjected`) takes into account the projection for this query and reduces the original `TSchema` type accordingly. See also [`ProjectionType`](api/utils.md#ProjectionType). * * @param id {string|TSchema._id} * @param [options] {FindOptions} * * @returns {Promise<TProjected|null>} * * @example * const user = await User.findById('606ac819fa14e243e66ec4f4'); * user.firstName; // valid * user.lastName; // valid * * const userProjected = await User.findById( * new ObjectId('606ac819fa14e243e66ec4f4'), * { projection: { lastName: 1 } } * ); * userProjected.firstName; // TypeScript error * userProjected.lastName; // valid */ // prettier-ignore model.findById = wrap(model, async function findById(id, options) { // @ts-expect-error We're accessing runtime properties on the schema to determine id type // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const _id = model.schema.properties._id?.bsonType === 'objectId' ? new ObjectId(id) : id; return model.collection.findOne({ _id }, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`find()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#find) method and returns the cursor. * * Useful when you want to process many records without loading them all into * memory at once. * * @param filter {PaprFilter<TSchema>} * @param [options] {FindOptions} * * @example * const cursor = await User.findCursor( * { active: true, email: { $exists: true } }, * { projection: { email: 1 } } * ) * * for await (const user of cursor) { * await notify(user.email); * } */ model.findCursor = wrap(model, async function findCursor(filter, options) { return model.collection.find(filter, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`findOne()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#findOne) method. * * The result type (`TProjected`) takes into account the projection for this query and reduces the original `TSchema` type accordingly. See also [`ProjectionType`](api/utils.md#ProjectionType). * * @param filter {PaprFilter<TSchema>} * @param [options] {FindOptions} * * @returns {Promise<TProjected | null>} * * @example * const user = await User.findOne({ firstName: 'John' }); * user.firstName; // valid * user.lastName; // valid * * const userProjected = await User.findOne( * { firstName: 'John' }, * { projection: { lastName: 1 } } * ); * userProjected.firstName; // TypeScript error * userProjected.lastName; // valid */ // prettier-ignore model.findOne = wrap(model, async function findOne(filter, options) { return model.collection.findOne(filter, options); }); /** * @description * Calls the MongoDB [`findOneAndDelete()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#findOneAndDelete) method and returns the document found before removal. * * The result type (`TProjected`) takes into account the projection for this query and reduces the original `TSchema` type accordingly. See also [`ProjectionType`](api/utils.md#ProjectionType). * * @param filter {PaprFilter<TSchema>} * @param [options] {FindOneAndUpdateOptions} * * @returns {Promise<TProjected | null>} * * @example * const user = await User.findOneAndDelete({ firstName: 'John' }); */ // prettier-ignore model.findOneAndDelete = wrap(model, async function findOneAndDelete(filter, options) { const result = await model.collection.findOneAndDelete(filter, { ...model.defaultOptions, ...options, }); return result; }); /** * @description * Calls the MongoDB [`findOneAndUpdate()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#findOneAndUpdate) method. * * The result type (`TProjected`) takes into account the projection for this query and reduces the original `TSchema` type accordingly. See also [`ProjectionType`](api/utils.md#ProjectionType). * * @param filter {PaprFilter<TSchema>} * @param update {PaprUpdateFilter<TSchema>} * @param [options] {FindOneAndUpdateOptions} * * @returns {Promise<TProjected | null>} * * @example * const user = await User.findOneAndUpdate( * { firstName: 'John' }, * { $set: { age: 40 } } * ); * user.firstName; // valid * user.lastName; // valid * * const userProjected = await User.findOneAndUpdate( * { firstName: 'John' }, * { $set: { age: 40 } }, * { projection: { lastName: 1 } } * ); * userProjected.firstName; // TypeScript error * userProjected.lastName; // valid */ // prettier-ignore model.findOneAndUpdate = wrap(model, async function findOneAndUpdate(filter, update, options) { const finalUpdate = model.timestamps ? timestampUpdateFilter(update, model.timestamps) : update; // @ts-expect-error We can't let TS know that the current schema has timestamps attributes const created = { ...(model.timestamps && { [getTimestampProperty('createdAt', model.timestamps)]: new Date() }) }; const $setOnInsert = cleanSetOnInsert({ ...await getDefaultValues(model.defaults), ...finalUpdate.$setOnInsert, ...created, }, finalUpdate); const result = await model.collection.findOneAndUpdate(filter, { ...finalUpdate, ...(options?.upsert && Object.keys($setOnInsert).length > 0 && { $setOnInsert }), }, { returnDocument: 'after', ...model.defaultOptions, ...options, }); return result; }); /** * @description * Calls the MongoDB [`insertMany()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#insertMany) method. * * @param documents {Array<DocumentForInsert<TSchema, TOptions>>} * @param [options] {BulkWriteOptions} * * @returns {Promise<Array<TSchema>>} * * @example * const users = await User.insertMany([ * { firstName: 'John', lastName: 'Wick' }, * { firstName: 'John', lastName: 'Doe' } * ]); */ model.insertMany = wrap(model, async function insertMany(docs, options) { const documents = await Promise.all(docs.map(async (doc) => { return { ...(model.timestamps && { [getTimestampProperty('createdAt', model.timestamps)]: new Date(), [getTimestampProperty('updatedAt', model.timestamps)]: new Date(), }), ...(await getDefaultValues(model.defaults)), ...doc, }; })); const result = await model.collection.insertMany(documents, { ...model.defaultOptions, ...options, }); if (result.acknowledged && result.insertedCount === docs.length) { return documents.map((doc, index) => ({ ...doc, _id: result.insertedIds[index], })); } throw new Error('insertMany failed'); }); /** * @description * Calls the MongoDB [`insertOne()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#insertOne) method. * * @param document {DocumentForInsert<TSchema, TOptions>} * @param [options] {InsertOneOptions} * * @returns {Promise<TSchema>} * * @example * const users = await User.insertOne([ * { firstName: 'John', lastName: 'Wick' }, * { firstName: 'John', lastName: 'Doe' } * ]); */ model.insertOne = wrap(model, async function insertOne(doc, options) { const data = { ...(model.timestamps && { [getTimestampProperty('createdAt', model.timestamps)]: new Date(), [getTimestampProperty('updatedAt', model.timestamps)]: new Date(), }), ...(await getDefaultValues(model.defaults)), ...doc, }; // Casting to unknown first because TS complains here const result = await model.collection.insertOne(data, { ...model.defaultOptions, ...options, }); if (result.acknowledged) { return { ...data, _id: result.insertedId, }; } throw new Error('insertOne failed'); }); /** * @description * Calls the MongoDB [`updateMany()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#updateMany) method. * * @param filter {PaprFilter<TSchema>} * @param update {PaprUpdateFilter<TSchema>} * @param [options] {UpdateOptions} * * @returns {Promise<UpdateResult>} https://mongodb.github.io/node-mongodb-native/5.0/interfaces/UpdateResult.html * * @example * const result = await User.updateMany( * { firstName: 'John' }, * { $set: { age: 40 } } * ); */ model.updateMany = wrap(model, async function updateMany(filter, update, options) { const finalUpdate = model.timestamps ? timestampUpdateFilter(update, model.timestamps) : update; return model.collection.updateMany(filter, finalUpdate, { ...model.defaultOptions, ...options, }); }); /** * @description * Calls the MongoDB [`updateOne()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#updateOne) method. * * @param filter {PaprFilter<TSchema>} * @param update {PaprUpdateFilter<TSchema>} * @param [options] {UpdateOptions} * * @returns {Promise<UpdateResult>} https://mongodb.github.io/node-mongodb-native/5.0/interfaces/UpdateResult.html * * @example * const result = await User.updateOne( * { firstName: 'John' }, * { $set: { age: 40 } } * ); */ model.updateOne = wrap(model, async function updateOne(filter, update, options) { const finalUpdate = model.timestamps ? timestampUpdateFilter(update, model.timestamps) : update; // @ts-expect-error removing the upsert from options at runtime const { upsert, ...finalOptions } = options || {}; return model.collection.updateOne(filter, finalUpdate, { ...model.defaultOptions, ...finalOptions, }); }); /** * @description * Calls the MongoDB [`findOneAndUpdate()`](https://mongodb.github.io/node-mongodb-native/5.0/classes/Collection.html#findOneAndUpdate) method with the `upsert` option enabled. * * @param filter {PaprFilter<TSchema>} * @param update {PaprUpdateFilter<TSchema>} * @param [options] {FindOneAndUpdateOptions} * * @returns {Promise<TSchema>} * * @example * const user = await User.upsert( * { firstName: 'John', lastName: 'Wick' }, * { $set: { age: 40 } } * ); * * const userProjected = await User.upsert( * { firstName: 'John', lastName: 'Wick' }, * { $set: { age: 40 } }, * { projection: { lastName: 1 } } * ); * userProjected.firstName; // TypeScript error * userProjected.lastName; // valid */ model.upsert = async function upsert(filter, update, options) { const item = await model.findOneAndUpdate(filter, update, { ...options, upsert: true, }); if (!item) { throw new Error('upsert failed'); } return item; }; }