Skip to content

Commit

Permalink
feat(Feat/get condition directly): Change the way select and join are…
Browse files Browse the repository at this point in the history
… retrieved from the GraphQL query. (#35)

* Chore

* Chore: Change string to symbol for metadata key

* Feat: Implement repository interceptor to get select and relations

- Change from util function to this

* Chore: Remove lodash

* Docs: Update read me

* Feat: Change from sending  repository to entity in interceptor

* Chore: Change filename

* Chore: Change function name

* Feat: Wrtie test code

* Feat: Update plop template file

* Chore: Remove useless

* Chore: Change from string to symbol as key of metadata

* Chore: Remark no use
  • Loading branch information
Ho-s authored Jan 12, 2025
1 parent 166e927 commit d91e731
Show file tree
Hide file tree
Showing 25 changed files with 1,176 additions and 1,075 deletions.
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati
}
```

### GraphQL Query To Select and relations

#### Dynamic Query Optimization

- Automatically maps GraphQL queries to optimized SELECT and JOIN clauses in TypeORM.

- Ensures that only the requested fields and necessary relations are retrieved, reducing over-fetching and improving performance.

- With using interceptor (name: `UseRepositoryInterceptor`) and paramDecorator (name: `GraphQLQueryToOption`)

#### How to use

- You can find example code in [/src/user/user.resolver.ts](/src/user/user.resolver.ts)

#### Example of some protected GraphQL

- getMe (must be authenticated)
Expand Down Expand Up @@ -342,13 +356,6 @@ db.public.registerFunction({
- [x] Integration Test (Use in-memory DB)
- [x] End To End Test (Use docker)
- [ ] Add Many OAUths (Both of front and back end)
- [ ] Kakao
- [ ] Google
- [ ] Apple
- [ ] Naver
- [x] CI
- [x] Github actions
Expand Down
22 changes: 15 additions & 7 deletions generator/templates/resolver.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,39 @@ import { UseAuthGuard } from 'src/common/decorators/auth-guard.decorator';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { {{pascalCase tableName}}Service } from './{{tableName}}.service'
import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input'
import { CurrentQuery } from 'src/common/decorators/query.decorator'
import GraphQLJSON from 'graphql-type-json';

import { GetInfoFromQueryProps } from 'src/common/graphql/utils/types';
import { GraphQLQueryToOption } from 'src/common/decorators/option.decorator';
import { UseRepositoryInterceptor } from 'src/common/decorators/repository-interceptor.decorator';

import { Get{{pascalCase tableName}}Type, {{pascalCase tableName}} } from './entities/{{tableName}}.entity';
import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input';

@Resolver()
export class {{pascalCase tableName}}Resolver {
constructor(private readonly {{tableName}}Service: {{pascalCase tableName}}Service) {}

@Query(() => Get{{pascalCase tableName}}Type)
@UseAuthGuard('admin')
@UseRepositoryInterceptor({{pascalCase tableName}})
getMany{{pascalCase tableName}}List(
@Args({ name: 'input', nullable: true }) qs: GetManyInput<{{pascalCase tableName}}>,
@CurrentQuery() gqlQuery: string,
@Args({ name: 'input', nullable: true }) condition: GetManyInput<{{pascalCase tableName}}>,
@GraphQLQueryToOption<{{pascalCase tableName}}>(true)
option: GetInfoFromQueryProps<{{pascalCase tableName}}>,
) {
return this.{{tableName}}Service.getMany(qs, gqlQuery);
return this.{{tableName}}Service.getMany({ ...condition, ...option });
}

@Query(() => {{pascalCase tableName}})
@UseAuthGuard('admin')
@UseRepositoryInterceptor({{pascalCase tableName}})
getOne{{pascalCase tableName}}(
@Args({ name: 'input' }) qs: GetOneInput<{{pascalCase tableName}}>,
@CurrentQuery() gqlQuery: string,
@Args({ name: 'input' }) condition: GetOneInput<{{pascalCase tableName}}>,
@GraphQLQueryToOption<{{pascalCase tableName}}>()
option: GetInfoFromQueryProps<{{pascalCase tableName}}>,
) {
return this.{{tableName}}Service.getOne(qs, gqlQuery);
return this.{{tableName}}Service.getOne({ ...condition, ...option });
}

@Mutation(() => {{pascalCase tableName}})
Expand Down
47 changes: 21 additions & 26 deletions generator/templates/resolver.spec.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input'
import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity'
import { UtilModule } from 'src/common/shared/services/util.module';
import { UtilService } from 'src/common/shared/services/util.service';
import { DataSource } from 'typeorm';

import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input'

Expand All @@ -26,6 +27,10 @@ describe('{{pascalCase tableName}}Resolver', () => {
provide: {{pascalCase tableName}}Service,
useFactory: MockServiceFactory.getMockService({{pascalCase tableName}}Service),
},
{
provide: DataSource,
useValue: undefined,
},
],
}).compile()

Expand All @@ -39,7 +44,7 @@ describe('{{pascalCase tableName}}Resolver', () => {
})

it('Calling "Get many {{tableName}} list" method', () => {
const qs: GetManyInput<{{pascalCase tableName}}> = {
const condition: GetManyInput<{{pascalCase tableName}}> = {
where: {
{{#if (is "increment" idType)}}
id: utilService.getRandomNumber(0,999999)
Expand All @@ -49,22 +54,17 @@ describe('{{pascalCase tableName}}Resolver', () => {
},
}

const gqlQuery = `
query GetMany{{pascalCase tableName}}List {
getMany{{pascalCase tableName}}List {
data {
id
}
}
}
`

expect(resolver.getMany{{pascalCase tableName}}List(qs, gqlQuery)).not.toEqual(null)
expect(mockedService.getMany).toHaveBeenCalledWith(qs, gqlQuery)
const option = { relations: undefined, select: undefined };

expect(resolver.getMany{{pascalCase tableName}}List(condition, option)).not.toEqual(null)
expect(mockedService.getMany).toHaveBeenCalledWith({
...condition,
...option,
})
})

it('Calling "Get one {{tableName}} list" method', () => {
const qs: GetOneInput<{{pascalCase tableName}}> = {
const condition: GetOneInput<{{pascalCase tableName}}> = {
where: {
{{#if (is "increment" idType)}}
id: utilService.getRandomNumber(0,999999)
Expand All @@ -74,18 +74,13 @@ describe('{{pascalCase tableName}}Resolver', () => {
},
}

const gqlQuery = `
query GetOne{{pascalCase tableName}} {
getOne{{pascalCase tableName}} {
data {
id
}
}
}
`

expect(resolver.getOne{{pascalCase tableName}}(qs, gqlQuery)).not.toEqual(null)
expect(mockedService.getOne).toHaveBeenCalledWith(qs, gqlQuery)
const option = { relations: undefined, select: undefined };

expect(resolver.getOne{{pascalCase tableName}}(condition, option)).not.toEqual(null)
expect(mockedService.getOne).toHaveBeenCalledWith({
...condition,
...option,
})
})

it('Calling "Create {{tableName}}" method', () => {
Expand Down
10 changes: 6 additions & 4 deletions generator/templates/service.hbs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Injectable } from '@nestjs/common'

import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types'

import { {{pascalCase tableName}}Repository } from './{{tableName}}.repository'
import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity';
import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input';

@Injectable()
export class {{pascalCase tableName}}Service {
constructor(private readonly {{tableName}}Repository: {{pascalCase tableName}}Repository) {}
getMany(qs: RepoQuery<{{pascalCase tableName}}> = {}, gqlQuery?: string) {
return this.{{tableName}}Repository.getMany(qs, gqlQuery);
getMany(option?: RepoQuery<{{pascalCase tableName}}>) {
return this.{{tableName}}Repository.getMany(option);
}

getOne(qs: OneRepoQuery<{{pascalCase tableName}}>, gqlQuery?: string) {
return this.{{tableName}}Repository.getOne(qs, gqlQuery);
getOne(option: OneRepoQuery<{{pascalCase tableName}}>) {
return this.{{tableName}}Repository.getOne(option);
}

create(input: Create{{pascalCase tableName}}Input) {
Expand Down
8 changes: 4 additions & 4 deletions generator/templates/service.spec.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('{{pascalCase tableName}}Service', () => {
})

it('Calling "Get many" method', () => {
const qs: RepoQuery<{{pascalCase tableName}}> = {
const option: RepoQuery<{{pascalCase tableName}}> = {
where: {
{{#if (is "increment" idType)}}
id: utilService.getRandomNumber(0,999999)
Expand All @@ -52,12 +52,12 @@ describe('{{pascalCase tableName}}Service', () => {
},
}

expect(service.getMany(qs)).not.toEqual(null)
expect(service.getMany(option)).not.toEqual(null)
expect(mockedRepository.getMany).toHaveBeenCalled()
})

it('Calling "Get one" method', () => {
const qs: OneRepoQuery<{{pascalCase tableName}}> = {
const option: OneRepoQuery<{{pascalCase tableName}}> = {
where: {
{{#if (is "increment" idType)}}
id: utilService.getRandomNumber(0,999999)
Expand All @@ -67,7 +67,7 @@ describe('{{pascalCase tableName}}Service', () => {
},
}

expect(service.getOne(qs)).not.toEqual(null)
expect(service.getOne(option)).not.toEqual(null)
expect(mockedRepository.getOne).toHaveBeenCalled()
})

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
"@types/express": "^5.0.0",
"@types/graphql-upload": "^15.0.2",
"@types/jest": "29.5.14",
"@types/lodash": "^4.17.13",
"@types/node": "^22.10.3",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
Expand Down
1 change: 1 addition & 0 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
try {
const userData = await this.userService.getOne({
where: { id: payload.id },
select: { id: true, role: true },
});

done(null, userData);
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion src/cache/custom-cache.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CacheModule, CacheModuleOptions } from '@nestjs/cache-manager';
import { DynamicModule, Module, OnModuleInit } from '@nestjs/common';
import { APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core';

import { CustomCacheInterceptor } from './custom-cache-interceptor';
import { CustomCacheInterceptor } from './custom-cache.interceptor';
import { CustomCacheService } from './custom-cache.service';

@Module({})
Expand Down
4 changes: 3 additions & 1 deletion src/common/decorators/auth-guard.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';

import { GraphqlPassportAuthGuard } from '../guards/graphql-passport-auth.guard';

export const GUARD_ROLE = Symbol('GUARD_ROLE');

export const UseAuthGuard = (roles?: string | string[]) =>
applyDecorators(
SetMetadata(
'roles',
GUARD_ROLE,
roles ? (Array.isArray(roles) ? roles : [roles]) : ['user'],
),
UseGuards(GraphqlPassportAuthGuard),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import {
ExecutionContext,
InternalServerErrorException,
createParamDecorator,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

import { parse, print } from 'graphql';
import { Repository } from 'typeorm';

import { set } from './processWhere';
import { AddKeyValueInObjectProps, GetInfoFromQueryProps } from './types';
import { set } from '../graphql/utils/processWhere';
import {
AddKeyValueInObjectProps,
GetInfoFromQueryProps,
} from '../graphql/utils/types';

const DATA = 'data';

Expand Down Expand Up @@ -32,7 +43,7 @@ const addKeyValuesInObject = <Entity>({
return { relations, select };
};

export function getConditionFromGqlQuery<Entity>(
export function getOptionFromGqlQuery<Entity>(
this: Repository<Entity>,
query: string,
hasCountType?: boolean,
Expand Down Expand Up @@ -108,3 +119,64 @@ export function getConditionFromGqlQuery<Entity>(
},
);
}

const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
const { fieldName, path } = ctx.getArgByIndex(3) as {
fieldName: string;
path: { key: string };
};

const query = ctx.getContext().req.body.query;
const operationJson = print(parse(query));
const operationArray = operationJson.split('\n');

operationArray.shift();
operationArray.pop();

const firstLineFinder = operationArray.findIndex((v) =>
v.includes(fieldName === path.key ? fieldName : path.key + ':'),
);

operationArray.splice(0, firstLineFinder);

const stack = [];

let depth = 0;

for (const line of operationArray) {
stack.push(line);
if (line.includes('{')) {
depth++;
} else if (line.includes('}')) {
depth--;
}

if (depth === 0) {
break;
}
}

return stack.join('\n');
};

export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
createParamDecorator((_: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
const query = getCurrentGraphQLQuery(ctx);
const repository: Repository<T> = request.repository;

if (!repository) {
throw new InternalServerErrorException(
"Repository not found in request, don't forget to use UseRepositoryInterceptor",
);
}

const queryOption: GetInfoFromQueryProps<T> = getOptionFromGqlQuery.call(
repository,
query,
hasCountType,
);

return queryOption;
})();
Loading

0 comments on commit d91e731

Please sign in to comment.