From 2c127e5eeefd84ee58fb847c5e246b810145b943 Mon Sep 17 00:00:00 2001 From: Joshua Turner Date: Mon, 31 Jul 2023 04:36:42 -0500 Subject: [PATCH] feat: Support dynamic default values --- docs/api/schema.md | 16 +- docs/recipes.md | 36 ++++- src/__tests__/model.test.ts | 299 +++++++++++++++++++++++++++++++++++- src/__tests__/utils.test.ts | 60 ++++++-- src/model.ts | 17 +- src/schema.ts | 6 +- src/utils.ts | 25 ++- 7 files changed, 419 insertions(+), 40 deletions(-) diff --git a/docs/api/schema.md b/docs/api/schema.md index 5cb4ccbf..c204c9fe 100644 --- a/docs/api/schema.md +++ b/docs/api/schema.md @@ -20,14 +20,14 @@ The options are exported as a result type (the second value in the return tuple) **Parameters:** -| Name | Type | Attribute | -| -------------------------- | ------------------------- | --------- | -| `properties` | `Record` | required | -| `options` | `SchemaOptions` | optional | -| `options.defaults` | `Partial` | optional | -| `options.timestamps` | `TimestampSchemaOptions` | optional | -| `options.validationAction` | `VALIDATION_ACTIONS` | optional | -| `options.validationLevel` | `VALIDATION_LEVEL` | optional | +| Name | Type | Attribute | +| -------------------------- | ----------------------------- | --------- | +| `properties` | `Record` | required | +| `options` | `SchemaOptions` | optional | +| `options.defaults` | `DefaultsOption` | optional | +| `options.timestamps` | `TimestampSchemaOptions` | optional | +| `options.validationAction` | `VALIDATION_ACTIONS` | optional | +| `options.validationLevel` | `VALIDATION_LEVEL` | optional | **Returns:** diff --git a/docs/recipes.md b/docs/recipes.md index 36f213f0..49bed357 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -87,7 +87,13 @@ const exampleSchema = schema( ### Default Values -Papr does not support dynamic default values, but static default values can be used. Unlike Mongoose where default values are defined in the individual property options, Papr defines defaults in the schema options. An example of this can be seen below. +Unlike Mongoose where default values are defined in the individual property options, Papr defines defaults in the schema options. + +Note: Default values are only applied to paths where no value is set at the time of insert. + +#### Static Default Values + +To set defaults you can supply an object in your schema with static values. ```js import mongoose from 'mongoose'; @@ -113,6 +119,34 @@ const exampleSchema = schema( ); ``` +#### Dynamic Default Values + +Rather than supplying an object with your default values you can supply a function which will be executed at the time of insert and the returned values used as defaults. + +```js +import mongoose from 'mongoose'; +const { Schema } = mongoose; + +const exampleSchema = new Schema({ + birthday: { type: Date, default: Date.now, required: true }, +}); +``` + +```ts +import { schema, types } from 'papr'; + +const exampleSchema = schema( + { + birthday: types.date({ required: true }), + }, + { + defaults: () => ({ + birthday: new Date(), + }), + } +); +``` + ### Version Key Mongoose automatically adds a `versionKey` to all of your schemas - you will need to either remove that value from your collections or include the matching key in your Papr schema when migrating. The default value for this property is `__v`, but may be changed in your Mongoose schema options. An example of this can be seen below. diff --git a/src/__tests__/model.test.ts b/src/__tests__/model.test.ts index 0d28cbb6..0f8b0c3a 100644 --- a/src/__tests__/model.test.ts +++ b/src/__tests__/model.test.ts @@ -84,6 +84,23 @@ describe('model', () => { } ); + const dynamicDefaultsSchema = schema( + { + bar: Types.number({ required: true }), + foo: Types.string({ required: true }), + ham: Types.date(), + nested: Types.object({ + direct: Types.string({ required: true }), + other: Types.number(), + }), + }, + { + defaults: () => ({ + bar: new Date().getTime(), + }), + } + ); + type SimpleDocument = (typeof simpleSchema)[0]; type SimpleOptions = (typeof simpleSchema)[1]; type TimestampsDocument = (typeof timestampsSchema)[0]; @@ -92,11 +109,14 @@ describe('model', () => { type TimestampConfigOptions = (typeof timestampConfigSchema)[1]; type NumericIdDocument = (typeof numericIdSchema)[0]; type NumericIdOptions = (typeof numericIdSchema)[1]; + type DynamicDefaultsDocument = (typeof dynamicDefaultsSchema)[0]; + type DynamicDefaultsOptions = (typeof dynamicDefaultsSchema)[1]; let simpleModel: Model; let timestampsModel: Model; let timestampConfigModel: Model; let numericIdModel: Model; + let dynamicDefaultsModel: Model; let doc: SimpleDocument; let docs: SimpleDocument[]; @@ -187,6 +207,11 @@ describe('model', () => { numericIdModel = abstract(numericIdSchema); // @ts-expect-error Ignore schema types build(numericIdSchema, numericIdModel, collection); + + // @ts-expect-error Ignore abstract assignment + dynamicDefaultsModel = abstract(dynamicDefaultsSchema); + // @ts-expect-error Ignore schema types + build(dynamicDefaultsSchema, dynamicDefaultsModel, collection); }); describe('aggregate', () => { @@ -437,6 +462,174 @@ describe('model', () => { ); }); + test('schema with dynamic defaults', async () => { + jest.useFakeTimers({ now: 123456 }); + + const operations: PaprBulkWriteOperation[] = + [ + { + insertOne: { + document: { + bar: 123, + foo: 'foo', + }, + }, + }, + { + insertOne: { + document: { + foo: 'foo', + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + bar: 123, + foo: 'foo', + }, + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + foo: 'foo', + }, + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + bar: 123, + foo: 'foo', + }, + }, + upsert: true, + }, + }, + { + updateOne: { + filter: {}, + update: { + $inc: { + bar: 123, + }, + $set: { + foo: 'foo', + }, + }, + upsert: true, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + foo: 'foo', + }, + }, + upsert: true, + }, + }, + ]; + + await dynamicDefaultsModel.bulkWrite(operations); + + expect(collection.bulkWrite).toHaveBeenCalledWith( + [ + { + insertOne: { + document: { + bar: 123, + foo: 'foo', + }, + }, + }, + { + insertOne: { + document: { + bar: 123456, + foo: 'foo', + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + bar: 123, + foo: 'foo', + }, + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + foo: 'foo', + }, + }, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + bar: 123, + foo: 'foo', + }, + $setOnInsert: {}, + }, + upsert: true, + }, + }, + { + updateOne: { + filter: {}, + update: { + $inc: { + bar: 123, + }, + $set: { + foo: 'foo', + }, + $setOnInsert: {}, + }, + upsert: true, + }, + }, + { + updateOne: { + filter: {}, + update: { + $set: { + foo: 'foo', + }, + $setOnInsert: { + bar: 123456, + }, + }, + upsert: true, + }, + }, + ], + { ignoreUndefined: true } + ); + }); + test('timestamps schema', async () => { await timestampsModel.bulkWrite([ { @@ -1690,6 +1883,33 @@ describe('model', () => { expectType(result._updatedDate); }); + test('dynamic defaults schema, inserts defaults', async () => { + jest.useFakeTimers({ now: 123456 }); + + const result = await dynamicDefaultsModel.insertOne({ + foo: 'foo', + }); + + expect(collection.insertOne).toHaveBeenCalledWith( + { + bar: 123456, + foo: 'foo', + }, + { ignoreUndefined: true } + ); + + expectType(result); + + expectType(result._id); + expectType(result.foo); + expectType(result.bar); + expectType(result.ham); + // @ts-expect-error `createdAt` is undefined here + result.createdAt; + // @ts-expect-error `updatedAt` is undefined here + result.updatedAt; + }); + test('throws error on not acknowledged result', async () => { (collection.insertOne as jest.Mocked).mockResolvedValue({ acknowledged: false, @@ -1926,6 +2146,46 @@ describe('model', () => { } }); + test('dynamic defaults schema, inserts defaults', async () => { + jest.useFakeTimers({ now: 123456 }); + + const result = await dynamicDefaultsModel.insertMany([ + { + foo: 'foo', + }, + { + foo: 'bar', + }, + ]); + + expect(collection.insertMany).toHaveBeenCalledWith( + [ + { + bar: 123456, + foo: 'foo', + }, + { + bar: 123456, + foo: 'bar', + }, + ], + { ignoreUndefined: true } + ); + + expectType(result); + + for (const item of result) { + expectType(item._id); + expectType(item.foo); + expectType(item.bar); + expectType(item.ham); + // @ts-expect-error `createdAt` is undefined here + item.createdAt; + // @ts-expect-error `updatedAt` is undefined here + item.updatedAt; + } + }); + test('throws error on not acknowledged result', async () => { (collection.insertMany as jest.Mocked).mockResolvedValue({ acknowledged: false, @@ -2194,8 +2454,9 @@ describe('model', () => { }); describe('upsert', () => { - test('default', async () => { - const result = await simpleModel.upsert({ foo: 'foo' }, { $set: { bar: 123 } }); + test('static defaults', async () => { + const date = new Date(); + const result = await simpleModel.upsert({ foo: 'foo' }, { $set: { ham: date } }); expectType(result); @@ -2211,7 +2472,39 @@ describe('model', () => { expect(collection.findOneAndUpdate).toHaveBeenCalledWith( { foo: 'foo' }, { - $set: { bar: 123 }, + $set: { ham: date }, + $setOnInsert: { bar: 123456 }, + }, + { + ignoreUndefined: true, + returnDocument: 'after', + upsert: true, + } + ); + }); + + test('dynamic defaults', async () => { + jest.useFakeTimers({ now: 123456 }); + + const date = new Date(); + const result = await dynamicDefaultsModel.upsert({ foo: 'foo' }, { $set: { ham: date } }); + + expectType(result); + + expectType(result._id); + expectType(result.foo); + expectType(result.bar); + expectType(result.ham); + // @ts-expect-error `createdAt` is undefined here + result.createdAt; + // @ts-expect-error `updatedAt` is undefined here + result.updatedAt; + + expect(collection.findOneAndUpdate).toHaveBeenCalledWith( + { foo: 'foo' }, + { + $set: { ham: date }, + $setOnInsert: { bar: 123456 }, }, { ignoreUndefined: true, diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index df94e30b..755e8fcc 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from '@jest/globals'; import { ObjectId } from 'mongodb'; import { expectType } from 'ts-expect'; -import { NestedPaths, ProjectionType, getIds, PropertyType } from '../utils'; +import { DefaultsOption } from '../schema'; +import { NestedPaths, ProjectionType, getIds, PropertyType, getDefaultValues } from '../utils'; describe('utils', () => { interface TestDocument { @@ -325,20 +326,47 @@ describe('utils', () => { }); }); - test.each([ - ['strings', ['123456789012345678900001', '123456789012345678900002']], - [ - 'objectIds', - [new ObjectId('123456789012345678900001'), new ObjectId('123456789012345678900002')], - ], - ['mixed', ['123456789012345678900001', new ObjectId('123456789012345678900002')]], - ])('getIds %s', (_name, input) => { - const result = getIds(input); - - expect(result).toHaveLength(2); - expect(result[0] instanceof ObjectId).toBeTruthy(); - expect(result[0].toHexString()).toBe('123456789012345678900001'); - expect(result[1] instanceof ObjectId).toBeTruthy(); - expect(result[1].toHexString()).toBe('123456789012345678900002'); + describe('getDefaultValues', () => { + test('static values', () => { + const defaults: DefaultsOption = { + bar: 1, + foo: 'test', + }; + + const result = getDefaultValues(defaults); + expectType>(result); + expect(result).toStrictEqual(defaults); + }); + + test('dynamic values', () => { + const defaults: DefaultsOption = () => ({ + bar: 1, + foo: 'test', + ham: new Date(), + }); + + const result = getDefaultValues(defaults); + expectType>(result); + expect(result.ham instanceof Date).toBeTruthy(); + }); + }); + + describe('getIds', () => { + test.each([ + ['strings', ['123456789012345678900001', '123456789012345678900002']], + [ + 'objectIds', + [new ObjectId('123456789012345678900001'), new ObjectId('123456789012345678900002')], + ], + ['mixed', ['123456789012345678900001', new ObjectId('123456789012345678900002')]], + ])('%s', (_name, input) => { + const result = getIds(input); + + expect(result).toHaveLength(2); + expect(result[0] instanceof ObjectId).toBeTruthy(); + expect(result[0].toHexString()).toBe('123456789012345678900001'); + expect(result[1] instanceof ObjectId).toBeTruthy(); + expect(result[1].toHexString()).toBe('123456789012345678900002'); + }); }); }); diff --git a/src/model.ts b/src/model.ts index 75c9c4e0..b3d75250 100644 --- a/src/model.ts +++ b/src/model.ts @@ -25,11 +25,12 @@ import type { } from 'mongodb'; import { serializeArguments } from './hooks'; import type { PaprBulkWriteOperation, PaprFilter, PaprUpdateFilter } from './mongodbTypes'; -import { SchemaOptions, SchemaTimestampOptions } from './schema'; +import { DefaultsOption, SchemaOptions, SchemaTimestampOptions } from './schema'; import { BaseSchema, cleanSetOnInsert, DocumentForInsert, + getDefaultValues, getTimestampProperty, ModelOptions, Projection, @@ -40,7 +41,7 @@ import { export interface Model> { collection: Collection; - defaults?: Partial; + defaults?: DefaultsOption; defaultOptions: { ignoreUndefined?: boolean; maxTimeMS?: number; @@ -272,7 +273,7 @@ export function build | boolean; +export type DefaultsOption = Partial | (() => Partial); + export interface SchemaOptions { - defaults?: Partial; + defaults?: DefaultsOption; timestamps?: SchemaTimestampOptions; validationAction?: VALIDATION_ACTIONS; validationLevel?: VALIDATION_LEVEL; @@ -74,7 +76,7 @@ function sanitize(value: any): void { * * @param properties {Record} * @param [options] {SchemaOptions} - * @param [options.defaults] {Partial} + * @param [options.defaults] {DefaultsOption} * @param [options.timestamps=false] {TimestampSchemaOptions} * @param [options.validationAction=VALIDATION_ACTIONS.ERROR] {VALIDATION_ACTIONS} * @param [options.validationLevel=VALIDATION_LEVEL.STRICT] {VALIDATION_LEVEL} diff --git a/src/utils.ts b/src/utils.ts index e173bb3d..c6e18c37 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,7 @@ import type { Join, KeysOfAType, OptionalId, WithId } from 'mongodb'; import type { DeepPick } from './DeepPick'; import type { Hooks } from './hooks'; import type { PaprBulkWriteOperation, PaprUpdateFilter } from './mongodbTypes'; -import type { SchemaOptions, SchemaTimestampOptions } from './schema'; +import type { DefaultsOption, SchemaOptions, SchemaTimestampOptions } from './schema'; // Some of the types are adapted from originals at: https://github.com/mongodb/node-mongodb-native/blob/v5.0.1/src/mongo_types.ts // licensed under Apache License 2.0: https://github.com/mongodb/node-mongodb-native/blob/v5.0.1/LICENSE.md @@ -53,10 +53,17 @@ export type DocumentForInsertWithoutDefaults & Partial>; +export type SchemaDefaultValues< + TSchema, + TOptions extends SchemaOptions, +> = TOptions['defaults'] extends () => infer ReturnDefaults ? ReturnDefaults : TOptions['defaults']; + export type DocumentForInsert< TSchema, TOptions extends SchemaOptions, - TDefaults extends NonNullable = NonNullable, + TDefaults extends NonNullable> = NonNullable< + SchemaDefaultValues + >, > = TOptions['timestamps'] extends SchemaTimestampOptions ? TOptions['timestamps'] extends false ? DocumentForInsertWithoutDefaults @@ -273,6 +280,20 @@ export function getIds(ids: Set | readonly (ObjectId | string)[]): Objec * ``` */ +// Checks the type of the model defaults property and if a function, returns +// the result of the function call, otherwise returns the object +export function getDefaultValues( + defaults?: DefaultsOption +): Partial { + if (typeof defaults === 'function') { + return defaults(); + } + if (typeof defaults === 'object') { + return defaults; + } + return {}; +} + // Returns either the default timestamp property or the value supplied in timestamp options export function getTimestampProperty< TProperty extends keyof Exclude,