Skip to content

feat(Feat/get condition directly): Change the way select and join are retrieved from the GraphQL query. #35

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading