diff --git a/lib/decorators/index.ts b/lib/decorators/index.ts index 6124afb7..ec4a7c3c 100644 --- a/lib/decorators/index.ts +++ b/lib/decorators/index.ts @@ -1,2 +1,3 @@ export * from './prop.decorator'; export * from './schema.decorator'; +export * from './virtual.decorator'; diff --git a/lib/decorators/virtual.decorator.ts b/lib/decorators/virtual.decorator.ts new file mode 100644 index 00000000..6dd758e4 --- /dev/null +++ b/lib/decorators/virtual.decorator.ts @@ -0,0 +1,42 @@ +import { VirtualTypeOptions } from 'mongoose'; +import { TypeMetadataStorage } from '../storages/type-metadata.storage'; + +/** + * Interface defining the options that can be passed to the `@Virtual()` decorator. + */ +export interface VirtualOptions { + /** + * The options to pass to the virtual type. + */ + options?: VirtualTypeOptions; + /** + * The sub path to use for the virtual. + * Defaults to the property key. + */ + subPath?: string; + /** + * The getter function to use for the virtual. + */ + get?: (...args: any[]) => any; + /** + * The setter function to use for the virtual. + */ + set?: (...args: any[]) => any; +} + +/** + * The Virtual decorator marks a class property as a Mongoose virtual. + */ +export function Virtual(options?: VirtualOptions): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + TypeMetadataStorage.addVirtualMetadata({ + target: target.constructor, + options: options?.options, + name: + propertyKey.toString() + + (options?.subPath ? `.${options.subPath}` : ''), + setter: options?.set, + getter: options?.get, + }); + }; +} diff --git a/lib/factories/index.ts b/lib/factories/index.ts index c979f514..ef2661dd 100644 --- a/lib/factories/index.ts +++ b/lib/factories/index.ts @@ -1,2 +1,3 @@ export * from './definitions.factory'; export * from './schema.factory'; +export * from './virtuals.factory'; diff --git a/lib/factories/schema.factory.ts b/lib/factories/schema.factory.ts index 801be63a..76f4c4f0 100644 --- a/lib/factories/schema.factory.ts +++ b/lib/factories/schema.factory.ts @@ -3,6 +3,7 @@ import * as mongoose from 'mongoose'; import { SchemaDefinition, SchemaDefinitionType } from 'mongoose'; import { TypeMetadataStorage } from '../storages/type-metadata.storage'; import { DefinitionsFactory } from './definitions.factory'; +import { VirtualsFactory } from './virtuals.factory'; export class SchemaFactory { static createForClass( @@ -13,9 +14,13 @@ export class SchemaFactory { TypeMetadataStorage.getSchemaMetadataByTarget(target); const schemaOpts = schemaMetadata?.options; - return new mongoose.Schema( + const schema = new mongoose.Schema( schemaDefinition as SchemaDefinition>, schemaOpts as mongoose.SchemaOptions, ); + + VirtualsFactory.inspect(target, schema); + + return schema; } } diff --git a/lib/factories/virtuals.factory.ts b/lib/factories/virtuals.factory.ts new file mode 100644 index 00000000..b7122be5 --- /dev/null +++ b/lib/factories/virtuals.factory.ts @@ -0,0 +1,24 @@ +import { Type } from '@nestjs/common'; +import * as mongoose from 'mongoose'; +import { TypeMetadataStorage } from '../storages/type-metadata.storage'; + +export class VirtualsFactory { + static inspect( + target: Type, + schema: mongoose.Schema, + ): void { + const virtuals = TypeMetadataStorage.getVirtualsMetadataByTarget(target); + + virtuals.forEach(({ options, name, getter, setter }) => { + const virtual = schema.virtual(name, options); + + if (getter) { + virtual.get(getter); + } + + if (setter) { + virtual.set(setter); + } + }); + } +} diff --git a/lib/metadata/virtual-metadata.interface.ts b/lib/metadata/virtual-metadata.interface.ts new file mode 100644 index 00000000..473ad828 --- /dev/null +++ b/lib/metadata/virtual-metadata.interface.ts @@ -0,0 +1,9 @@ +import { VirtualTypeOptions } from 'mongoose'; + +export interface VirtualMetadataInterface { + target: Function; + name: string; + options?: VirtualTypeOptions; + getter?: (...args: any[]) => any; + setter?: (...args: any[]) => any; +} diff --git a/lib/storages/type-metadata.storage.ts b/lib/storages/type-metadata.storage.ts index 25073689..20578234 100644 --- a/lib/storages/type-metadata.storage.ts +++ b/lib/storages/type-metadata.storage.ts @@ -1,11 +1,13 @@ import { Type } from '@nestjs/common'; import { PropertyMetadata } from '../metadata/property-metadata.interface'; import { SchemaMetadata } from '../metadata/schema-metadata.interface'; +import { VirtualMetadataInterface } from '../metadata/virtual-metadata.interface'; import { isTargetEqual } from '../utils/is-target-equal-util'; export class TypeMetadataStorageHost { private schemas = new Array(); private properties = new Array(); + private virtuals = new Array(); addPropertyMetadata(metadata: PropertyMetadata) { this.properties.unshift(metadata); @@ -16,6 +18,10 @@ export class TypeMetadataStorageHost { this.schemas.push(metadata); } + addVirtualMetadata(metadata: VirtualMetadataInterface) { + this.virtuals.push(metadata); + } + getSchemaMetadataByTarget(target: Type): SchemaMetadata | undefined { return this.schemas.find((item) => item.target === target); } @@ -33,6 +39,10 @@ export class TypeMetadataStorageHost { ) { return this.properties.filter(belongsToClass); } + + getVirtualsMetadataByTarget(targetFilter: Type) { + return this.virtuals.filter(({ target }) => target === targetFilter); + } } const globalRef = global as any; diff --git a/tests/e2e/schema.factory.spec.ts b/tests/e2e/schema.factory.spec.ts index db50b49e..fd7dd2a2 100644 --- a/tests/e2e/schema.factory.spec.ts +++ b/tests/e2e/schema.factory.spec.ts @@ -1,4 +1,5 @@ -import { Prop, Schema, SchemaFactory } from '../../lib'; +import { VirtualTypeOptions } from 'mongoose'; +import { Prop, Schema, SchemaFactory, Virtual } from '../../lib'; @Schema({ validateBeforeSave: false, _id: true, autoIndex: true }) class ChildClass { @@ -9,6 +10,9 @@ class ChildClass { name: string; } +const getterFunctionMock = jest.fn(); +const setterFunctionMock = jest.fn(); + @Schema({ validateBeforeSave: false, _id: true, @@ -24,6 +28,21 @@ class ExampleClass { @Prop() array: Array; + + @Virtual({ + options: { + localField: 'array', + ref: 'ChildClass', + foreignField: 'id', + }, + }) + virtualPropsWithOptions: Array; + + @Virtual({ + get: getterFunctionMock, + set: setterFunctionMock, + }) + virtualPropsWithGetterSetterFunctions: Array; } describe('SchemaFactory', () => { @@ -50,4 +69,38 @@ describe('SchemaFactory', () => { }), ); }); + + it('should define for schema a virtuals with options', () => { + const { + virtuals: { virtualPropsWithOptions }, + } = SchemaFactory.createForClass(ExampleClass) as any; + + expect(virtualPropsWithOptions).toEqual( + expect.objectContaining({ + path: 'virtualPropsWithOptions', + setters: [expect.any(Function)], + getters: [], + options: expect.objectContaining({ + localField: 'array', + ref: 'ChildClass', + foreignField: 'id', + }), + }), + ); + }); + + it('should define for schema a virtual with getter/setter functions', () => { + const { + virtuals: { virtualPropsWithGetterSetterFunctions }, + } = SchemaFactory.createForClass(ExampleClass) as any; + + expect(virtualPropsWithGetterSetterFunctions).toEqual( + expect.objectContaining({ + path: 'virtualPropsWithGetterSetterFunctions', + setters: [setterFunctionMock], + getters: [getterFunctionMock], + options: {}, + }), + ); + }); }); diff --git a/tests/e2e/virtual.factory.spec.ts b/tests/e2e/virtual.factory.spec.ts new file mode 100644 index 00000000..b74fd483 --- /dev/null +++ b/tests/e2e/virtual.factory.spec.ts @@ -0,0 +1,140 @@ +import { VirtualsFactory } from '../../lib'; +import { VirtualMetadataInterface } from '../../lib/metadata/virtual-metadata.interface'; +import { TypeMetadataStorage } from '../../lib/storages/type-metadata.storage'; + +describe('VirtualsFactory', () => { + const setVirtualSetterFunctionMock = jest.fn(); + const setVirtualGetterFunctionMock = jest.fn(); + const schemaMock = { + virtual: jest.fn(() => ({ + get: setVirtualGetterFunctionMock, + set: setVirtualSetterFunctionMock, + })), + } as any; + + const targetConstructorMock = jest.fn(); + + const virtualOptionsMock = { + ref: 'collectionNameMock', + localField: 'localFieldMockValue', + foreignField: 'foreignFieldMockValue', + }; + + const virtualMetadataWithOnlyRequiredAttributesMock = { + target: targetConstructorMock, + name: 'attribute1Mock', + }; + + const virtualMetadataNotLikedToModelMock = { + target: jest.fn(), + name: 'attribute1Mock', + }; + + const virtualMetadataWithOptionsMock = { + target: targetConstructorMock, + name: 'virtualMetadataWithOptionsMock', + options: virtualOptionsMock, + }; + + const virtualMetadataWithGetterMock = { + target: targetConstructorMock, + name: 'virtualMetadataWithGetterMock', + options: virtualOptionsMock, + getter: jest.fn(), + }; + + const virtualMetadataWithSetterMock = { + target: targetConstructorMock, + name: 'virtualMetadataWithSetterMock', + options: virtualOptionsMock, + setter: jest.fn(), + }; + + const virtualMetadataWithGetterSetterMock = { + target: targetConstructorMock, + name: 'virtualMetadataWithGetterSetterMock', + options: virtualOptionsMock, + getter: jest.fn(), + setter: jest.fn(), + }; + + beforeEach(() => { + (schemaMock.virtual as any) = jest.fn(() => ({ + get: setVirtualGetterFunctionMock, + set: setVirtualSetterFunctionMock, + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Schema virtual definition', () => { + it('should not define virtuals if there is no stored virtual definition', () => { + TypeMetadataStorage['virtuals'] = []; + + VirtualsFactory.inspect(targetConstructorMock, schemaMock); + + expect(schemaMock.virtual).toHaveBeenCalledTimes(0); + }); + + it('should not define virtuals if there is no stored virtual definition linked to schema model', () => { + TypeMetadataStorage['virtuals'] = [virtualMetadataNotLikedToModelMock]; + + VirtualsFactory.inspect(targetConstructorMock, schemaMock); + + expect(schemaMock.virtual).toHaveBeenCalledTimes(0); + }); + + it('should defines virtual for each stored virtualMetadata linked to schema model', () => { + TypeMetadataStorage['virtuals'] = [ + virtualMetadataWithOnlyRequiredAttributesMock, + virtualMetadataNotLikedToModelMock, + virtualMetadataWithOptionsMock, + ]; + + VirtualsFactory.inspect(targetConstructorMock, schemaMock); + + expect(schemaMock.virtual['mock'].calls).toEqual([ + [virtualMetadataWithOnlyRequiredAttributesMock.name, undefined], + [ + virtualMetadataWithOptionsMock.name, + virtualMetadataWithOptionsMock.options, + ], + ]); + }); + }); + + describe('Schema virtual getter/setter definition', () => { + it('should not call the getter/setter definition method if no getter/setter defined in the stored virtual metadata linked to the schema model', () => { + TypeMetadataStorage['virtuals'] = [ + virtualMetadataWithOptionsMock, + ] as VirtualMetadataInterface[]; + + VirtualsFactory.inspect(targetConstructorMock, schemaMock); + + expect(setVirtualGetterFunctionMock).toHaveBeenCalledTimes(0); + expect(setVirtualSetterFunctionMock).toHaveBeenCalledTimes(0); + }); + + it('should call the getter/setter definition method for each stored virtuals metadata with defined getter/setter linked to the schema model', () => { + TypeMetadataStorage['virtuals'] = [ + virtualMetadataWithOptionsMock, + virtualMetadataWithGetterMock, + virtualMetadataWithSetterMock, + virtualMetadataWithGetterSetterMock, + ] as VirtualMetadataInterface[]; + + VirtualsFactory.inspect(targetConstructorMock, schemaMock); + + expect(setVirtualGetterFunctionMock.mock.calls).toEqual([ + [virtualMetadataWithGetterMock.getter], + [virtualMetadataWithGetterSetterMock.getter], + ]); + expect(setVirtualSetterFunctionMock.mock.calls).toEqual([ + [virtualMetadataWithSetterMock.setter], + [virtualMetadataWithGetterSetterMock.setter], + ]); + }); + }); +});