diff --git a/packages/core/__tests__/services/assembler-query.service.spec.ts b/packages/core/__tests__/services/assembler-query.service.spec.ts index 4031e9ccc..a4ba32d18 100644 --- a/packages/core/__tests__/services/assembler-query.service.spec.ts +++ b/packages/core/__tests__/services/assembler-query.service.spec.ts @@ -445,7 +445,7 @@ describe('AssemblerQueryService', () => { it('should transform the results for a single entity', () => { const mockQueryService = mock>() const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)) - when(mockQueryService.createOne(objectContaining({ bar: 'baz' }))).thenResolve({ + when(mockQueryService.createOne(objectContaining({ bar: 'baz' }), undefined)).thenResolve({ bar: 'baz' }) @@ -457,7 +457,7 @@ describe('AssemblerQueryService', () => { it('should transform the results for a single entity', () => { const mockQueryService = mock>() const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)) - when(mockQueryService.createMany(deepEqual([{ bar: 'baz' }]))).thenResolve([{ bar: 'baz' }]) + when(mockQueryService.createMany(deepEqual([{ bar: 'baz' }]), undefined)).thenResolve([{ bar: 'baz' }]) return expect(assemblerService.createMany([{ foo: 'baz' }])).resolves.toEqual([{ foo: 'baz' }]) }) diff --git a/packages/core/__tests__/services/proxy-query.service.spec.ts b/packages/core/__tests__/services/proxy-query.service.spec.ts index ff3c3f05a..69edbf14c 100644 --- a/packages/core/__tests__/services/proxy-query.service.spec.ts +++ b/packages/core/__tests__/services/proxy-query.service.spec.ts @@ -23,12 +23,12 @@ describe('ProxyQueryService', () => { }) it('should proxy to the underlying service when calling createMany', () => { const entities = [{ foo: 'bar' }] - when(mockQueryService.createMany(entities)).thenResolve(entities) + when(mockQueryService.createMany(entities, undefined)).thenResolve(entities) return expect(queryService.createMany(entities)).resolves.toBe(entities) }) it('should proxy to the underlying service when calling createOne', () => { const entity = { foo: 'bar' } - when(mockQueryService.createOne(entity)).thenResolve(entity) + when(mockQueryService.createOne(entity, undefined)).thenResolve(entity) return expect(queryService.createOne(entity)).resolves.toBe(entity) }) it('should proxy to the underlying service when calling deleteMany', () => { diff --git a/packages/core/src/interfaces/create-many-options.interface.ts b/packages/core/src/interfaces/create-many-options.interface.ts new file mode 100644 index 000000000..74224c015 --- /dev/null +++ b/packages/core/src/interfaces/create-many-options.interface.ts @@ -0,0 +1,3 @@ +import { CreateOneOptions } from './create-one-options.interface' + +export type CreateManyOptions = CreateOneOptions diff --git a/packages/core/src/interfaces/create-one-options.interface.ts b/packages/core/src/interfaces/create-one-options.interface.ts new file mode 100644 index 000000000..1156eb3b2 --- /dev/null +++ b/packages/core/src/interfaces/create-one-options.interface.ts @@ -0,0 +1,9 @@ +import { Filter } from './filter.interface' + +export interface CreateOneOptions { + /** + * Additional filter applied to the input dto before creation. This could be used to apply an additional filter to ensure + * that the entity being created belongs to a particular user. + */ + filter?: Filter +} diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts index d47ab793b..7ccb00c40 100644 --- a/packages/core/src/interfaces/index.ts +++ b/packages/core/src/interfaces/index.ts @@ -2,6 +2,8 @@ export * from './aggregate-options.interface' export * from './aggregate-query.interface' export * from './aggregate-response.interface' export * from './count-options.interface' +export * from './create-many-options.interface' +export * from './create-one-options.interface' export * from './delete-many-options.interface' export * from './delete-many-response.interface' export * from './delete-one-options.interface' diff --git a/packages/core/src/services/assembler-query.service.ts b/packages/core/src/services/assembler-query.service.ts index 740200b56..e8e08c5a0 100644 --- a/packages/core/src/services/assembler-query.service.ts +++ b/packages/core/src/services/assembler-query.service.ts @@ -5,6 +5,8 @@ import { AggregateQuery, AggregateResponse, CountOptions, + CreateManyOptions, + CreateOneOptions, DeleteManyResponse, DeleteOneOptions, Filter, @@ -39,15 +41,15 @@ export class AssemblerQueryService, CE = DeepP ) } - public async createMany(items: C[]): Promise { + public async createMany(items: C[], opts?: CreateManyOptions): Promise { const { assembler } = this const converted = await assembler.convertToCreateEntities(items) - return this.assembler.convertToDTOs(await this.queryService.createMany(converted)) + return this.assembler.convertToDTOs(await this.queryService.createMany(converted, this.convertFilterable(opts))) } - public async createOne(item: C): Promise { + public async createOne(item: C, opts?: CreateOneOptions): Promise { const c = await this.assembler.convertToCreateEntity(item) - return this.assembler.convertToDTO(await this.queryService.createOne(c)) + return this.assembler.convertToDTO(await this.queryService.createOne(c, this.convertFilterable(opts))) } public async deleteMany(filter: Filter): Promise { diff --git a/packages/core/src/services/noop-query.service.ts b/packages/core/src/services/noop-query.service.ts index a5faa7bf2..ec0f679cb 100644 --- a/packages/core/src/services/noop-query.service.ts +++ b/packages/core/src/services/noop-query.service.ts @@ -7,6 +7,8 @@ import { AggregateQuery, AggregateResponse, CountOptions, + CreateManyOptions, + CreateOneOptions, DeleteManyOptions, DeleteManyResponse, DeleteOneOptions, @@ -43,11 +45,11 @@ export class NoOpQueryService, U = DeepPartial> i return Promise.reject(new NotImplementedException('addRelations is not implemented')) } - public createMany(items: C[]): Promise { + public createMany(items: C[], opts?: CreateManyOptions): Promise { return Promise.reject(new NotImplementedException('createMany is not implemented')) } - public createOne(item: C): Promise { + public createOne(item: C, opts?: CreateOneOptions): Promise { return Promise.reject(new NotImplementedException('createOne is not implemented')) } diff --git a/packages/core/src/services/proxy-query.service.ts b/packages/core/src/services/proxy-query.service.ts index 88f84cf67..258a5ec88 100644 --- a/packages/core/src/services/proxy-query.service.ts +++ b/packages/core/src/services/proxy-query.service.ts @@ -4,6 +4,8 @@ import { AggregateQuery, AggregateResponse, CountOptions, + CreateManyOptions, + CreateOneOptions, DeleteManyResponse, DeleteOneOptions, Filter, @@ -172,12 +174,12 @@ export class ProxyQueryService, U = DeepPartial> return this.proxied.findRelation(RelationClass, relationName, dto, opts) } - public createMany(items: C[]): Promise { - return this.proxied.createMany(items) + public createMany(items: C[], opts?: CreateManyOptions): Promise { + return this.proxied.createMany(items, opts) } - public createOne(item: C): Promise { - return this.proxied.createOne(item) + public createOne(item: C, opts?: CreateOneOptions): Promise { + return this.proxied.createOne(item, opts) } public async deleteMany(filter: Filter): Promise { diff --git a/packages/core/src/services/query.service.ts b/packages/core/src/services/query.service.ts index 5ff3952a5..677036f89 100644 --- a/packages/core/src/services/query.service.ts +++ b/packages/core/src/services/query.service.ts @@ -7,6 +7,8 @@ import { AggregateQuery, AggregateResponse, CountOptions, + CreateManyOptions, + CreateOneOptions, DeleteManyOptions, DeleteManyResponse, DeleteOneOptions, @@ -243,17 +245,19 @@ export interface QueryService, U = DeepPartial> { * Create a single record. * * @param item - the record to create. + * @param opts - Additional opts to apply when creating one entity. * @returns the created record. */ - createOne(item: C): Promise + createOne(item: C, opts?: CreateOneOptions): Promise /** * Creates a multiple record. * * @param items - the records to create. + * @param opts - Additional opts to apply when creating many entities. * @returns a created records. */ - createMany(items: C[]): Promise + createMany(items: C[], opts?: CreateManyOptions): Promise /** * Update one record. diff --git a/packages/query-graphql/__tests__/resolvers/create.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/create.resolver.spec.ts index 355a8b62a..181ec45fe 100644 --- a/packages/query-graphql/__tests__/resolvers/create.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/create.resolver.spec.ts @@ -78,7 +78,7 @@ describe('CreateResolver', () => { id: 'id-1', stringField: 'foo' } - when(mockService.createOne(objectContaining(args.input))).thenResolve(output) + when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createOne({ input: args }) return expect(result).toEqual(output) }) @@ -109,7 +109,7 @@ describe('CreateResolver', () => { stringField: 'foo' } ] - when(mockService.createMany(objectContaining(args.input))).thenResolve(output) + when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createMany({ input: args }) return expect(result).toEqual(output) }) @@ -134,7 +134,7 @@ describe('CreateResolver', () => { } const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO) const event = { [eventName]: output } - when(mockService.createOne(objectContaining(args.input))).thenResolve(output) + when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output) when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve() const result = await resolver.createOne({ input: args }) verify(mockPubSub.publish(eventName, deepEqual(event))).once() @@ -154,7 +154,7 @@ describe('CreateResolver', () => { } const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO) const event = { [eventName]: output } - when(mockService.createOne(objectContaining(args.input))).thenResolve(output) + when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output) when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve() const result = await resolver.createOne({ input: args }) verify(mockPubSub.publish(eventName, deepEqual(event))).once() @@ -172,7 +172,7 @@ describe('CreateResolver', () => { id: 'id-1', stringField: 'foo' } - when(mockService.createOne(objectContaining(args.input))).thenResolve(output) + when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createOne({ input: args }) verify(mockPubSub.publish(anything(), anything())).never() return expect(result).toEqual(output) @@ -192,7 +192,7 @@ describe('CreateResolver', () => { id: 'id-1', stringField: 'foo' } - when(mockService.createOne(objectContaining(args.input))).thenResolve(output) + when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createOne({ input: args }) verify(mockPubSub.publish(anything(), anything())).never() return expect(result).toEqual(output) @@ -217,7 +217,7 @@ describe('CreateResolver', () => { ] const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO) const events = output.map((o) => ({ [eventName]: o })) - when(mockService.createMany(objectContaining(args.input))).thenResolve(output) + when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output) events.forEach((e) => when(mockPubSub.publish(eventName, deepEqual(e))).thenResolve()) const result = await resolver.createMany({ input: args }) events.forEach((e) => verify(mockPubSub.publish(eventName, deepEqual(e))).once()) @@ -241,7 +241,7 @@ describe('CreateResolver', () => { ] const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO) const events = output.map((o) => ({ [eventName]: o })) - when(mockService.createMany(objectContaining(args.input))).thenResolve(output) + when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output) events.forEach((e) => when(mockPubSub.publish(eventName, deepEqual(e))).thenResolve()) const result = await resolver.createMany({ input: args }) events.forEach((e) => verify(mockPubSub.publish(eventName, deepEqual(e))).once()) @@ -263,7 +263,7 @@ describe('CreateResolver', () => { stringField: 'foo' } ] - when(mockService.createMany(objectContaining(args.input))).thenResolve(output) + when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createMany({ input: args }) verify(mockPubSub.publish(anything(), anything())).never() return expect(result).toEqual(output) @@ -287,7 +287,7 @@ describe('CreateResolver', () => { stringField: 'foo' } ] - when(mockService.createMany(objectContaining(args.input))).thenResolve(output) + when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output) const result = await resolver.createMany({ input: args }) verify(mockPubSub.publish(anything(), anything())).never() return expect(result).toEqual(output) diff --git a/packages/query-graphql/src/resolvers/create.resolver.ts b/packages/query-graphql/src/resolvers/create.resolver.ts index 8d3b2f9b6..ae380f9fb 100644 --- a/packages/query-graphql/src/resolvers/create.resolver.ts +++ b/packages/query-graphql/src/resolvers/create.resolver.ts @@ -25,7 +25,14 @@ import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolv export type CreatedEvent = { [eventName: string]: DTO } -export interface CreateResolverOpts> extends SubscriptionResolverOpts { +interface AuthValidationOpts { + /** + * Determines whether the auth filter should be passed into the query service + */ + validateWithAuthFilter?: boolean +} + +export interface CreateResolverOpts> extends SubscriptionResolverOpts, AuthValidationOpts { /** * The Input DTO that should be used to create records. */ @@ -41,6 +48,9 @@ export interface CreateResolverOpts> extends Subscript createOneMutationName?: string createManyMutationName?: string + + one?: SubscriptionResolverOpts['one'] & AuthValidationOpts + many?: SubscriptionResolverOpts['many'] & AuthValidationOpts } export interface CreateResolver> extends ServiceResolver { @@ -134,11 +144,12 @@ export const Creatable = @AuthorizerFilter({ operationGroup: OperationGroup.CREATE, many: false - }) // eslint-disable-next-line @typescript-eslint/no-unused-vars + }) authorizeFilter?: Filter ): Promise { - // Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException - const created = await this.service.createOne(input.input.input) + const createOneOpts = + opts?.validateWithAuthFilter || opts?.one?.validateWithAuthFilter ? { filter: authorizeFilter ?? {} } : undefined + const created = await this.service.createOne(input.input.input, createOneOpts) if (enableOneSubscriptions) { await this.publishCreatedEvent(created, authorizeFilter) } @@ -159,11 +170,12 @@ export const Creatable = @AuthorizerFilter({ operationGroup: OperationGroup.CREATE, many: true - }) // eslint-disable-next-line @typescript-eslint/no-unused-vars + }) authorizeFilter?: Filter ): Promise { - // Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException - const created = await this.service.createMany(input.input.input) + const createManyOpts = + opts.validateWithAuthFilter || opts.many?.validateWithAuthFilter ? { filter: authorizeFilter ?? {} } : undefined + const created = await this.service.createMany(input.input.input, createManyOpts) if (enableManySubscriptions) { await Promise.all(created.map((c) => this.publishCreatedEvent(c, authorizeFilter))) } diff --git a/packages/query-mongoose/src/services/mongoose-query.service.ts b/packages/query-mongoose/src/services/mongoose-query.service.ts index d38d44e82..628506453 100644 --- a/packages/query-mongoose/src/services/mongoose-query.service.ts +++ b/packages/query-mongoose/src/services/mongoose-query.service.ts @@ -3,6 +3,8 @@ import { NotFoundException } from '@nestjs/common' import { AggregateQuery, AggregateResponse, + applyFilter, + CreateOneOptions, DeepPartial, DeleteManyResponse, DeleteOneOptions, @@ -132,9 +134,13 @@ export class MongooseQueryService * const todoItem = await this.service.createOne({title: 'Todo Item', completed: false }); * ``` * @param record - The entity to create. + * @param opts - Additional options. */ - async createOne(record: DeepPartial): Promise { + async createOne(record: DeepPartial, opts?: CreateOneOptions): Promise { this.ensureIdIsNotPresent(record) + if (opts?.filter && !applyFilter(record as Entity, opts.filter)) { + throw new Error('Entity does not meet creation constraints') + } return this.Model.create(record) } @@ -149,10 +155,11 @@ export class MongooseQueryService * ]); * ``` * @param records - The entities to create. + * @param opts - Additional options. */ - public async createMany(records: DeepPartial[]): Promise { + public async createMany(records: DeepPartial[], opts?: CreateOneOptions): Promise { records.forEach((r) => this.ensureIdIsNotPresent(r)) - return this.Model.create(records) + return this.Model.create(opts?.filter ? applyFilter(records as Entity[], opts.filter) : records) } /** diff --git a/packages/query-sequelize/src/services/sequelize-query.service.ts b/packages/query-sequelize/src/services/sequelize-query.service.ts index b25069633..d28dc671f 100644 --- a/packages/query-sequelize/src/services/sequelize-query.service.ts +++ b/packages/query-sequelize/src/services/sequelize-query.service.ts @@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common' import { AggregateQuery, AggregateResponse, + applyFilter, + CreateOneOptions, DeepPartial, DeleteManyResponse, DeleteOneOptions, @@ -129,9 +131,13 @@ export class SequelizeQueryService> * const todoItem = await this.service.createOne({title: 'Todo Item', completed: false }); * ``` * @param record - The entity to create. + * @param opts - Additional options. */ - public async createOne(record: DeepPartial): Promise { + public async createOne(record: DeepPartial, opts?: CreateOneOptions): Promise { await this.ensureEntityDoesNotExist(record) + if (opts?.filter && !applyFilter(record as Entity, opts.filter)) { + throw new Error('Entity does not meet creation constraints') + } const changedValues = this.getChangedValues(record) return this.model.create(changedValues as MakeNullishOptional) } @@ -147,11 +153,15 @@ export class SequelizeQueryService> * ]); * ``` * @param records - The entities to create. + * @param opts - Additional options. */ - public async createMany(records: DeepPartial[]): Promise { + public async createMany(records: DeepPartial[], opts?: CreateOneOptions): Promise { await Promise.all(records.map((r) => this.ensureEntityDoesNotExist(r))) + const filteredRecords = opts?.filter ? applyFilter(records as Entity[], opts.filter) : records - return this.model.bulkCreate(records.map((r) => this.getChangedValues(r) as MakeNullishOptional)) + return this.model.bulkCreate( + filteredRecords.map((r) => this.getChangedValues(r as DeepPartial) as MakeNullishOptional) + ) } /** diff --git a/packages/query-typegoose/src/services/typegoose-query-service.ts b/packages/query-typegoose/src/services/typegoose-query-service.ts index b5b5b1d3c..5ba77a21a 100644 --- a/packages/query-typegoose/src/services/typegoose-query-service.ts +++ b/packages/query-typegoose/src/services/typegoose-query-service.ts @@ -2,6 +2,9 @@ import { NotFoundException } from '@nestjs/common' import { AggregateQuery, AggregateResponse, + applyFilter, + CreateManyOptions, + CreateOneOptions, DeepPartial, DeleteManyResponse, DeleteOneOptions, @@ -116,9 +119,13 @@ export class TypegooseQueryService extends ReferenceQuerySe * const todoItem = await this.service.createOne({title: 'Todo Item', completed: false }); * ``` * @param record - The entity to create. + * @param opts - Additional options. */ - async createOne(record: DeepPartial): Promise> { + async createOne(record: DeepPartial, opts?: CreateOneOptions): Promise> { this.ensureIdIsNotPresent(record) + if (opts?.filter && !applyFilter(record as Entity, opts.filter)) { + throw new Error('Entity does not meet creation constraints') + } const doc = await this.Model.create(record) return doc } @@ -135,9 +142,9 @@ export class TypegooseQueryService extends ReferenceQuerySe * ``` * @param records - The entities to create. */ - async createMany(records: DeepPartial[]): Promise[]> { + async createMany(records: DeepPartial[], opts?: CreateManyOptions): Promise[]> { records.forEach((r) => this.ensureIdIsNotPresent(r)) - const entities = await this.Model.create(records) + const entities = await this.Model.create(opts?.filter ? applyFilter(records as Entity[], opts.filter) : records) return entities } diff --git a/packages/query-typeorm/src/services/typeorm-query.service.ts b/packages/query-typeorm/src/services/typeorm-query.service.ts index 685e49491..e5e757386 100644 --- a/packages/query-typeorm/src/services/typeorm-query.service.ts +++ b/packages/query-typeorm/src/services/typeorm-query.service.ts @@ -3,8 +3,10 @@ import { AggregateOptions, AggregateQuery, AggregateResponse, + applyFilter, Class, CountOptions, + CreateOneOptions, DeepPartial, DeleteManyOptions, DeleteManyResponse, @@ -190,12 +192,15 @@ export class TypeOrmQueryService * const todoItem = await this.service.createOne({title: 'Todo Item', completed: false }); * ``` * @param record - The entity to create. + * @param opts - Additional options. */ - public async createOne(record: DeepPartial): Promise { + public async createOne(record: DeepPartial, opts?: CreateOneOptions): Promise { const entity = await this.ensureIsEntityAndDoesNotExist(record) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + if (opts?.filter && !applyFilter(entity, opts.filter)) { + throw new Error('Entity does not meet creation constraints') + } + return this.repo.save(entity) } @@ -210,12 +215,11 @@ export class TypeOrmQueryService * ]); * ``` * @param records - The entities to create. + * @param opts - Additional options. */ - public async createMany(records: DeepPartial[]): Promise { + public async createMany(records: DeepPartial[], opts?: CreateOneOptions): Promise { const entities = await Promise.all(records.map((r) => this.ensureIsEntityAndDoesNotExist(r))) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return this.repo.save(entities) + return this.repo.save(opts?.filter ? applyFilter(entities, opts.filter) : entities) } /**