From 107bba040a2b1d423deb4f1e428a43cecab48e79 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Thu, 1 Oct 2020 09:11:08 -0500 Subject: [PATCH] feat(mongoose): Hardening reference support --- .../assemblers/class-transformer.assembler.ts | 2 +- packages/query-mongoose/.eslintrc.js | 1 + .../__tests__/__fixtures__/seeds.ts | 13 +- .../__tests__/__fixtures__/test.entity.ts | 3 - .../query-mongoose/__tests__/module.spec.ts | 6 +- .../__tests__/providers.spec.ts | 9 +- .../__tests__/query/aggregate.builder.spec.ts | 71 +++++ .../services/mongoose-query.service.spec.ts | 285 ++++++++++++------ packages/query-mongoose/package.json | 8 +- packages/query-mongoose/src/module.ts | 7 +- .../src/mongoose-types.helper.ts | 2 +- packages/query-mongoose/src/providers.ts | 27 +- .../src/query/aggregate.builder.ts | 15 +- .../src/query/filter-query.builder.ts | 19 +- .../src/services/mongoose-query.service.ts | 38 ++- .../src/services/reference-query.service.ts | 172 ++++++----- packages/query-mongoose/tsconfig.build.json | 11 - packages/query-mongoose/tsconfig.json | 11 +- 18 files changed, 468 insertions(+), 232 deletions(-) create mode 100644 packages/query-mongoose/__tests__/query/aggregate.builder.spec.ts delete mode 100644 packages/query-mongoose/tsconfig.build.json diff --git a/packages/core/src/assemblers/class-transformer.assembler.ts b/packages/core/src/assemblers/class-transformer.assembler.ts index 49f5c610e..fe721749b 100644 --- a/packages/core/src/assemblers/class-transformer.assembler.ts +++ b/packages/core/src/assemblers/class-transformer.assembler.ts @@ -55,7 +55,7 @@ export abstract class ClassTransformerAssembler extends AbstractAss // eslint-disable-next-line @typescript-eslint/ban-types toPlain(entityOrDto: Entity | DTO): object { - if (entityOrDto instanceof this.EntityClass) { + if (entityOrDto && entityOrDto instanceof this.EntityClass) { const serializer = getAssemblerSerializer(this.EntityClass); if (serializer) { return serializer(entityOrDto); diff --git a/packages/query-mongoose/.eslintrc.js b/packages/query-mongoose/.eslintrc.js index 60e9eccb1..de68ee560 100644 --- a/packages/query-mongoose/.eslintrc.js +++ b/packages/query-mongoose/.eslintrc.js @@ -9,6 +9,7 @@ module.exports = { assertFunctionNames: [ 'expect', 'assertFilterQuery', + 'assertQuery', 'expectEqualEntities', 'expectEqualCreate' ], diff --git a/packages/query-mongoose/__tests__/__fixtures__/seeds.ts b/packages/query-mongoose/__tests__/__fixtures__/seeds.ts index 2452c4a13..c2a5f9787 100644 --- a/packages/query-mongoose/__tests__/__fixtures__/seeds.ts +++ b/packages/query-mongoose/__tests__/__fixtures__/seeds.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-underscore-dangle,@typescript-eslint/no-unsafe-return */ import { Connection } from 'mongoose'; import { TestEntity } from './test.entity'; import { TestReference } from './test-reference.entity'; @@ -33,20 +33,21 @@ export const seed = async (connection: Connection): Promise => { const testEntities = await TestEntityModel.create(TEST_ENTITIES); const testReferences = await TestReferencesModel.create(TEST_REFERENCES); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - testEntities.forEach((te, index) => Object.assign(TEST_ENTITIES[index], te.toObject())); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - testReferences.forEach((tr, index) => Object.assign(TEST_REFERENCES[index], tr.toObject())); + testEntities.forEach((te, index) => Object.assign(TEST_ENTITIES[index], te.toObject({ virtuals: true }))); + testReferences.forEach((tr, index) => Object.assign(TEST_REFERENCES[index], tr.toObject({ virtuals: true }))); await Promise.all( testEntities.map(async (te, index) => { const references = testReferences.filter((tr) => tr.referenceName.includes(`${te.stringType}-`)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment TEST_ENTITIES[index].testReference = references[0]._id; TEST_ENTITIES[index].testReferences = references.map((r) => r._id); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment await te.update({ $set: { testReferences: references.map((r) => r._id), testReference: references[0]._id } }); await Promise.all( references.map((r) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access TEST_REFERENCES.find((tr) => tr._id.toString() === r._id.toString())!.testEntity = te._id; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return r.update({ $set: { testEntity: te._id } }); }), ); diff --git a/packages/query-mongoose/__tests__/__fixtures__/test.entity.ts b/packages/query-mongoose/__tests__/__fixtures__/test.entity.ts index f6d20e155..575ed6736 100644 --- a/packages/query-mongoose/__tests__/__fixtures__/test.entity.ts +++ b/packages/query-mongoose/__tests__/__fixtures__/test.entity.ts @@ -1,6 +1,5 @@ import { Document, Types, SchemaTypes } from 'mongoose'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { TestReference } from 'packages/query-mongoose/__tests__/__fixtures__/test-reference.entity'; @Schema() export class TestEntity extends Document { @@ -21,8 +20,6 @@ export class TestEntity extends Document { @Prop([{ type: SchemaTypes.ObjectId, ref: 'TestReference' }]) testReferences?: Types.ObjectId[]; - - virtualTestReference?: TestReference; } export const TestEntitySchema = SchemaFactory.createForClass(TestEntity); diff --git a/packages/query-mongoose/__tests__/module.spec.ts b/packages/query-mongoose/__tests__/module.spec.ts index f061bfe1e..c7a475185 100644 --- a/packages/query-mongoose/__tests__/module.spec.ts +++ b/packages/query-mongoose/__tests__/module.spec.ts @@ -1,9 +1,11 @@ import { NestjsQueryMongooseModule } from '../src'; +import { TestEntity, TestEntitySchema } from './__fixtures__'; describe('NestjsQueryTypegooseModule', () => { it('should create a module', () => { - class TestEntity {} - const typeOrmModule = NestjsQueryMongooseModule.forFeature([TestEntity]); + const typeOrmModule = NestjsQueryMongooseModule.forFeature([ + { document: TestEntity, name: TestEntity.name, schema: TestEntitySchema }, + ]); expect(typeOrmModule.imports).toHaveLength(1); expect(typeOrmModule.module).toBe(NestjsQueryMongooseModule); expect(typeOrmModule.providers).toHaveLength(1); diff --git a/packages/query-mongoose/__tests__/providers.spec.ts b/packages/query-mongoose/__tests__/providers.spec.ts index 1fe903857..2817fcfba 100644 --- a/packages/query-mongoose/__tests__/providers.spec.ts +++ b/packages/query-mongoose/__tests__/providers.spec.ts @@ -1,12 +1,15 @@ import { getQueryServiceToken } from '@nestjs-query/core'; import { instance } from 'ts-mockito'; -import { createTypegooseQueryServiceProviders } from '../src/providers'; +import { Document } from 'mongoose'; +import { createMongooseQueryServiceProviders } from '../src/providers'; import { MongooseQueryService } from '../src/services'; describe('createTypegooseQueryServiceProviders', () => { it('should create a provider for the entity', () => { - class TestEntity {} - const providers = createTypegooseQueryServiceProviders([TestEntity]); + class TestEntity extends Document {} + const providers = createMongooseQueryServiceProviders([ + { document: TestEntity, name: TestEntity.name, schema: null }, + ]); expect(providers).toHaveLength(1); expect(providers[0].provide).toBe(getQueryServiceToken(TestEntity)); expect(providers[0].inject).toEqual([`${TestEntity.name}Model`]); diff --git a/packages/query-mongoose/__tests__/query/aggregate.builder.spec.ts b/packages/query-mongoose/__tests__/query/aggregate.builder.spec.ts new file mode 100644 index 000000000..2f288a633 --- /dev/null +++ b/packages/query-mongoose/__tests__/query/aggregate.builder.spec.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { AggregateQuery } from '@nestjs-query/core'; +import { TestEntity } from '../__fixtures__/test.entity'; +import { AggregateBuilder, MongooseAggregate } from '../../src/query'; + +describe('AggregateBuilder', (): void => { + const createAggregateBuilder = () => new AggregateBuilder(); + + const assertQuery = (agg: AggregateQuery, expected: MongooseAggregate): void => { + const actual = createAggregateBuilder().build(agg); + expect(actual).toEqual(expected); + }; + + it('should throw an error if no selects are generated', (): void => { + expect(() => createAggregateBuilder().build({})).toThrow('No aggregate fields found.'); + }); + + it('or multiple operators for a single field together', (): void => { + assertQuery( + { + count: ['id', 'stringType'], + avg: ['numberType'], + sum: ['numberType'], + max: ['stringType', 'dateType', 'numberType'], + min: ['stringType', 'dateType', 'numberType'], + }, + { + avg_numberType: { $avg: '$numberType' }, + count_id: { $sum: { $cond: { if: { $ne: ['$_id', null] }, then: 1, else: 0 } } }, + count_stringType: { $sum: { $cond: { if: { $ne: ['$stringType', null] }, then: 1, else: 0 } } }, + max_dateType: { $max: '$dateType' }, + max_numberType: { $max: '$numberType' }, + max_stringType: { $max: '$stringType' }, + min_dateType: { $min: '$dateType' }, + min_numberType: { $min: '$numberType' }, + min_stringType: { $min: '$stringType' }, + sum_numberType: { $sum: '$numberType' }, + }, + ); + }); + + describe('.convertToAggregateResponse', () => { + it('should convert a flat response into an Aggregtate response', () => { + const dbResult = { + count_id: 10, + sum_numberType: 55, + avg_numberType: 5, + max_stringType: 'z', + max_numberType: 10, + min_stringType: 'a', + min_numberType: 1, + }; + expect(AggregateBuilder.convertToAggregateResponse(dbResult)).toEqual({ + count: { id: 10 }, + sum: { numberType: 55 }, + avg: { numberType: 5 }, + max: { stringType: 'z', numberType: 10 }, + min: { stringType: 'a', numberType: 1 }, + }); + }); + + it('should throw an error if a column is not expected', () => { + const dbResult = { + COUNTtestEntityPk: 10, + }; + expect(() => AggregateBuilder.convertToAggregateResponse(dbResult)).toThrow( + 'Unknown aggregate column encountered.', + ); + }); + }); +}); diff --git a/packages/query-mongoose/__tests__/services/mongoose-query.service.spec.ts b/packages/query-mongoose/__tests__/services/mongoose-query.service.spec.ts index dbb984741..b2819b15b 100644 --- a/packages/query-mongoose/__tests__/services/mongoose-query.service.spec.ts +++ b/packages/query-mongoose/__tests__/services/mongoose-query.service.spec.ts @@ -64,12 +64,6 @@ describe('MongooseQueryService', () => { }; } - function expectEqualEntities(result: TestEntity[], expected: TestEntity[]) { - const cleansedResults = result.map(testEntityToObject); - const cleansedExpected = expected.map(testEntityToObject); - expect(cleansedResults).toEqual(expect.arrayContaining(cleansedExpected)); - } - function testEntityToCreate(te: TestEntity): Partial { // eslint-disable-next-line @typescript-eslint/naming-convention const { _id, ...insert } = testEntityToObject(te); @@ -92,97 +86,91 @@ describe('MongooseQueryService', () => { it('call find and return the result', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({}); - expectEqualEntities(queryResult, TEST_ENTITIES); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES)); }); it('should support eq operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { eq: 'foo1' } } }); - expectEqualEntities(queryResult, [TEST_ENTITIES[0]]); + expect(queryResult).toEqual([TEST_ENTITIES[0]]); }); it('should support neq operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { neq: 'foo1' } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(1)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(1))); }); it('should support gt operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { gt: 5 } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(5)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(5))); }); it('should support gte operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { gte: 5 } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(4)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(4))); }); it('should support lt operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { lt: 5 } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(0, 4)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(0, 4))); }); it('should support lte operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { lte: 5 } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(0, 5)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(0, 5))); }); it('should support in operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { in: [1, 2, 3] } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(0, 3)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(0, 3))); }); it('should support notIn operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { numberType: { notIn: [1, 2, 3] } } }); - expectEqualEntities(queryResult, TEST_ENTITIES.slice(4)); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.slice(4))); }); it('should support is operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { boolType: { is: true } } }); - expectEqualEntities( - queryResult, - TEST_ENTITIES.filter((e) => e.boolType), - ); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.filter((e) => e.boolType))); }); it('should support isNot operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { boolType: { isNot: true } } }); - expectEqualEntities( - queryResult, - TEST_ENTITIES.filter((e) => !e.boolType), - ); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES.filter((e) => !e.boolType))); }); it('should support like operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { like: 'foo%' } } }); - expectEqualEntities(queryResult, TEST_ENTITIES); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES)); }); it('should support notLike operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { notLike: 'foo%' } } }); - expectEqualEntities(queryResult, []); + expect(queryResult).toEqual([]); }); it('should support iLike operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { iLike: 'FOO%' } } }); - expectEqualEntities(queryResult, TEST_ENTITIES); + expect(queryResult).toEqual(expect.arrayContaining(TEST_ENTITIES)); }); it('should support notILike operator', async () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.query({ filter: { stringType: { notILike: 'FOO%' } } }); - expectEqualEntities(queryResult, []); + expect(queryResult).toEqual([]); }); }); @@ -276,7 +264,7 @@ describe('MongooseQueryService', () => { const entity = TEST_ENTITIES[0]; const queryService = moduleRef.get(TestEntityService); const found = await queryService.findById(entity._id); - expectEqualEntities([found!], [entity]); + expect(found).toEqual(entity); }); it('return undefined if not found', async () => { @@ -292,7 +280,7 @@ describe('MongooseQueryService', () => { const found = await queryService.findById(entity._id, { filter: { stringType: { eq: entity.stringType } }, }); - expectEqualEntities([found!], [entity]); + expect(found).toEqual(entity); }); it('should return an undefined if an entity with the pk and filter is not found', async () => { @@ -311,7 +299,7 @@ describe('MongooseQueryService', () => { const entity = TEST_ENTITIES[0]; const queryService = moduleRef.get(TestEntityService); const found = await queryService.getById(entity._id); - expectEqualEntities([found], [entity]); + expect(found).toEqual(entity); }); it('return undefined if not found', () => { @@ -327,7 +315,7 @@ describe('MongooseQueryService', () => { const found = await queryService.getById(entity._id, { filter: { stringType: { eq: entity.stringType } }, }); - expectEqualEntities([found], [entity]); + expect(found).toEqual(entity); }); it('should return an undefined if an entity with the pk and filter is not found', async () => { @@ -376,7 +364,7 @@ describe('MongooseQueryService', () => { const entity = new TestEntityModel(testEntityToCreate(TEST_ENTITIES[0])); const queryService = moduleRef.get(TestEntityService); const created = await queryService.createOne(entity); - expect(created).toEqual(expect.objectContaining(entity)); + expect(created).toEqual(expect.objectContaining(entity.toObject({ virtuals: true }))); // expectEqualCreate([created], [TEST_ENTITIES[0]]); }); @@ -406,7 +394,7 @@ describe('MongooseQueryService', () => { it('remove the entity', async () => { const queryService = moduleRef.get(TestEntityService); const deleted = await queryService.deleteOne(TEST_ENTITIES[0]._id); - expectEqualEntities([deleted], [TEST_ENTITIES[0]]); + expect(deleted).toEqual(TEST_ENTITIES[0]); }); it('call fail if the entity is not found', async () => { @@ -422,7 +410,7 @@ describe('MongooseQueryService', () => { const deleted = await queryService.deleteOne(entity._id, { filter: { stringType: { eq: entity.stringType } }, }); - expectEqualEntities([deleted], [TEST_ENTITIES[0]]); + expect(deleted).toEqual(TEST_ENTITIES[0]); }); it('should return throw an error if unable to find', async () => { @@ -517,7 +505,7 @@ describe('MongooseQueryService', () => { const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.findRelation(TestReference, 'testReference', entity); - expect(queryResult?.toObject()).toEqual(TEST_REFERENCES[0]); + expect(queryResult).toEqual(TEST_REFERENCES[0]); }); it('apply the filter option', async () => { @@ -526,12 +514,12 @@ describe('MongooseQueryService', () => { const queryResult1 = await queryService.findRelation(TestReference, 'testReference', entity, { filter: { referenceName: { eq: TEST_REFERENCES[0].referenceName } }, }); - expect(queryResult1?.toObject()).toEqual(TEST_REFERENCES[0]); + expect(queryResult1).toEqual(TEST_REFERENCES[0]); const queryResult2 = await queryService.findRelation(TestReference, 'testReference', entity, { filter: { referenceName: { eq: TEST_REFERENCES[1].referenceName } }, }); - expect(queryResult2?.toObject()).toBeUndefined(); + expect(queryResult2).toBeUndefined(); }); it('should return undefined select if no results are found.', async () => { @@ -556,7 +544,7 @@ describe('MongooseQueryService', () => { const queryService = moduleRef.get(TestReferenceService); const queryResult = await queryService.findRelation(TestEntity, 'virtualTestEntity', entity); - expect(queryResult?.toObject()).toEqual(TEST_ENTITIES[0]); + expect(queryResult).toEqual(TEST_ENTITIES[0]); }); it('apply the filter option', async () => { @@ -565,12 +553,12 @@ describe('MongooseQueryService', () => { const queryResult1 = await queryService.findRelation(TestEntity, 'virtualTestEntity', entity, { filter: { stringType: { eq: TEST_ENTITIES[0].stringType } }, }); - expect(queryResult1?.toObject()).toEqual(TEST_ENTITIES[0]); + expect(queryResult1).toEqual(TEST_ENTITIES[0]); const queryResult2 = await queryService.findRelation(TestEntity, 'virtualTestEntity', entity, { filter: { stringType: { eq: TEST_ENTITIES[1].stringType } }, }); - expect(queryResult2?.toObject()).toBeUndefined(); + expect(queryResult2).toBeUndefined(); }); it('should return undefined select if no results are found.', async () => { @@ -578,7 +566,7 @@ describe('MongooseQueryService', () => { await TestReferenceModel.updateOne({ _id: entity._id }, { $set: { testEntity: undefined } }); const queryService = moduleRef.get(TestReferenceService); const queryResult = await queryService.findRelation(TestEntity, 'virtualTestEntity', entity); - expect(queryResult?.toObject()).toBeUndefined(); + expect(queryResult).toBeUndefined(); }); it('throw an error if a relation with that name is not found.', async () => { @@ -639,11 +627,6 @@ describe('MongooseQueryService', () => { }); describe('#queryRelations', () => { - const referenceContaining = (refs: TestReference[]): unknown[] => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return refs.map((r) => expect.objectContaining(r)); - }; - describe('with one entity', () => { it('call select and return the result', async () => { const queryService = moduleRef.get(TestEntityService); @@ -651,7 +634,7 @@ describe('MongooseQueryService', () => { filter: { referenceName: { isNot: null } }, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return expect(queryResult.map((r) => r.toObject())).toEqual(referenceContaining(TEST_REFERENCES.slice(0, 3))); + return expect(queryResult).toEqual(TEST_REFERENCES.slice(0, 3)); }); it('should apply a filter', async () => { @@ -659,7 +642,7 @@ describe('MongooseQueryService', () => { const queryResult = await queryService.queryRelations(TestReference, 'testReferences', TEST_ENTITIES[0], { filter: { referenceName: { eq: TEST_REFERENCES[1].referenceName } }, }); - expect(queryResult.map((r) => r.toObject())).toEqual(referenceContaining([TEST_REFERENCES[1]])); + expect(queryResult).toEqual([TEST_REFERENCES[1]]); }); it('should apply paging', async () => { @@ -668,7 +651,7 @@ describe('MongooseQueryService', () => { paging: { limit: 2, offset: 1 }, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return - expect(queryResult.map((r) => r.toObject())).toEqual(referenceContaining(TEST_REFERENCES.slice(1, 3))); + expect(queryResult).toEqual(TEST_REFERENCES.slice(1, 3)); }); }); @@ -684,9 +667,7 @@ describe('MongooseQueryService', () => { }, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return expect(queryResult.map((r) => r.toObject())).toEqual( - expect.arrayContaining(referenceContaining(TEST_REFERENCES.slice(0, 3))), - ); + return expect(queryResult).toEqual(expect.arrayContaining(TEST_REFERENCES.slice(0, 3))); }); it('should apply a filter', async () => { @@ -699,7 +680,7 @@ describe('MongooseQueryService', () => { filter: { referenceName: { eq: TEST_REFERENCES[1].referenceName } }, }, ); - expect(queryResult.map((r) => r.toObject())).toEqual(referenceContaining([TEST_REFERENCES[1]])); + expect(queryResult).toEqual([TEST_REFERENCES[1]]); }); it('should apply paging', async () => { @@ -713,8 +694,7 @@ describe('MongooseQueryService', () => { sorting: [{ field: 'referenceName', direction: SortDirection.ASC }], }, ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - expect(queryResult.map((r) => r.toObject())).toEqual(referenceContaining(TEST_REFERENCES.slice(1, 3))); + expect(queryResult).toEqual(TEST_REFERENCES.slice(1, 3)); }); }); @@ -726,15 +706,9 @@ describe('MongooseQueryService', () => { filter: { referenceName: { isNot: null } }, }); expect(queryResult.size).toBe(3); - expect(queryResult.get(entities[0])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(0, 3)), - ); - expect(queryResult.get(entities[1])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(3, 6)), - ); - expect(queryResult.get(entities[2])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(6, 9)), - ); + expect(queryResult.get(entities[0])).toEqual(TEST_REFERENCES.slice(0, 3)); + expect(queryResult.get(entities[1])).toEqual(TEST_REFERENCES.slice(3, 6)); + expect(queryResult.get(entities[2])).toEqual(TEST_REFERENCES.slice(6, 9)); }); it('should apply a filter per entity', async () => { @@ -745,9 +719,9 @@ describe('MongooseQueryService', () => { filter: { referenceName: { in: references.map((r) => r.referenceName) } }, }); expect(queryResult.size).toBe(3); - expect(queryResult.get(entities[0])?.map((r) => r.toObject())).toEqual(referenceContaining([references[0]])); - expect(queryResult.get(entities[1])?.map((r) => r.toObject())).toEqual(referenceContaining([references[1]])); - expect(queryResult.get(entities[2])?.map((r) => r.toObject())).toEqual(referenceContaining([references[2]])); + expect(queryResult.get(entities[0])).toEqual([references[0]]); + expect(queryResult.get(entities[1])).toEqual([references[1]]); + expect(queryResult.get(entities[2])).toEqual([references[2]]); }); it('should apply paging per entity', async () => { @@ -757,15 +731,9 @@ describe('MongooseQueryService', () => { paging: { limit: 2, offset: 1 }, }); expect(queryResult.size).toBe(3); - expect(queryResult.get(entities[0])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(1, 3)), - ); - expect(queryResult.get(entities[1])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(4, 6)), - ); - expect(queryResult.get(entities[2])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(7, 9)), - ); + expect(queryResult.get(entities[0])).toEqual(TEST_REFERENCES.slice(1, 3)); + expect(queryResult.get(entities[1])).toEqual(TEST_REFERENCES.slice(4, 6)); + expect(queryResult.get(entities[2])).toEqual(TEST_REFERENCES.slice(7, 9)); }); it('should return an empty array if no results are found.', async () => { @@ -775,9 +743,7 @@ describe('MongooseQueryService', () => { filter: { referenceName: { isNot: null } }, }); expect(queryResult.size).toBe(2); - expect(queryResult.get(entities[0])?.map((r) => r.toObject())).toEqual( - referenceContaining(TEST_REFERENCES.slice(0, 3)), - ); + expect(queryResult.get(entities[0])).toEqual(TEST_REFERENCES.slice(0, 3)); expect(queryResult.get(entities[1])).toEqual([]); }); }); @@ -1011,7 +977,7 @@ describe('MongooseQueryService', () => { const queryResult = await queryService.addRelations( 'testReferences', entity._id, - TEST_REFERENCES.slice(3, 6).map((r) => r._id.toString()), + TEST_REFERENCES.slice(3, 6).map((r) => r._id), ); expect(queryResult).toEqual( expect.objectContaining({ @@ -1032,9 +998,9 @@ describe('MongooseQueryService', () => { queryService.addRelations( 'virtualTestReferences', entity._id, - TEST_REFERENCES.slice(3, 6).map((r) => r._id.toString()), + TEST_REFERENCES.slice(3, 6).map((r) => r._id), ), - ).rejects.toThrow('Mutations on virtual relation virtualTestReferences not supported'); + ).rejects.toThrow('AddRelations not supported for virtual relation virtualTestReferences'); }); }); @@ -1046,7 +1012,7 @@ describe('MongooseQueryService', () => { queryService.addRelations( 'testReferences', entity._id, - TEST_REFERENCES.slice(3, 6).map((r) => r._id.toString()), + TEST_REFERENCES.slice(3, 6).map((r) => r._id), { filter: { stringType: { eq: TEST_ENTITIES[1].stringType } }, }, @@ -1061,7 +1027,7 @@ describe('MongooseQueryService', () => { queryService.addRelations( 'testReferences', entity._id, - TEST_REFERENCES.slice(3, 6).map((r) => r._id.toString()), + TEST_REFERENCES.slice(3, 6).map((r) => r._id), { relationFilter: { referenceName: { like: '%-one' } }, }, @@ -1070,4 +1036,155 @@ describe('MongooseQueryService', () => { }); }); }); + + describe('#setRelation', () => { + it('call select and return the result', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + const queryResult = await queryService.setRelation('testEntity', entity._id, TEST_ENTITIES[1]._id); + expect(queryResult).toEqual(expect.objectContaining({ ...entity, testEntity: TEST_ENTITIES[1]._id })); + + const relation = await queryService.findRelation(TestEntity, 'testEntity', entity); + expect(relation!).toEqual(TEST_ENTITIES[1]); + }); + + it('should reject with a virtual reference', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect(queryService.setRelation('virtualTestEntity', entity._id, TEST_ENTITIES[1]._id)).rejects.toThrow( + 'SetRelation not supported for virtual relation virtualTestEntity', + ); + }); + + describe('with modify options', () => { + it('should throw an error if the entity is not found with the id and provided filter', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect( + queryService.setRelation('testEntity', entity._id, TEST_ENTITIES[1]._id, { + filter: { referenceName: { eq: TEST_REFERENCES[1].referenceName } }, + }), + ).rejects.toThrow(`Unable to find TestReference with id: ${String(entity._id)}`); + }); + + it('should throw an error if the relations are not found with the relationIds and provided filter', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect( + queryService.setRelation('testEntity', entity._id, TEST_ENTITIES[1]._id, { + relationFilter: { stringType: { like: '%-one' } }, + }), + ).rejects.toThrow('Unable to find testEntity to set on TestReference'); + }); + }); + }); + + describe('#removeRelations', () => { + it('call select and return the result', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.removeRelations( + 'testReferences', + entity._id, + TEST_REFERENCES.slice(0, 3).map((r) => r._id), + ); + expect(queryResult.toObject()).toEqual( + expect.objectContaining({ + _id: entity._id, + testReferences: [], + }), + ); + + const relations = await queryService.queryRelations(TestReference, 'testReferences', entity, {}); + expect(relations).toHaveLength(0); + }); + + describe('with virtual reference', () => { + it('should return a rejected promise', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + return expect( + queryService.removeRelations( + 'virtualTestReferences', + entity._id, + TEST_REFERENCES.slice(0, 3).map((r) => r._id), + ), + ).rejects.toThrow('RemoveRelations not supported for virtual relation virtualTestReferences'); + }); + }); + + describe('with modify options', () => { + it('should throw an error if the entity is not found with the id and provided filter', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + return expect( + queryService.removeRelations( + 'testReferences', + entity._id, + TEST_REFERENCES.slice(0, 3).map((r) => r._id), + { + filter: { stringType: { eq: TEST_ENTITIES[1].stringType } }, + }, + ), + ).rejects.toThrow(`Unable to find TestEntity with id: ${String(entity._id)}`); + }); + + it('should throw an error if the relations are not found with the relationIds and provided filter', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + return expect( + queryService.removeRelations( + 'testReferences', + entity._id, + TEST_REFERENCES.slice(0, 3).map((r) => r._id), + { + relationFilter: { referenceName: { like: '%-one' } }, + }, + ), + ).rejects.toThrow('Unable to find all testReferences to remove from TestEntity'); + }); + }); + }); + + describe('#removeRelation', () => { + it('call select and return the result', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + const queryResult = await queryService.removeRelation('testEntity', entity._id, TEST_ENTITIES[1]._id); + expect(queryResult).toEqual(expect.objectContaining({ ...entity, testEntity: undefined })); + + const relation = await queryService.findRelation(TestEntity, 'testEntity', entity); + expect(relation).toBeUndefined(); + }); + + it('should reject with a virtual reference', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect(queryService.removeRelation('virtualTestEntity', entity._id, TEST_ENTITIES[1]._id)).rejects.toThrow( + 'RemoveRelation not supported for virtual relation virtualTestEntity', + ); + }); + + describe('with modify options', () => { + it('should throw an error if the entity is not found with the id and provided filter', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect( + queryService.removeRelation('testEntity', entity._id, TEST_ENTITIES[1]._id, { + filter: { referenceName: { eq: TEST_REFERENCES[1].referenceName } }, + }), + ).rejects.toThrow(`Unable to find TestReference with id: ${String(entity._id)}`); + }); + + it('should throw an error if the relations are not found with the relationIds and provided filter', async () => { + const entity = TEST_REFERENCES[0]; + const queryService = moduleRef.get(TestReferenceService); + return expect( + queryService.removeRelation('testEntity', entity._id, TEST_ENTITIES[1]._id, { + relationFilter: { stringType: { like: '%-one' } }, + }), + ).rejects.toThrow('Unable to find testEntity to remove from TestReference'); + }); + }); + }); }); diff --git a/packages/query-mongoose/package.json b/packages/query-mongoose/package.json index 8b5719aaf..54c698864 100644 --- a/packages/query-mongoose/package.json +++ b/packages/query-mongoose/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs-query/query-mongoose", - "version": "0.19.1", + "version": "0.20.1", "description": "Mongoose adapter for @nestjs-query/core", "author": "doug-martin ", "homepage": "https://github.com/doug-martin/nestjs-query#readme", @@ -18,7 +18,7 @@ "access": "public" }, "dependencies": { - "@nestjs-query/core": "0.20.0", + "@nestjs-query/core": "0.20.1", "lodash.escaperegexp": "^4.1.2", "lodash.merge": "^4.6.2" }, @@ -39,7 +39,7 @@ "@types/mongoose": "5.7.36", "class-transformer": "0.3.1", "mongodb-memory-server": "6.7.6", - "mongoose": "5.10.6", + "mongoose": "5.10.7", "mongodb": "3.6.2", "ts-mockito": "2.6.1", "typescript": "4.0.3" @@ -52,7 +52,7 @@ "scripts": { "prepublishOnly": "npm run build", "prebuild": "npm run clean", - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.json", "clean": "rm -rf ./dist && rm -rf tsconfig.tsbuildinfo" }, "bugs": { diff --git a/packages/query-mongoose/src/module.ts b/packages/query-mongoose/src/module.ts index c382eba03..3a5552a10 100644 --- a/packages/query-mongoose/src/module.ts +++ b/packages/query-mongoose/src/module.ts @@ -1,9 +1,10 @@ import { DynamicModule } from '@nestjs/common'; -import { ModelDefinition, MongooseModule } from '@nestjs/mongoose'; -import { createMongooseQueryServiceProviders } from './providers'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { createMongooseQueryServiceProviders, NestjsQueryModelDefinition } from './providers'; export class NestjsQueryMongooseModule { - static forFeature(models: ModelDefinition[], connectionName?: string): DynamicModule { + static forFeature(models: NestjsQueryModelDefinition[], connectionName?: string): DynamicModule { const queryServiceProviders = createMongooseQueryServiceProviders(models); const mongooseModule = MongooseModule.forFeature(models, connectionName); return { diff --git a/packages/query-mongoose/src/mongoose-types.helper.ts b/packages/query-mongoose/src/mongoose-types.helper.ts index 0cece2603..eaf353575 100644 --- a/packages/query-mongoose/src/mongoose-types.helper.ts +++ b/packages/query-mongoose/src/mongoose-types.helper.ts @@ -6,7 +6,7 @@ export type ReferenceOptions = { }; export function isReferenceOptions(options: unknown): options is ReferenceOptions { - return options && typeof options === 'object' && 'type' in options && 'ref' in options; + return options && typeof options === 'object' && 'type' in options && 'ref' in options && typeof (options as {ref: unknown}).ref === 'string'; } export type SchemaTypeWithReferenceOptions = { diff --git a/packages/query-mongoose/src/providers.ts b/packages/query-mongoose/src/providers.ts index 825597607..445f4f38c 100644 --- a/packages/query-mongoose/src/providers.ts +++ b/packages/query-mongoose/src/providers.ts @@ -1,18 +1,29 @@ -import { getQueryServiceToken } from '@nestjs-query/core'; +import { AssemblerDeserializer, Class, getQueryServiceToken } from '@nestjs-query/core'; import { FactoryProvider } from '@nestjs/common'; import { ModelDefinition } from '@nestjs/mongoose'; import { Model, Document } from 'mongoose'; -import { MongooseQueryService } from './services/mongoose-query.service'; +import { MongooseQueryService } from './services'; -function createMongooseQueryServiceProvider(model: ModelDefinition): FactoryProvider { +export type NestjsQueryModelDefinition = { + document: Class; +} & ModelDefinition; + +function createMongooseQueryServiceProvider( + model: NestjsQueryModelDefinition, +): FactoryProvider { return { - provide: getQueryServiceToken({ name: model.name }), - useFactory(modelClass: Model) { - return new MongooseQueryService(modelClass); + provide: getQueryServiceToken(model.document), + useFactory(ModelClass: Model) { + AssemblerDeserializer((obj: unknown) => new ModelClass(obj))(model.document); + // eslint-disable-next-line @typescript-eslint/ban-types + return new MongooseQueryService(ModelClass); }, inject: [`${model.name}Model`], }; } -export const createMongooseQueryServiceProviders = (models: ModelDefinition[]): FactoryProvider[] => - models.map((model) => createMongooseQueryServiceProvider(model)); +export const createMongooseQueryServiceProviders = ( + models: NestjsQueryModelDefinition[], +): FactoryProvider[] => { + return models.map((model) => createMongooseQueryServiceProvider(model)); +}; diff --git a/packages/query-mongoose/src/query/aggregate.builder.ts b/packages/query-mongoose/src/query/aggregate.builder.ts index 2944a3393..eb449f04c 100644 --- a/packages/query-mongoose/src/query/aggregate.builder.ts +++ b/packages/query-mongoose/src/query/aggregate.builder.ts @@ -1,4 +1,5 @@ import { AggregateQuery, AggregateResponse } from '@nestjs-query/core'; +import { BadRequestException } from '@nestjs/common'; import { Document } from 'mongoose'; import { getSchemaKey } from './helpers'; @@ -42,20 +43,22 @@ export class AggregateBuilder { }, {} as AggregateResponse); } - constructor() {} - /** * Builds a aggregate SELECT clause from a aggregate. * @param aggregate - the aggregates to select. */ build(aggregate: AggregateQuery): MongooseAggregate { - return { + const query = { ...this.createAggSelect(AggregateFuncs.COUNT, aggregate.count), ...this.createAggSelect(AggregateFuncs.SUM, aggregate.sum), ...this.createAggSelect(AggregateFuncs.AVG, aggregate.avg), ...this.createAggSelect(AggregateFuncs.MAX, aggregate.max), ...this.createAggSelect(AggregateFuncs.MIN, aggregate.min), }; + if (!Object.keys(query).length) { + throw new BadRequestException('No aggregate fields found.'); + } + return query; } private createAggSelect(func: AggregateFuncs, fields?: (keyof Entity)[]): MongooseAggregate { @@ -71,9 +74,9 @@ export class AggregateBuilder { [aggAlias]: { $sum: { $cond: { - if: { $ne: [fieldAlias, null] }, - then: 1, - else: 0, + if: { $in: [{$type: fieldAlias}, ['missing', 'null']] }, + then: 0, + else: 1, }, }, }, diff --git a/packages/query-mongoose/src/query/filter-query.builder.ts b/packages/query-mongoose/src/query/filter-query.builder.ts index 17b2ab793..ccdd8b309 100644 --- a/packages/query-mongoose/src/query/filter-query.builder.ts +++ b/packages/query-mongoose/src/query/filter-query.builder.ts @@ -1,4 +1,4 @@ -import { AggregateQuery, Filter, Paging, Query, SortDirection, SortField } from '@nestjs-query/core'; +import { AggregateQuery, Filter, Query, SortDirection, SortField } from '@nestjs-query/core'; import { FilterQuery, Document } from 'mongoose'; import { AggregateBuilder, MongooseAggregate } from './aggregate.builder'; import { getSchemaKey } from './helpers'; @@ -8,8 +8,7 @@ type MongooseSort = Record; type MongooseQuery = { filterQuery: FilterQuery; - paging?: Paging; - sorting?: MongooseSort[]; + options: { limit?: number; skip?: number; sort?: MongooseSort[] }; }; type MongooseAggregateQuery = { @@ -27,19 +26,17 @@ export class FilterQueryBuilder { readonly aggregateBuilder: AggregateBuilder = new AggregateBuilder(), ) {} - buildQuery(query: Query): MongooseQuery { + buildQuery({ filter, paging, sorting }: Query): MongooseQuery { return { - filterQuery: this.buildFilterQuery(query.filter), - paging: query.paging, - sorting: this.buildSorting(query.sorting), + filterQuery: this.buildFilterQuery(filter), + options: { limit: paging?.limit, skip: paging?.offset, sort: this.buildSorting(sorting) }, }; } - buildIdQuery(id: unknown | unknown[], query: Query): MongooseQuery { + buildIdQuery(id: unknown | unknown[], { filter, paging, sorting }: Query): MongooseQuery { return { - filterQuery: this.buildIdFilterQuery(id, query.filter), - paging: query.paging, - sorting: this.buildSorting(query.sorting), + filterQuery: this.buildIdFilterQuery(id, filter), + options: { limit: paging?.limit, skip: paging?.offset, sort: this.buildSorting(sorting) }, }; } diff --git a/packages/query-mongoose/src/services/mongoose-query.service.ts b/packages/query-mongoose/src/services/mongoose-query.service.ts index ab4b905f8..2f3891854 100644 --- a/packages/query-mongoose/src/services/mongoose-query.service.ts +++ b/packages/query-mongoose/src/services/mongoose-query.service.ts @@ -69,8 +69,9 @@ export class MongooseQueryService * @param query - The Query used to filter, page, and sort rows. */ async query(query: Query): Promise { - const { filterQuery, sorting, paging } = this.filterQueryBuilder.buildQuery(query); - return this.Model.find(filterQuery, {}, { limit: paging?.limit, skip: paging?.offset, sort: sorting }).exec(); + const { filterQuery, options } = this.filterQueryBuilder.buildQuery(query); + const models = await this.Model.find(filterQuery, {}, options).exec(); + return this.convertToEntityInstances(models); } async aggregate(filter: Filter, aggregateQuery: AggregateQuery): Promise> { @@ -103,7 +104,7 @@ export class MongooseQueryService if (!doc) { return undefined; } - return doc; + return this.convertToEntityInstance(doc); } /** @@ -121,11 +122,8 @@ export class MongooseQueryService * @param opts - Additional options */ async getById(id: string, opts?: GetByIdOptions): Promise { - const entity = await this.findById(id, opts); - if (!entity) { - throw new NotFoundException(`Unable to find ${this.Model.modelName} with id: ${id}`); - } - return entity; + const entity = await this.getModelById(id, opts); + return this.convertToEntityInstance(entity); } /** @@ -139,7 +137,8 @@ export class MongooseQueryService */ async createOne(record: DeepPartial): Promise { this.ensureIdIsNotPresent(record); - return this.Model.create(record as CreateQuery); + const created = await this.Model.create(record as CreateQuery); + return this.convertToEntityInstance(created); } /** @@ -179,7 +178,7 @@ export class MongooseQueryService if (!doc) { throw new NotFoundException(`Unable to find ${this.Model.modelName} with id: ${id}`); } - return doc; + return this.convertToEntityInstance(doc); } /** @@ -223,7 +222,7 @@ export class MongooseQueryService if (!doc) { throw new NotFoundException(`Unable to find ${this.Model.modelName} with id: ${id}`); } - return doc; + return this.convertToEntityInstance(doc); } /** @@ -250,4 +249,21 @@ export class MongooseQueryService throw new Error('Id cannot be specified when updating or creating'); } } + + private convertToEntityInstance(model: Entity): Entity { + return model.toObject(this.documentToObjectOptions) as Entity; + } + + private convertToEntityInstances(model: Entity[]): Entity[] { + return model.map((m) => this.convertToEntityInstance(m)); + } + + async getModelById(id: string | number, opts?: GetByIdOptions): Promise { + const filterQuery = this.filterQueryBuilder.buildIdFilterQuery(id, opts?.filter); + const doc = await this.Model.findOne(filterQuery); + if (!doc) { + throw new NotFoundException(`Unable to find ${this.Model.modelName} with id: ${id}`); + } + return doc; + } } diff --git a/packages/query-mongoose/src/services/reference-query.service.ts b/packages/query-mongoose/src/services/reference-query.service.ts index 3f6237d23..854fd9133 100644 --- a/packages/query-mongoose/src/services/reference-query.service.ts +++ b/packages/query-mongoose/src/services/reference-query.service.ts @@ -22,37 +22,7 @@ import { export abstract class ReferenceQueryService { abstract readonly Model: MongooseModel; - abstract getById(id: string | number, opts?: GetByIdOptions): Promise; - - abstract findById(id: string | number, opts?: GetByIdOptions): Promise; - - async addRelations( - relationName: string, - id: string, - relationIds: (string | number)[], - opts?: ModifyRelationOptions, - ): Promise { - this.checkForReference(relationName); - const referenceModel = this.getReferenceModel(relationName); - const entity = await this.getById(id, opts); - const referenceQueryBuilder = this.getReferenceQueryBuilder(); - const refCount = await referenceModel.count( - referenceQueryBuilder.buildIdFilterQuery(relationIds, opts?.relationFilter), - ); - if (relationIds.length !== refCount) { - throw new Error(`Unable to find all ${relationName} to add to ${this.Model.modelName}`); - } - if (this.isVirtualPath(relationName)) { - throw new Error(`AddRelations not supported for virtual relation ${relationName}`); - } - await entity - .updateOne({ - $push: { [relationName]: { $each: relationIds } }, - }) - .exec(); - // reload the document - return this.getById(id); - } + abstract getModelById(id: string | number, opts?: GetByIdOptions): Promise; aggregateRelations( RelationClass: Class, @@ -77,7 +47,7 @@ export abstract class ReferenceQueryService { filter: Filter, aggregateQuery: AggregateQuery, ): Promise | Map>> { - this.checkForReference(relationName); + this.checkForReference('AggregateRelations', relationName); const relationModel = this.getReferenceModel(relationName); const referenceQueryBuilder = this.getReferenceQueryBuilder(); if (Array.isArray(dto)) { @@ -118,7 +88,7 @@ export abstract class ReferenceQueryService { dto: Entity | Entity[], filter: Filter, ): Promise> { - this.checkForReference(relationName); + this.checkForReference('CountRelations', relationName); if (Array.isArray(dto)) { return dto.reduce(async (mapPromise, entity) => { const map = await mapPromise; @@ -153,7 +123,7 @@ export abstract class ReferenceQueryService { dto: Entity | Entity[], opts?: FindRelationOptions, ): Promise<(Relation | undefined) | Map> { - this.checkForReference(relationName); + this.checkForReference('FindRelation', relationName); const referenceQueryBuilder = this.getReferenceQueryBuilder(); if (Array.isArray(dto)) { return dto.reduce(async (prev, curr) => { @@ -162,19 +132,14 @@ export abstract class ReferenceQueryService { return map.set(curr, ref); }, Promise.resolve(new Map())); } - const foundEntity = await this.findById(dto._id ?? dto.id); + const foundEntity = await this.Model.findById(dto._id ?? dto.id); if (!foundEntity) { return undefined; } const filterQuery = referenceQueryBuilder.buildFilterQuery(opts?.filter); - const populated = await foundEntity - .populate({ - path: relationName, - match: filterQuery, - }) - .execPopulate(); + const populated = await foundEntity.populate({ path: relationName, match: filterQuery }).execPopulate(); const populatedRef: unknown = populated.get(relationName); - return populatedRef ? (populatedRef as Relation) : undefined; + return populatedRef ? this.convertRefToObject(populatedRef as Relation) : undefined; } queryRelations( @@ -195,7 +160,7 @@ export abstract class ReferenceQueryService { dto: Entity | Entity[], query: Query, ): Promise> { - this.checkForReference(relationName); + this.checkForReference('QueryRelations', relationName); const referenceQueryBuilder = this.getReferenceQueryBuilder(); if (Array.isArray(dto)) { return dto.reduce(async (mapPromise, entity) => { @@ -204,27 +169,30 @@ export abstract class ReferenceQueryService { return map.set(entity, refs); }, Promise.resolve(new Map())); } - const foundEntity = await this.findById(dto._id ?? dto.id); + const foundEntity = await this.Model.findById(dto._id ?? dto.id); if (!foundEntity) { return []; } - const { filterQuery, paging, sorting } = referenceQueryBuilder.buildQuery(query); - const populated = await foundEntity - .populate({ - path: relationName, - match: filterQuery, - options: { limit: paging?.limit, skip: paging?.offset, sort: sorting }, - }) - .execPopulate(); - return populated.get(relationName) as Relation[]; - } - - removeRelation(): Promise { - throw new Error('Not implemented yet'); + const { filterQuery, options } = referenceQueryBuilder.buildQuery(query); + const populated = await foundEntity.populate({ path: relationName, match: filterQuery, options }).execPopulate(); + return this.convertRefsToObject(populated.get(relationName) as Relation[]); } - removeRelations(): Promise { - throw new Error('Not implemented yet'); + async addRelations( + relationName: string, + id: string, + relationIds: (string | number)[], + opts?: ModifyRelationOptions, + ): Promise { + this.checkForReference('AddRelations', relationName, false); + const entity = await this.getModelById(id, opts); + const refCount = await this.getRefCount(relationName, relationIds, opts?.relationFilter); + if (relationIds.length !== refCount) { + throw new Error(`Unable to find all ${relationName} to add to ${this.Model.modelName}`); + } + await entity.updateOne({ $push: { [relationName]: { $each: relationIds } } }).exec(); + // reload the document + return this.getModelById(id); } async setRelation( @@ -233,33 +201,73 @@ export abstract class ReferenceQueryService { relationId: string | number, opts?: ModifyRelationOptions, ): Promise { - this.checkForReference(relationName); - const referenceModel = this.getReferenceModel(relationName); - const entity = await this.getById(id, opts); - const referenceQueryBuilder = this.getReferenceQueryBuilder(); - const refCount = await referenceModel.count( - referenceQueryBuilder.buildIdFilterQuery([relationId], opts?.relationFilter), - ); + this.checkForReference('SetRelation', relationName, false); + const entity = await this.getModelById(id, opts); + const refCount = await this.getRefCount(relationName, [relationId], opts?.relationFilter); if (refCount !== 1) { throw new Error(`Unable to find ${relationName} to set on ${this.Model.modelName}`); } + await entity.updateOne({ [relationName]: relationId }).exec(); + // reload the document + return this.getModelById(id); + } + + async removeRelation( + relationName: string, + id: string | number, + relationId: string | number, + opts?: ModifyRelationOptions, + ): Promise { + this.checkForReference('RemoveRelation', relationName, false); + const entity = await this.getModelById(id, opts); + const refCount = await this.getRefCount(relationName, [relationId], opts?.relationFilter); + if (refCount !== 1) { + throw new Error(`Unable to find ${relationName} to remove from ${this.Model.modelName}`); + } + await entity + .updateOne({ + $unset: { [relationName]: relationId }, + }) + .exec(); + // reload the document + return this.getModelById(id); + } + + async removeRelations( + relationName: string, + id: string | number, + relationIds: string[] | number[], + opts?: ModifyRelationOptions, + ): Promise { + this.checkForReference('RemoveRelations', relationName, false); + const entity = await this.getModelById(id, opts); + const refCount = await this.getRefCount(relationName, relationIds, opts?.relationFilter); + if (relationIds.length !== refCount) { + throw new Error(`Unable to find all ${relationName} to remove from ${this.Model.modelName}`); + } if (this.isVirtualPath(relationName)) { - throw new Error(`SetRelation not supported for virtual relation ${relationName}`); + throw new Error(`RemoveRelations not supported for virtual relation ${relationName}`); } await entity .updateOne({ - [relationName]: relationId, + $pullAll: { [relationName]: relationIds }, }) .exec(); // reload the document - return this.getById(id); + return this.getModelById(id); } - private checkForReference(refName: string): void { - const found = this.isReferencePath(refName) || this.isVirtualPath(refName); - if (!found) { - throw new Error(`Unable to find reference ${refName} on ${this.Model.modelName}`); + private checkForReference(operation: string, refName: string, allowVirtual = true): void { + if (this.isReferencePath(refName)) { + return; } + if (this.isVirtualPath(refName)) { + if (allowVirtual) { + return; + } + throw new Error(`${operation} not supported for virtual relation ${refName}`); + } + throw new Error(`Unable to find reference ${refName} on ${this.Model.modelName}`); } private isReferencePath(refName: string): boolean { @@ -335,4 +343,22 @@ export abstract class ReferenceQueryService { } as unknown) as Filter; return mergeFilter(filter ?? ({} as Filter), lookupFilter); } + + private getRefCount( + relationName: string, + relationIds: (string | number)[], + filter?: Filter, + ): Promise { + const referenceModel = this.getReferenceModel(relationName); + const referenceQueryBuilder = this.getReferenceQueryBuilder(); + return referenceModel.count(referenceQueryBuilder.buildIdFilterQuery(relationIds, filter)).exec(); + } + + private convertRefToObject(ref: Reference): Reference { + return ref.toObject({ virtuals: true }) as Reference; + } + + private convertRefsToObject(refs: Reference[]): Reference[] { + return refs.map((r) => this.convertRefToObject(r)); + } } diff --git a/packages/query-mongoose/tsconfig.build.json b/packages/query-mongoose/tsconfig.build.json deleted file mode 100644 index 0a6e60a60..000000000 --- a/packages/query-mongoose/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.build.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "." - }, - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/packages/query-mongoose/tsconfig.json b/packages/query-mongoose/tsconfig.json index 584a91ca1..4a2bfa403 100644 --- a/packages/query-mongoose/tsconfig.json +++ b/packages/query-mongoose/tsconfig.json @@ -1,10 +1,11 @@ { - "extends": "../../tsconfig.json", - "include": [ - "src", - "__tests__" - ], + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, "exclude": [ + "node_modules", "dist" ] }