papr
Version:
MongoDB TypeScript-aware Models
711 lines (710 loc) • 26.7 kB
JavaScript
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;
};
}