Skip to content

Commit f6fd92a

Browse files
WeikocharlesBochet
andauthored
[POC] add graphql query runner (#6747)
## Context The goal is to replace pg_graphql with our own ORM wrapper (TwentyORM). This PR tries to add some parsing logic to convert graphql requests to send to the ORM to replace pg_graphql implementation. --------- Co-authored-by: Charles Bochet <[email protected]>
1 parent ef4f2e4 commit f6fd92a

File tree

51 files changed

+1397
-249
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1397
-249
lines changed

packages/twenty-front/vite.config.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,19 @@ export default defineConfig(({ command, mode }) => {
3232
overlay: false,
3333
};
3434

35-
console.log(
36-
`VITE_DISABLE_TYPESCRIPT_CHECKER: ${VITE_DISABLE_TYPESCRIPT_CHECKER}`,
37-
);
38-
console.log(`VITE_DISABLE_ESLINT_CHECKER: ${VITE_DISABLE_ESLINT_CHECKER}`);
39-
console.log(`VITE_BUILD_SOURCEMAP: ${VITE_BUILD_SOURCEMAP}`);
35+
if (VITE_DISABLE_TYPESCRIPT_CHECKER === 'true') {
36+
console.log(
37+
`VITE_DISABLE_TYPESCRIPT_CHECKER: ${VITE_DISABLE_TYPESCRIPT_CHECKER}`,
38+
);
39+
}
40+
41+
if (VITE_DISABLE_ESLINT_CHECKER === 'true') {
42+
console.log(`VITE_DISABLE_ESLINT_CHECKER: ${VITE_DISABLE_ESLINT_CHECKER}`);
43+
}
44+
45+
if (VITE_BUILD_SOURCEMAP === 'true') {
46+
console.log(`VITE_BUILD_SOURCEMAP: ${VITE_BUILD_SOURCEMAP}`);
47+
}
4048

4149
if (VITE_DISABLE_TYPESCRIPT_CHECKER !== 'true') {
4250
checkers['typescript'] = {

packages/twenty-server/src/app.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { MessageQueueDriverType } from 'src/engine/integrations/message-queue/in
2222
import { MessageQueueModule } from 'src/engine/integrations/message-queue/message-queue.module';
2323
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
2424
import { GraphQLHydrateRequestFromTokenMiddleware } from 'src/engine/middlewares/graphql-hydrate-request-from-token.middleware';
25+
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
2526
import { ModulesModule } from 'src/modules/modules.module';
2627

2728
import { CoreEngineModule } from './engine/core-modules/core-engine.module';
@@ -45,6 +46,7 @@ import { IntegrationsModule } from './engine/integrations/integrations.module';
4546
imports: [CoreEngineModule, GraphQLConfigModule],
4647
useClass: GraphQLConfigService,
4748
}),
49+
TwentyORMModule,
4850
// Integrations module, contains all the integrations with other services
4951
IntegrationsModule,
5052
// Core engine module, contains all the core modules

packages/twenty-server/src/database/typeorm/typeorm.module.ts

-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
33

44
import { typeORMCoreModuleOptions } from 'src/database/typeorm/core/core.datasource';
55
import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module';
6-
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
76

87
import { TypeORMService } from './typeorm.service';
98

@@ -29,7 +28,6 @@ const coreTypeORMFactory = async (): Promise<TypeOrmModuleOptions> => ({
2928
useFactory: coreTypeORMFactory,
3029
name: 'core',
3130
}),
32-
TwentyORMModule.register({}),
3331
EnvironmentModule,
3432
],
3533
providers: [TypeORMService],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const CONNECTION_MAX_DEPTH = 5;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const QUERY_MAX_RECORDS = 60;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { CustomException } from 'src/utils/custom-exception';
2+
3+
export class GraphqlQueryRunnerException extends CustomException {
4+
code: GraphqlQueryRunnerExceptionCode;
5+
constructor(message: string, code: GraphqlQueryRunnerExceptionCode) {
6+
super(message, code);
7+
}
8+
}
9+
10+
export enum GraphqlQueryRunnerExceptionCode {
11+
MAX_DEPTH_REACHED = 'MAX_DEPTH_REACHED',
12+
INVALID_CURSOR = 'INVALID_CURSOR',
13+
INVALID_DIRECTION = 'INVALID_DIRECTION',
14+
UNSUPPORTED_OPERATOR = 'UNSUPPORTED_OPERATOR',
15+
ARGS_CONFLICT = 'ARGS_CONFLICT',
16+
FIELD_NOT_FOUND = 'FIELD_NOT_FOUND',
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
4+
5+
@Module({
6+
providers: [GraphqlQueryRunnerService],
7+
exports: [GraphqlQueryRunnerService],
8+
})
9+
export class GraphqlQueryRunnerModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Injectable } from '@nestjs/common';
2+
3+
import graphqlFields from 'graphql-fields';
4+
import { FindManyOptions, ObjectLiteral } from 'typeorm';
5+
6+
import {
7+
Record as IRecord,
8+
RecordFilter,
9+
RecordOrderBy,
10+
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
11+
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
12+
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
13+
import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
14+
15+
import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
16+
import {
17+
GraphqlQueryRunnerException,
18+
GraphqlQueryRunnerExceptionCode,
19+
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
20+
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/parsers/graphql-query.parser';
21+
import { applyRangeFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/apply-range-filter.util';
22+
import {
23+
createConnection,
24+
decodeCursor,
25+
} from 'src/engine/api/graphql/graphql-query-runner/utils/connection.util';
26+
import { convertObjectMetadataToMap } from 'src/engine/api/graphql/graphql-query-runner/utils/convert-object-metadata-to-map.util';
27+
import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator';
28+
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
29+
30+
@Injectable()
31+
export class GraphqlQueryRunnerService {
32+
constructor(
33+
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
34+
) {}
35+
36+
@LogExecutionTime()
37+
async findManyWithTwentyOrm<
38+
ObjectRecord extends IRecord = IRecord,
39+
Filter extends RecordFilter = RecordFilter,
40+
OrderBy extends RecordOrderBy = RecordOrderBy,
41+
>(
42+
args: FindManyResolverArgs<Filter, OrderBy>,
43+
options: WorkspaceQueryRunnerOptions,
44+
): Promise<IConnection<ObjectRecord>> {
45+
const { authContext, objectMetadataItem, info, objectMetadataCollection } =
46+
options;
47+
48+
const repository =
49+
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
50+
authContext.workspace.id,
51+
objectMetadataItem.nameSingular,
52+
);
53+
54+
const selectedFields = graphqlFields(info);
55+
56+
const objectMetadataMap = convertObjectMetadataToMap(
57+
objectMetadataCollection,
58+
);
59+
60+
const objectMetadata = objectMetadataMap[objectMetadataItem.nameSingular];
61+
62+
if (!objectMetadata) {
63+
throw new Error(
64+
`Object metadata for ${objectMetadataItem.nameSingular} not found`,
65+
);
66+
}
67+
68+
const fieldMetadataMap = objectMetadata.fields;
69+
70+
const graphqlQueryParser = new GraphqlQueryParser(
71+
fieldMetadataMap,
72+
objectMetadataMap,
73+
);
74+
75+
const { select, relations } = graphqlQueryParser.parseSelectedFields(
76+
objectMetadataItem,
77+
selectedFields,
78+
);
79+
80+
const order = args.orderBy
81+
? graphqlQueryParser.parseOrder(args.orderBy)
82+
: undefined;
83+
84+
const where = args.filter
85+
? graphqlQueryParser.parseFilter(args.filter)
86+
: {};
87+
88+
let cursor: Record<string, any> | undefined;
89+
90+
if (args.after) {
91+
cursor = decodeCursor(args.after);
92+
} else if (args.before) {
93+
cursor = decodeCursor(args.before);
94+
}
95+
96+
if (args.first && args.last) {
97+
throw new GraphqlQueryRunnerException(
98+
'Cannot provide both first and last',
99+
GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT,
100+
);
101+
}
102+
103+
const take = args.first ?? args.last ?? QUERY_MAX_RECORDS;
104+
105+
const findOptions: FindManyOptions<ObjectLiteral> = {
106+
where,
107+
order,
108+
select,
109+
relations,
110+
take,
111+
};
112+
113+
const totalCount = await repository.count({
114+
where,
115+
});
116+
117+
if (cursor) {
118+
applyRangeFilter(where, order, cursor);
119+
}
120+
121+
const objectRecords = await repository.find(findOptions);
122+
123+
return createConnection(
124+
(objectRecords as ObjectRecord[]) ?? [],
125+
take,
126+
totalCount,
127+
order,
128+
);
129+
}
130+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { FindOperator, Not } from 'typeorm';
2+
3+
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
4+
5+
import { GraphqlQueryFilterFieldParser } from 'src/engine/api/graphql/graphql-query-runner/parsers/graphql-query-filter/graphql-query-filter-field.parser';
6+
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
7+
8+
describe('GraphqlQueryFilterFieldParser', () => {
9+
let parser: GraphqlQueryFilterFieldParser;
10+
let mockFieldMetadataMap: Record<string, FieldMetadataInterface>;
11+
12+
beforeEach(() => {
13+
mockFieldMetadataMap = {
14+
simpleField: {
15+
id: '1',
16+
name: 'simpleField',
17+
type: FieldMetadataType.TEXT,
18+
label: 'Simple Field',
19+
objectMetadataId: 'obj1',
20+
},
21+
};
22+
parser = new GraphqlQueryFilterFieldParser(mockFieldMetadataMap);
23+
});
24+
it('should parse simple field correctly', () => {
25+
const result = parser.parse('simpleField', 'value', false);
26+
27+
expect(result).toEqual({ simpleField: 'value' });
28+
});
29+
30+
it('should negate simple field correctly', () => {
31+
const result = parser.parse('simpleField', 'value', true);
32+
33+
expect(result).toEqual({ simpleField: Not('value') });
34+
});
35+
36+
it('should parse object value using operator parser', () => {
37+
const result = parser.parse('simpleField', { like: '%value%' }, false);
38+
39+
expect(result).toEqual({
40+
simpleField: new FindOperator('like', '%%value%%'),
41+
});
42+
});
43+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
FindOperator,
3+
ILike,
4+
In,
5+
IsNull,
6+
LessThan,
7+
LessThanOrEqual,
8+
Like,
9+
MoreThan,
10+
MoreThanOrEqual,
11+
Not,
12+
} from 'typeorm';
13+
14+
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
15+
import { GraphqlQueryFilterOperatorParser } from 'src/engine/api/graphql/graphql-query-runner/parsers/graphql-query-filter/graphql-query-filter-operator.parser';
16+
17+
describe('GraphqlQueryFilterOperatorParser', () => {
18+
let parser: GraphqlQueryFilterOperatorParser;
19+
20+
beforeEach(() => {
21+
parser = new GraphqlQueryFilterOperatorParser();
22+
});
23+
24+
describe('parseOperator', () => {
25+
it('should parse eq operator correctly', () => {
26+
const result = parser.parseOperator({ eq: 'value' }, false);
27+
28+
expect(result).toBe('value');
29+
});
30+
31+
it('should parse neq operator correctly', () => {
32+
const result = parser.parseOperator({ neq: 'value' }, false);
33+
34+
expect(result).toBeInstanceOf(FindOperator);
35+
expect(result).toEqual(Not('value'));
36+
});
37+
38+
it('should parse gt operator correctly', () => {
39+
const result = parser.parseOperator({ gt: 5 }, false);
40+
41+
expect(result).toBeInstanceOf(FindOperator);
42+
expect(result).toEqual(MoreThan(5));
43+
});
44+
45+
it('should parse gte operator correctly', () => {
46+
const result = parser.parseOperator({ gte: 5 }, false);
47+
48+
expect(result).toBeInstanceOf(FindOperator);
49+
expect(result).toEqual(MoreThanOrEqual(5));
50+
});
51+
52+
it('should parse lt operator correctly', () => {
53+
const result = parser.parseOperator({ lt: 5 }, false);
54+
55+
expect(result).toBeInstanceOf(FindOperator);
56+
expect(result).toEqual(LessThan(5));
57+
});
58+
59+
it('should parse lte operator correctly', () => {
60+
const result = parser.parseOperator({ lte: 5 }, false);
61+
62+
expect(result).toBeInstanceOf(FindOperator);
63+
expect(result).toEqual(LessThanOrEqual(5));
64+
});
65+
66+
it('should parse in operator correctly', () => {
67+
const result = parser.parseOperator({ in: [1, 2, 3] }, false);
68+
69+
expect(result).toBeInstanceOf(FindOperator);
70+
expect(result).toEqual(In([1, 2, 3]));
71+
});
72+
73+
it('should parse is operator with NULL correctly', () => {
74+
const result = parser.parseOperator({ is: 'NULL' }, false);
75+
76+
expect(result).toBeInstanceOf(FindOperator);
77+
expect(result).toEqual(IsNull());
78+
});
79+
80+
it('should parse is operator with non-NULL value correctly', () => {
81+
const result = parser.parseOperator({ is: 'NOT_NULL' }, false);
82+
83+
expect(result).toBe('NOT_NULL');
84+
});
85+
86+
it('should parse like operator correctly', () => {
87+
const result = parser.parseOperator({ like: 'test' }, false);
88+
89+
expect(result).toBeInstanceOf(FindOperator);
90+
expect(result).toEqual(Like('%test%'));
91+
});
92+
93+
it('should parse ilike operator correctly', () => {
94+
const result = parser.parseOperator({ ilike: 'test' }, false);
95+
96+
expect(result).toBeInstanceOf(FindOperator);
97+
expect(result).toEqual(ILike('%test%'));
98+
});
99+
100+
it('should parse startsWith operator correctly', () => {
101+
const result = parser.parseOperator({ startsWith: 'test' }, false);
102+
103+
expect(result).toBeInstanceOf(FindOperator);
104+
expect(result).toEqual(ILike('test%'));
105+
});
106+
107+
it('should parse endsWith operator correctly', () => {
108+
const result = parser.parseOperator({ endsWith: 'test' }, false);
109+
110+
expect(result).toBeInstanceOf(FindOperator);
111+
expect(result).toEqual(ILike('%test'));
112+
});
113+
114+
it('should negate the operator when isNegated is true', () => {
115+
const result = parser.parseOperator({ eq: 'value' }, true);
116+
117+
expect(result).toBeInstanceOf(FindOperator);
118+
expect(result).toEqual(Not('value'));
119+
});
120+
121+
it('should throw an exception for unsupported operator', () => {
122+
expect(() =>
123+
parser.parseOperator({ unsupported: 'value' }, false),
124+
).toThrow(GraphqlQueryRunnerException);
125+
expect(() =>
126+
parser.parseOperator({ unsupported: 'value' }, false),
127+
).toThrow('Operator "unsupported" is not supported');
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)