diff --git a/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts b/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts new file mode 100644 index 000000000..30e06cbbf --- /dev/null +++ b/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts @@ -0,0 +1,42 @@ +import { ObjectType } from '@nestjs/graphql'; +import { Relation, Connection } from '../../src'; +import { getMetadataStorage } from '../../src/metadata'; + +@ObjectType() +class TestRelation {} + +describe('@Relation', () => { + it('should add the relation metadata to the metadata storage', () => { + const relationFn = () => TestRelation; + const relationOpts = { disableRead: true }; + @ObjectType() + @Relation('test', relationFn, relationOpts) + class TestDTO {} + + const relations = getMetadataStorage().getRelations(TestDTO); + expect(relations).toHaveLength(1); + const relation = relations![0]; + expect(relation.name).toBe('test'); + expect(relation.relationTypeFunc).toBe(relationFn); + expect(relation.isConnection).toBe(false); + expect(relation.relationOpts).toBe(relationOpts); + }); +}); + +describe('@Connection', () => { + it('should add the relation metadata to the metadata storage', () => { + const relationFn = () => TestRelation; + const relationOpts = { disableRead: true }; + @ObjectType() + @Connection('test', relationFn, relationOpts) + class TestDTO {} + + const relations = getMetadataStorage().getRelations(TestDTO); + expect(relations).toHaveLength(1); + const relation = relations![0]; + expect(relation.name).toBe('test'); + expect(relation.relationTypeFunc).toBe(relationFn); + expect(relation.isConnection).toBe(true); + expect(relation.relationOpts).toBe(relationOpts); + }); +}); diff --git a/packages/query-graphql/__tests__/resolvers/relations/relations.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/relations.resolver.spec.ts new file mode 100644 index 000000000..a1d94011a --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/relations.resolver.spec.ts @@ -0,0 +1,61 @@ +import { ObjectType } from '@nestjs/graphql'; +import { Connection, Relation } from '../../../src/decorators'; +import { FilterableField } from '../../../src/decorators/filterable-field.decorator'; +import * as readRelations from '../../../src/resolvers/relations/read-relations.resolver'; +import * as updateRelations from '../../../src/resolvers/relations/update-relations.resolver'; +import * as removeRelations from '../../../src/resolvers/relations/remove-relations.resolver'; +import { Relatable } from '../../../src'; +import { BaseServiceResolver } from '../../../src/resolvers/resolver.interface'; + +describe('Relatable', () => { + const readMixinSpy = jest.spyOn(readRelations, 'ReadRelationsMixin'); + const updateMixinSpy = jest.spyOn(updateRelations, 'UpdateRelationsMixin'); + const removeMixinSpy = jest.spyOn(removeRelations, 'RemoveRelationsMixin'); + + @ObjectType() + class TestRelation { + @FilterableField() + id!: number; + } + + afterEach(() => jest.clearAllMocks()); + + it('should call the mixins with the relations derived from decorators', () => { + @ObjectType() + @Relation('testRelation', () => TestRelation) + @Connection('testConnection', () => TestRelation) + class Test {} + + Relatable(Test, {}, {})(BaseServiceResolver); + + const relations = { + one: { testRelation: { DTO: TestRelation } }, + many: { testConnection: { DTO: TestRelation } }, + }; + expect(readMixinSpy).toBeCalledWith(Test, relations); + expect(updateMixinSpy).toBeCalledWith(Test, relations); + expect(removeMixinSpy).toBeCalledWith(Test, relations); + }); + + it('should call the mixins with the relations that are passed in', () => { + @ObjectType() + class Test {} + + Relatable( + Test, + { + one: { testRelation: { DTO: TestRelation } }, + many: { testConnection: { DTO: TestRelation } }, + }, + {}, + )(BaseServiceResolver); + + const relations = { + one: { testRelation: { DTO: TestRelation } }, + many: { testConnection: { DTO: TestRelation } }, + }; + expect(readMixinSpy).toBeCalledWith(Test, relations); + expect(updateMixinSpy).toBeCalledWith(Test, relations); + expect(removeMixinSpy).toBeCalledWith(Test, relations); + }); +}); diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 12dd6c829..8c27b7387 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -1,5 +1,6 @@ export { FilterableField } from './filterable-field.decorator'; export { ResolverMethodOpts } from './resolver-method.decorator'; +export { Connection, Relation, RelationDecoratorOpts, RelationTypeFunc } from './relation.decorator'; export * from './resolver-mutation.decorator'; export * from './resolver-query.decorator'; export * from './resolver-field.decorator'; diff --git a/packages/query-graphql/src/decorators/relation.decorator.ts b/packages/query-graphql/src/decorators/relation.decorator.ts new file mode 100644 index 000000000..1d990a4ef --- /dev/null +++ b/packages/query-graphql/src/decorators/relation.decorator.ts @@ -0,0 +1,38 @@ +import { Class } from '@nestjs-query/core'; +import { getMetadataStorage } from '../metadata'; +import { ResolverRelation } from '../resolvers/relations/relations.interface'; + +export type RelationDecoratorOpts = Omit, 'DTO'>; +export type RelationTypeFunc = () => Class; + +export function Relation( + name: string, + relationTypeFunction: RelationTypeFunc, + options?: RelationDecoratorOpts, +) { + return >(DTOClass: Cls): Cls | void => { + getMetadataStorage().addRelation(DTOClass, name, { + name, + isConnection: false, + relationOpts: options, + relationTypeFunc: relationTypeFunction, + }); + return DTOClass; + }; +} + +export function Connection( + name: string, + relationTypeFunction: RelationTypeFunc, + options?: RelationDecoratorOpts, +) { + return >(DTOClass: Cls): Cls | void => { + getMetadataStorage().addRelation(DTOClass, name, { + name, + isConnection: true, + relationOpts: options, + relationTypeFunc: relationTypeFunction, + }); + return DTOClass; + }; +} diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 2ecb62065..1a58f4b62 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -1,5 +1,12 @@ export * from './types'; -export { FilterableField, ResolverMethodOpts } from './decorators'; +export { + FilterableField, + ResolverMethodOpts, + Relation, + Connection, + RelationTypeFunc, + RelationDecoratorOpts, +} from './decorators'; export * from './resolvers'; export * from './federation'; export { DTONamesOpts } from './common'; diff --git a/packages/query-graphql/src/metadata/metadata-storage.ts b/packages/query-graphql/src/metadata/metadata-storage.ts index 4b9ac5803..8ecdced0e 100644 --- a/packages/query-graphql/src/metadata/metadata-storage.ts +++ b/packages/query-graphql/src/metadata/metadata-storage.ts @@ -2,6 +2,7 @@ import { TypeMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storage import { Class, Filter, SortField } from '@nestjs-query/core'; import { ObjectTypeMetadata } from '@nestjs/graphql/dist/schema-builder/metadata/object-type.metadata'; import { ReturnTypeFunc, FieldOptions } from '@nestjs/graphql'; +import { ResolverRelation } from '../resolvers/relations'; import { EdgeType, StaticConnectionType } from '../types/connection'; /** @@ -14,6 +15,12 @@ interface FilterableFieldDescriptor { advancedOptions?: FieldOptions; } +interface RelationDescriptor { + name: string; + relationTypeFunc: () => Class; + isConnection: boolean; + relationOpts?: Omit, 'DTO'>; +} /** * @internal */ @@ -28,12 +35,15 @@ export class GraphQLQueryMetadataStorage { private readonly edgeTypeStorage: Map, Class>>; + private readonly relationStorage: Map, RelationDescriptor[]>; + constructor() { this.filterableObjectStorage = new Map(); this.filterTypeStorage = new Map(); this.sortTypeStorage = new Map(); this.connectionTypeStorage = new Map(); this.edgeTypeStorage = new Map(); + this.relationStorage = new Map(); } addFilterableObjectField(type: Class, field: FilterableFieldDescriptor): void { @@ -93,6 +103,19 @@ export class GraphQLQueryMetadataStorage { return this.getValue(this.edgeTypeStorage, type); } + addRelation(type: Class, name: string, relation: RelationDescriptor): void { + let relations: RelationDescriptor[] | undefined = this.relationStorage.get(type); + if (!relations) { + relations = []; + this.relationStorage.set(type, relations); + } + relations.push(relation); + } + + getRelations(type: Class): RelationDescriptor[] | undefined { + return this.relationStorage.get(type); + } + getGraphqlObjectMetadata(objType: Class): ObjectTypeMetadata | undefined { return TypeMetadataStorage.getObjectTypesMetadata().find((o) => o.target === objType); } @@ -104,6 +127,7 @@ export class GraphQLQueryMetadataStorage { this.sortTypeStorage.clear(); this.connectionTypeStorage.clear(); this.edgeTypeStorage.clear(); + this.relationStorage.clear(); } private getValue(map: Map, Class>, key: Class): V | undefined { diff --git a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts index 31bb3155b..ad036b0cb 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts @@ -1,4 +1,5 @@ import { Class } from '@nestjs-query/core'; +import { getMetadataStorage } from '../../metadata'; import { ServiceResolver } from '../resolver.interface'; import { ReadRelationsMixin } from './read-relations.resolver'; import { ReferencesRelationMixin } from './references-relation.resolver'; @@ -6,18 +7,36 @@ import { ReferencesOpts, RelationsOpts } from './relations.interface'; import { RemoveRelationsMixin } from './remove-relations.resolver'; import { UpdateRelationsMixin } from './update-relations.resolver'; +const getRelationsFromMetadata = (DTOClass: Class): RelationsOpts => { + const relations: RelationsOpts = {}; + const metaRelations = getMetadataStorage().getRelations(DTOClass) ?? []; + metaRelations.forEach((r) => { + const opts = { ...r.relationOpts, DTO: r.relationTypeFunc() }; + if (r.isConnection) { + relations.many = { ...relations.many, [r.name]: opts }; + } else { + relations.one = { ...relations.one, [r.name]: opts }; + } + }); + return relations; +}; + export const Relatable = (DTOClass: Class, relations: RelationsOpts, referencesOpts: ReferencesOpts) => < B extends Class> >( Base: B, ): B => { + const metaRelations = getRelationsFromMetadata(DTOClass); + const oneRelations = { ...relations.one, ...(metaRelations.one ?? {}) }; + const manyRelations = { ...relations.many, ...(metaRelations.many ?? {}) }; + const mergedRelations = { one: oneRelations, many: manyRelations }; return ReferencesRelationMixin( DTOClass, referencesOpts, )( ReadRelationsMixin( DTOClass, - relations, - )(UpdateRelationsMixin(DTOClass, relations)(RemoveRelationsMixin(DTOClass, relations)(Base))), + mergedRelations, + )(UpdateRelationsMixin(DTOClass, mergedRelations)(RemoveRelationsMixin(DTOClass, mergedRelations)(Base))), ); };