Skip to content

Commit

Permalink
feat(graphql): Add support for auto-generated federations
Browse files Browse the repository at this point in the history
* Added @reference decorator
* Added federated auto-generated resolvers
  • Loading branch information
doug-martin committed May 11, 2020
1 parent d654388 commit 238f641
Show file tree
Hide file tree
Showing 18 changed files with 416 additions and 70 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/services/noop-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class NoOpQueryService<DTO> implements QueryService<DTO> {
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
constructor() {}

addRelations<Relation>(relationName: string, id: string | number, relationIds: (string | number)[]): Promise<DTO> {
return Promise.reject(new NotImplementedException('addRelations is not implemented'));
Expand Down
10 changes: 10 additions & 0 deletions packages/query-graphql/__tests__/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ObjectType } from '@nestjs/graphql';
import { FilterableField } from '../src/decorators';
import { createResolvers } from '../src/providers';
import { CRUDResolver } from '../src/resolvers';
import { ServiceResolver } from '../src/resolvers/resolver.interface';

describe('createTypeOrmQueryServiceProviders', () => {
@ObjectType()
Expand All @@ -19,4 +20,13 @@ describe('createTypeOrmQueryServiceProviders', () => {
expect(Provider.name).toBe('TestDTOAutoResolver');
expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider);
});

it('should create a federated provider for the entity', () => {
class Service extends NoOpQueryService<TestDTO> {}
const providers = createResolvers([{ type: 'federated', DTOClass: TestDTO, Service }]);
expect(providers).toHaveLength(1);
const Provider = providers[0] as Class<ServiceResolver<TestDTO>>;
expect(Provider.name).toBe('TestDTOFederatedAutoResolver');
expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { FederationResolver, Relation } from '../../../src';
import * as relations from '../../../src/resolvers/relations';

describe('FederationResolver', () => {
const readRelation = jest.spyOn(relations, 'ReadRelationsResolver');
@ObjectType()
class TestFederatedRelationDTO {
@Field()
id!: string;
}

it('should create a federated resolver with relations from metadata', () => {
@ObjectType()
@Relation('test', () => TestFederatedRelationDTO)
class TestFederatedDTO {
@Field()
id!: string;
}
FederationResolver(TestFederatedDTO);
expect(readRelation).toBeCalledWith(TestFederatedDTO, {
many: {},
one: {
test: { DTO: TestFederatedRelationDTO },
},
});
});

it('should create a federated resolver with provided relations', () => {
@ObjectType()
class TestFederatedDTO {
@Field()
id!: string;
}
FederationResolver(TestFederatedDTO, {
one: {
test: { DTO: TestFederatedRelationDTO },
},
});
expect(readRelation).toBeCalledWith(TestFederatedDTO, {
many: {},
one: {
test: { DTO: TestFederatedRelationDTO },
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'reflect-metadata';
import * as nestGraphql from '@nestjs/graphql';
import { instance, mock, when } from 'ts-mockito';
import { QueryService } from '@nestjs-query/core';
import * as decorators from '../../src/decorators';
import { ReferenceResolver } from '../../src';

const { ID, ObjectType } = nestGraphql;

@ObjectType('TestReference')
class TestReferenceDTO {
@decorators.FilterableField(() => ID)
id!: string;

@decorators.FilterableField()
stringField!: string;
}

describe('ReferenceResolver', () => {
const resolveReferenceSpy = jest.spyOn(nestGraphql, 'ResolveReference');

beforeEach(() => jest.clearAllMocks());

class TestResolver extends ReferenceResolver(TestReferenceDTO, { key: 'id' }) {
constructor(service: QueryService<TestReferenceDTO>) {
super(service);
}
}

function asserResolveReferenceCall() {
expect(resolveReferenceSpy).toBeCalledTimes(1);
expect(resolveReferenceSpy).toBeCalledWith();
}

it('should create a new resolver with a resolveReference method', () => {
jest.clearAllMocks(); // reset
const Resolver = ReferenceResolver(TestReferenceDTO, { key: 'id' });
asserResolveReferenceCall();
expect(Resolver.prototype.resolveReference).toBeInstanceOf(Function);
});

it('should return the original resolver if key is not provided', () => {
jest.clearAllMocks(); // reset
const Resolver = ReferenceResolver(TestReferenceDTO);
expect(resolveReferenceSpy).not.toBeCalled();
expect(Resolver.prototype.resolveReference).toBeUndefined();
});

describe('#resolveReference', () => {
it('should call the service getById with the provided input', async () => {
const mockService = mock<QueryService<TestReferenceDTO>>();
const id = 'id-1';
const output: TestReferenceDTO = {
id,
stringField: 'foo',
};
const resolver = new TestResolver(instance(mockService));
when(mockService.getById(id)).thenResolve(output);
// @ts-ignore
const result = await resolver.resolveReference({ __type: 'TestReference', id });
return expect(result).toEqual(output);
});

it('should reject if the id is not found', async () => {
const mockService = mock<QueryService<TestReferenceDTO>>();
const id = 'id-1';
const output: TestReferenceDTO = {
id,
stringField: 'foo',
};
const resolver = new TestResolver(instance(mockService));
when(mockService.getById(id)).thenResolve(output);
// @ts-ignore
return expect(resolver.resolveReference({ __type: 'TestReference' })).rejects.toThrow(
'Unable to resolve reference, missing required key id for TestReference',
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { ObjectType } from '@nestjs/graphql';
import { Connection, Relation } from '../../../src/decorators';
import { ObjectType, Field } from '@nestjs/graphql';
import { Connection, Reference, Relation } from '../../../src/decorators';
import { FilterableField } from '../../../src/decorators/filterable-field.decorator';
import * as referenceRelation from '../../../src/resolvers/relations/references-relation.resolver';
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 { ReferencesOpts, Relatable } from '../../../src';
import { BaseServiceResolver } from '../../../src/resolvers/resolver.interface';

describe('Relatable', () => {
const referenceMixinSpy = jest.spyOn(referenceRelation, 'ReferencesRelationMixin');
const readMixinSpy = jest.spyOn(readRelations, 'ReadRelationsMixin');
const updateMixinSpy = jest.spyOn(updateRelations, 'UpdateRelationsMixin');
const removeMixinSpy = jest.spyOn(removeRelations, 'RemoveRelationsMixin');
Expand Down Expand Up @@ -35,27 +37,64 @@ describe('Relatable', () => {
expect(readMixinSpy).toBeCalledWith(Test, relations);
expect(updateMixinSpy).toBeCalledWith(Test, relations);
expect(removeMixinSpy).toBeCalledWith(Test, relations);
expect(referenceMixinSpy).toBeCalledWith(Test, {});
});

it('should call the mixins with the relations that are passed in', () => {
const relations = {
one: { testRelation: { DTO: TestRelation } },
many: { testConnection: { DTO: TestRelation } },
};

@ObjectType()
class Test {}

Relatable(
Test,
{
one: { testRelation: { DTO: TestRelation } },
many: { testConnection: { DTO: TestRelation } },
},
{},
)(BaseServiceResolver);
Relatable(Test, relations, {})(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);
expect(referenceMixinSpy).toBeCalledWith(Test, {});
});

it('should call the mixins with the references derived from decorators', () => {
@ObjectType()
@Reference('testRelation', () => TestRelation, { id: 'relationId' })
@Reference('testRelation2', () => TestRelation, { id: 'relationId' })
class Test {
@Field()
relationId!: number;
}

Relatable(Test, {}, {})(BaseServiceResolver);

const references = {
testRelation: { DTO: TestRelation, keys: { id: 'relationId' } },
testRelation2: { DTO: TestRelation, keys: { id: 'relationId' } },
};
const relations = { many: {}, one: {} };
expect(readMixinSpy).toBeCalledWith(Test, relations);
expect(updateMixinSpy).toBeCalledWith(Test, relations);
expect(removeMixinSpy).toBeCalledWith(Test, relations);
expect(referenceMixinSpy).toBeCalledWith(Test, references);
});

it('should call the mixins with the references passed in', () => {
@ObjectType()
class Test {
@Field()
relationId!: number;
}

const references: ReferencesOpts<Test> = {
testRelation: { DTO: TestRelation, keys: { id: 'relationId' } },
};
const relations = { many: {}, one: {} };
Relatable(Test, {}, references)(BaseServiceResolver);

expect(readMixinSpy).toBeCalledWith(Test, relations);
expect(updateMixinSpy).toBeCalledWith(Test, relations);
expect(removeMixinSpy).toBeCalledWith(Test, relations);
expect(referenceMixinSpy).toBeCalledWith(Test, references);
});
});
1 change: 1 addition & 0 deletions packages/query-graphql/src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { Connection, Relation, RelationDecoratorOpts, RelationTypeFunc } from '.
export * from './resolver-mutation.decorator';
export * from './resolver-query.decorator';
export * from './resolver-field.decorator';
export { Reference, ReferenceDecoratorOpts, ReferenceTypeFunc } from './reference.decorator';
24 changes: 24 additions & 0 deletions packages/query-graphql/src/decorators/reference.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Class } from '@nestjs-query/core';
import { getMetadataStorage } from '../metadata';
import { ResolverRelationReference } from '../resolvers/relations';
import { ReferencesKeys } from '../resolvers/relations/relations.interface';

export type ReferenceDecoratorOpts<DTO, Relation> = Omit<ResolverRelationReference<DTO, Relation>, 'DTO'>;
export type ReferenceTypeFunc<Relation> = () => Class<Relation>;

export function Reference<DTO, Reference>(
name: string,
relationTypeFunction: ReferenceTypeFunc<Reference>,
keys: ReferencesKeys<any, Reference>,
options?: ReferenceDecoratorOpts<DTO, Reference>,
) {
return <Cls extends Class<DTO>>(DTOClass: Cls): Cls | void => {
getMetadataStorage().addReference(DTOClass, name, {
name,
keys,
relationOpts: options,
relationTypeFunc: relationTypeFunction,
});
return DTOClass;
};
}
3 changes: 3 additions & 0 deletions packages/query-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export {
Connection,
RelationTypeFunc,
RelationDecoratorOpts,
Reference,
ReferenceTypeFunc,
ReferenceDecoratorOpts,
} from './decorators';
export * from './resolvers';
export * from './federation';
Expand Down
27 changes: 26 additions & 1 deletion packages/query-graphql/src/metadata/metadata-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ 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 { ResolverRelation, ResolverRelationReference } from '../resolvers/relations';
import { ReferencesKeys } from '../resolvers/relations/relations.interface';
import { EdgeType, StaticConnectionType } from '../types/connection';

/**
Expand All @@ -21,6 +22,13 @@ interface RelationDescriptor<Relation> {
isConnection: boolean;
relationOpts?: Omit<ResolverRelation<Relation>, 'DTO'>;
}

interface ReferenceDescriptor<DTO, Reference> {
name: string;
keys: ReferencesKeys<DTO, Reference>;
relationTypeFunc: () => Class<Reference>;
relationOpts?: Omit<ResolverRelationReference<DTO, Reference>, 'DTO'>;
}
/**
* @internal
*/
Expand All @@ -37,13 +45,16 @@ export class GraphQLQueryMetadataStorage {

private readonly relationStorage: Map<Class<unknown>, RelationDescriptor<unknown>[]>;

private readonly referenceStorage: Map<Class<unknown>, ReferenceDescriptor<unknown, unknown>[]>;

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();
this.referenceStorage = new Map();
}

addFilterableObjectField<T>(type: Class<T>, field: FilterableFieldDescriptor<unknown>): void {
Expand Down Expand Up @@ -116,6 +127,19 @@ export class GraphQLQueryMetadataStorage {
return this.relationStorage.get(type);
}

addReference<T>(type: Class<T>, name: string, reference: ReferenceDescriptor<T, unknown>): void {
let references: ReferenceDescriptor<unknown, unknown>[] | undefined = this.referenceStorage.get(type);
if (!references) {
references = [];
this.referenceStorage.set(type, references);
}
references.push(reference as ReferenceDescriptor<unknown, unknown>);
}

getReferences<T>(type: Class<T>): ReferenceDescriptor<T, unknown>[] | undefined {
return this.referenceStorage.get(type);
}

getGraphqlObjectMetadata<T>(objType: Class<T>): ObjectTypeMetadata | undefined {
return TypeMetadataStorage.getObjectTypesMetadata().find((o) => o.target === objType);
}
Expand All @@ -128,6 +152,7 @@ export class GraphQLQueryMetadataStorage {
this.connectionTypeStorage.clear();
this.edgeTypeStorage.clear();
this.relationStorage.clear();
this.referenceStorage.clear();
}

private getValue<V>(map: Map<Class<unknown>, Class<unknown>>, key: Class<unknown>): V | undefined {
Expand Down
8 changes: 5 additions & 3 deletions packages/query-graphql/src/module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { DynamicModule } from '@nestjs/common';
import { Class } from '@nestjs-query/core';
import { DynamicModule, ForwardReference, Provider } from '@nestjs/common';
import { AutoResolverOpts, createResolvers } from './providers';

export interface NestjsQueryGraphqlModuleOpts {
imports: DynamicModule[];
imports: Array<Class<any> | DynamicModule | Promise<DynamicModule> | ForwardReference>;
services?: Provider[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolvers: AutoResolverOpts<any, any, unknown, unknown>[];
}
Expand All @@ -13,7 +15,7 @@ export class NestjsQueryGraphQLModule {
return {
module: NestjsQueryGraphQLModule,
imports: [...opts.imports],
providers: [...resolverProviders],
providers: [...(opts.services || []), ...resolverProviders],
exports: [...resolverProviders, ...opts.imports],
};
}
Expand Down
Loading

0 comments on commit 238f641

Please sign in to comment.