feat: refactor schema builder and resolver builder (#2215)

* feat: wip refactor schema builder

* feat: wip store types and first queries generation

* feat: refactor schema-builder and resolver-builder

* fix: clean & small type fix

* fix: avoid breaking change

* fix: remove util from pg-graphql classes

* fix: required default fields

* Refactor frontend accordingly

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-11-03 17:16:37 +01:00
committed by GitHub
parent aba3fd454b
commit 1ed4965a95
216 changed files with 3215 additions and 2028 deletions

View File

@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import {
CreateManyResolverArgs,
Resolver,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
@Injectable()
export class CreateManyResolverFactory implements FactoryInterface {
public static methodName = 'createMany' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<CreateManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
});
return runner.createMany(args);
};
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import {
CreateOneResolverArgs,
Resolver,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
@Injectable()
export class CreateOneResolverFactory implements FactoryInterface {
public static methodName = 'createOne' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<CreateOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection:
internalContext.fieldMetadataCollection as FieldMetadata[],
});
return runner.createOne(args);
};
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import {
DeleteOneResolverArgs,
Resolver,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
@Injectable()
export class DeleteOneResolverFactory implements FactoryInterface {
public static methodName = 'deleteOne' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<DeleteOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection:
internalContext.fieldMetadataCollection as FieldMetadata[],
});
return runner.deleteOne(args);
};
}
}

View File

@ -0,0 +1,28 @@
import { FindManyResolverFactory } from './find-many-resolver.factory';
import { FindOneResolverFactory } from './find-one-resolver.factory';
import { CreateManyResolverFactory } from './create-many-resolver.factory';
import { CreateOneResolverFactory } from './create-one-resolver.factory';
import { UpdateOneResolverFactory } from './update-one-resolver.factory';
import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
export const resolverBuilderFactories = [
FindManyResolverFactory,
FindOneResolverFactory,
CreateManyResolverFactory,
CreateOneResolverFactory,
UpdateOneResolverFactory,
DeleteOneResolverFactory,
];
export const resolverBuilderMethodNames = {
queries: [
FindManyResolverFactory.methodName,
FindOneResolverFactory.methodName,
],
mutations: [
CreateManyResolverFactory.methodName,
CreateOneResolverFactory.methodName,
UpdateOneResolverFactory.methodName,
DeleteOneResolverFactory.methodName,
],
} as const;

View File

@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import {
FindManyResolverArgs,
Resolver,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
@Injectable()
export class FindManyResolverFactory implements FactoryInterface {
public static methodName = 'findMany' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<FindManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
});
return runner.findMany(args);
};
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import {
FindOneResolverArgs,
Resolver,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
@Injectable()
export class FindOneResolverFactory implements FactoryInterface {
public static methodName = 'findOne' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<FindOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection:
internalContext.fieldMetadataCollection as FieldMetadata[],
});
return runner.findOne(args);
};
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import {
Resolver,
UpdateOneResolverArgs,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { FactoryInterface } from 'src/tenant/resolver-builder/interfaces/factory.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
@Injectable()
export class UpdateOneResolverFactory implements FactoryInterface {
public static methodName = 'updateOne' as const;
constructor(private readonly dataSourceService: DataSourceService) {}
create(context: SchemaBuilderContext): Resolver<UpdateOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
targetTableName: internalContext.targetTableName,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection:
internalContext.fieldMetadataCollection as FieldMetadata[],
});
return runner.updateOne(args);
};
}
}

View File

@ -0,0 +1,7 @@
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
import { Resolver } from './resolvers-builder.interface';
export interface FactoryInterface {
create(context: SchemaBuilderContext): Resolver;
}

View File

@ -0,0 +1,14 @@
import { Record as IRecord } from './record.interface';
export interface PGGraphQLResponse<Data = any> {
resolve: {
data: Data;
};
}
export type PGGraphQLResult<Data = any> = [PGGraphQLResponse<Data>];
export interface PGGraphQLMutation<Record = IRecord> {
affectedRows: number;
records: Record[];
}

View File

@ -0,0 +1,21 @@
export interface Record {
id?: string;
[key: string]: any;
createdAt?: Date;
updatedAt?: Date;
}
export type RecordFilter = {
[Property in keyof Record]: any;
};
export enum OrderByDirection {
AscNullsFirst = 'AscNullsFirst',
AscNullsLast = 'AscNullsLast',
DescNullsFirst = 'DescNullsFirst',
DescNullsLast = 'DescNullsLast',
}
export type RecordOrderBy = {
[Property in keyof Record]: OrderByDirection;
};

View File

@ -0,0 +1,55 @@
import { GraphQLFieldResolver } from 'graphql';
import { resolverBuilderMethodNames } from 'src/tenant/resolver-builder/factories/factories';
import { Record, RecordFilter, RecordOrderBy } from './record.interface';
export type Resolver<Args = any> = GraphQLFieldResolver<any, any, Args>;
export interface FindManyResolverArgs<
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
> {
first?: number;
last?: number;
before?: string;
after?: string;
filter?: Filter;
orderBy?: OrderBy;
}
export interface FindOneResolverArgs<Filter = any> {
filter?: Filter;
}
export interface CreateOneResolverArgs<Data extends Record = Record> {
data: Data;
}
export interface CreateManyResolverArgs<Data extends Record = Record> {
data: Data[];
}
export interface UpdateOneResolverArgs<Data extends Record = Record> {
id: string;
data: Data;
}
export interface DeleteOneResolverArgs {
id: string;
}
export type ResolverBuilderQueryMethodNames =
(typeof resolverBuilderMethodNames.queries)[number];
export type ResolverBuilderMutationMethodNames =
(typeof resolverBuilderMethodNames.mutations)[number];
export type ResolverBuilderMethodNames =
| ResolverBuilderQueryMethodNames
| ResolverBuilderMutationMethodNames;
export interface ResolverBuilderMethods {
readonly queries: readonly ResolverBuilderQueryMethodNames[];
readonly mutations: readonly ResolverBuilderMutationMethodNames[];
}

View File

@ -0,0 +1,233 @@
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import {
PGGraphQLQueryBuilder,
PGGraphQLQueryBuilderOptions,
} from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-builder';
const testUUID = '123e4567-e89b-12d3-a456-426614174001';
const normalizeWhitespace = (str) => str.replace(/\s+/g, '');
// Mocking dependencies
jest.mock('uuid', () => ({
v4: jest.fn(() => testUUID),
}));
jest.mock('graphql-fields', () =>
jest.fn(() => ({
name: true,
age: true,
complexField: {
subField1: true,
subField2: true,
},
})),
);
describe('PGGraphQLQueryBuilder', () => {
let queryBuilder;
let mockOptions: PGGraphQLQueryBuilderOptions;
beforeEach(() => {
const fieldMetadataCollection = [
{
name: 'name',
targetColumnMap: {
value: 'column_name',
} as FieldMetadataTargetColumnMap,
},
{
name: 'age',
targetColumnMap: {
value: 'column_age',
} as FieldMetadataTargetColumnMap,
},
{
name: 'complexField',
targetColumnMap: {
subField1: 'column_subField1',
subField2: 'column_subField2',
} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
mockOptions = {
targetTableName: 'TestTable',
info: {} as GraphQLResolveInfo,
fieldMetadataCollection,
};
queryBuilder = new PGGraphQLQueryBuilder(mockOptions);
});
test('findMany generates correct query with no arguments', () => {
const query = queryBuilder.findMany();
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
`),
);
});
test('findMany generates correct query with filter parameters', () => {
const args = {
filter: {
name: { eq: 'Alice' },
age: { gt: 20 },
},
};
const query = queryBuilder.findMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(filter: { column_name: { eq: "Alice" }, column_age: { gt: 20 } }) {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
`),
);
});
test('findMany generates correct query with combined pagination parameters', () => {
const args = {
first: 5,
after: 'someCursor',
before: 'anotherCursor',
last: 3,
};
const query = queryBuilder.findMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(
first: 5,
after: "someCursor",
before: "anotherCursor",
last: 3
) {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
`),
);
});
test('findOne generates correct query with ID filter', () => {
const args = { filter: { id: { eq: testUUID } } };
const query = queryBuilder.findOne(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(filter: { id: { eq: "${testUUID}" } }) {
edges {
node {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
}
}
`),
);
});
test('createMany generates correct mutation with complex and nested fields', () => {
const args = {
data: [
{
name: 'Alice',
age: 30,
complexField: {
subField1: 'data1',
subField2: 'data2',
},
},
],
};
const query = queryBuilder.createMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
mutation {
insertIntoTestTableCollection(objects: [{
id: "${testUUID}",
column_name: "Alice",
column_age: 30,
column_subField1: "data1",
column_subField2: "data2"
}]) {
affectedCount
records {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
}
`),
);
});
test('updateOne generates correct mutation with complex and nested fields', () => {
const args = {
id: '1',
data: {
name: 'Bob',
age: 40,
complexField: {
subField1: 'newData1',
subField2: 'newData2',
},
},
};
const query = queryBuilder.updateOne(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
mutation {
updateTestTableCollection(
set: {
column_name: "Bob",
column_age: 40,
column_subField1: "newData1",
column_subField2: "newData2"
},
filter: { id: { eq: "1" } }
) {
affectedCount
records {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
}
`),
);
});
});

View File

@ -0,0 +1,149 @@
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import { v4 as uuidv4 } from 'uuid';
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
import {
CreateManyResolverArgs,
DeleteOneResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateOneResolverArgs,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/tenant/resolver-builder/interfaces/record.interface';
import { stringifyWithoutKeyQuote } from 'src/tenant/resolver-builder/utils/stringify-without-key-quote.util';
import { convertFieldsToGraphQL } from 'src/tenant/resolver-builder/utils/convert-fields-to-graphql.util';
import { convertArguments } from 'src/tenant/resolver-builder/utils/convert-arguments.util';
import { generateArgsInput } from 'src/tenant/resolver-builder/utils/generate-args-input.util';
export interface PGGraphQLQueryBuilderOptions {
targetTableName: string;
info: GraphQLResolveInfo;
fieldMetadataCollection: FieldMetadataInterface[];
}
export class PGGraphQLQueryBuilder<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
> {
private options: PGGraphQLQueryBuilderOptions;
constructor(options: PGGraphQLQueryBuilderOptions) {
this.options = options;
}
private getFieldsString(): string {
const select = graphqlFields(this.options.info);
return convertFieldsToGraphQL(select, this.options.fieldMetadataCollection);
}
findMany(args?: FindManyResolverArgs<Filter, OrderBy>): string {
const { targetTableName } = this.options;
const fieldsString = this.getFieldsString();
const convertedArgs = convertArguments(
args,
this.options.fieldMetadataCollection,
);
const argsString = generateArgsInput(convertedArgs);
return `
query {
${targetTableName}Collection${argsString ? `(${argsString})` : ''} {
${fieldsString}
}
}
`;
}
findOne(args: FindOneResolverArgs<Filter>): string {
const { targetTableName } = this.options;
const fieldsString = this.getFieldsString();
const convertedArgs = convertArguments(
args,
this.options.fieldMetadataCollection,
);
const argsString = generateArgsInput(convertedArgs);
return `
query {
${targetTableName}Collection${argsString ? `(${argsString})` : ''} {
edges {
node {
${fieldsString}
}
}
}
}
`;
}
createMany(initialArgs: CreateManyResolverArgs<Record>): string {
const { targetTableName } = this.options;
const fieldsString = this.getFieldsString();
const args = convertArguments(
initialArgs,
this.options.fieldMetadataCollection,
);
return `
mutation {
insertInto${targetTableName}Collection(objects: ${stringifyWithoutKeyQuote(
args.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
)}) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
updateOne(initialArgs: UpdateOneResolverArgs<Record>): string {
const { targetTableName } = this.options;
const fieldsString = this.getFieldsString();
const args = convertArguments(
initialArgs,
this.options.fieldMetadataCollection,
);
return `
mutation {
update${targetTableName}Collection(set: ${stringifyWithoutKeyQuote(
args.data,
)}, filter: { id: { eq: "${args.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
deleteOne(args: DeleteOneResolverArgs): string {
const { targetTableName } = this.options;
const fieldsString = this.getFieldsString();
return `
mutation {
deleteFrom${targetTableName}Collection(filter: { id: { eq: "${args.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,148 @@
import { BadRequestException } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteOneResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateOneResolverArgs,
} from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/tenant/resolver-builder/interfaces/record.interface';
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
import {
PGGraphQLMutation,
PGGraphQLResult,
} from 'src/tenant/resolver-builder/interfaces/pg-graphql.interface';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { parseResult } from 'src/tenant/resolver-builder/utils/parse-result.util';
import { PGGraphQLQueryBuilder } from './pg-graphql-query-builder';
interface QueryRunnerOptions {
targetTableName: string;
workspaceId: string;
info: GraphQLResolveInfo;
fieldMetadataCollection: FieldMetadataInterface[];
}
export class PGGraphQLQueryRunner<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
> {
private queryBuilder: PGGraphQLQueryBuilder;
private options: QueryRunnerOptions;
constructor(
private dataSourceService: DataSourceService,
options: QueryRunnerOptions,
) {
this.queryBuilder = new PGGraphQLQueryBuilder({
targetTableName: options.targetTableName,
info: options.info,
fieldMetadataCollection: options.fieldMetadataCollection,
});
this.options = options;
}
private async execute(
query: string,
workspaceId: string,
): Promise<PGGraphQLResult | undefined> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
await workspaceDataSource?.query(`
SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)};
`);
return workspaceDataSource?.query<PGGraphQLResult>(`
SELECT graphql.resolve($$
${query}
$$);
`);
}
private parseResult<Result>(
graphqlResult: PGGraphQLResult | undefined,
command: string,
): Result {
const tableName = this.options.targetTableName;
const entityKey = `${command}${tableName}Collection`;
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return parseResult(result);
}
async findMany(
args: FindManyResolverArgs<Filter, OrderBy>,
): Promise<IConnection<Record> | undefined> {
const query = this.queryBuilder.findMany(args);
const result = await this.execute(query, this.options.workspaceId);
return this.parseResult<IConnection<Record>>(result, '');
}
async findOne(
args: FindOneResolverArgs<Filter>,
): Promise<Record | undefined> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new BadRequestException('Missing filter argument');
}
const query = this.queryBuilder.findOne(args);
const result = await this.execute(query, this.options.workspaceId);
const parsedResult = this.parseResult<IConnection<Record>>(result, '');
return parsedResult?.edges?.[0]?.node;
}
async createMany(
args: CreateManyResolverArgs<Record>,
): Promise<Record[] | undefined> {
const query = this.queryBuilder.createMany(args);
const result = await this.execute(query, this.options.workspaceId);
return this.parseResult<PGGraphQLMutation<Record>>(result, 'insertInto')
?.records;
}
async createOne(
args: CreateOneResolverArgs<Record>,
): Promise<Record | undefined> {
const records = await this.createMany({ data: [args.data] });
return records?.[0];
}
async updateOne(
args: UpdateOneResolverArgs<Record>,
): Promise<Record | undefined> {
const query = this.queryBuilder.updateOne(args);
const result = await this.execute(query, this.options.workspaceId);
return this.parseResult<PGGraphQLMutation<Record>>(result, 'update')
?.records?.[0];
}
async deleteOne(args: DeleteOneResolverArgs): Promise<Record | undefined> {
const query = this.queryBuilder.deleteOne(args);
const result = await this.execute(query, this.options.workspaceId);
return this.parseResult<PGGraphQLMutation<Record>>(result, 'deleteFrom')
?.records?.[0];
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { ResolverFactory } from './resolver.factory';
import { resolverBuilderFactories } from './factories/factories';
@Module({
imports: [DataSourceModule],
providers: [...resolverBuilderFactories, ResolverFactory],
exports: [ResolverFactory],
})
export class ResolverBuilderModule {}

View File

@ -0,0 +1,100 @@
import { Injectable, Logger } from '@nestjs/common';
import { IResolvers } from '@graphql-tools/utils';
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
import { getResolverName } from 'src/tenant/utils/get-resolver-name.util';
import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
import { UpdateOneResolverFactory } from './factories/update-one-resolver.factory';
import { DeleteOneResolverFactory } from './factories/delete-one-resolver.factory';
import {
ResolverBuilderMethodNames,
ResolverBuilderMethods,
} from './interfaces/resolvers-builder.interface';
import { FactoryInterface } from './interfaces/factory.interface';
@Injectable()
export class ResolverFactory {
private readonly logger = new Logger(ResolverFactory.name);
constructor(
private readonly findManyResolverFactory: FindManyResolverFactory,
private readonly findOneResolverFactory: FindOneResolverFactory,
private readonly createManyResolverFactory: CreateManyResolverFactory,
private readonly createOneResolverFactory: CreateOneResolverFactory,
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
) {}
async create(
workspaceId: string,
objectMetadataCollection: ObjectMetadataInterface[],
resolverBuilderMethods: ResolverBuilderMethods,
): Promise<IResolvers> {
const factories = new Map<ResolverBuilderMethodNames, FactoryInterface>([
['findMany', this.findManyResolverFactory],
['findOne', this.findOneResolverFactory],
['createMany', this.createManyResolverFactory],
['createOne', this.createOneResolverFactory],
['updateOne', this.updateOneResolverFactory],
['deleteOne', this.deleteOneResolverFactory],
]);
const resolvers: IResolvers = {
Query: {},
Mutation: {},
};
for (const objectMetadata of objectMetadataCollection) {
// Generate query resolvers
for (const methodName of resolverBuilderMethods.queries) {
const resolverName = getResolverName(objectMetadata, methodName);
const resolverFactory = factories.get(methodName);
if (!resolverFactory) {
this.logger.error(`Unknown query resolver type: ${methodName}`, {
objectMetadata,
methodName,
resolverName,
});
throw new Error(`Unknown query resolver type: ${methodName}`);
}
resolvers.Query[resolverName] = resolverFactory.create({
workspaceId,
targetTableName: objectMetadata.targetTableName,
fieldMetadataCollection: objectMetadata.fields,
});
}
// Generate mutation resolvers
for (const methodName of resolverBuilderMethods.mutations) {
const resolverName = getResolverName(objectMetadata, methodName);
const resolverFactory = factories.get(methodName);
if (!resolverFactory) {
this.logger.error(`Unknown mutation resolver type: ${methodName}`, {
objectMetadata,
methodName,
resolverName,
});
throw new Error(`Unknown mutation resolver type: ${methodName}`);
}
resolvers.Mutation[resolverName] = resolverFactory.create({
workspaceId,
targetTableName: objectMetadata.targetTableName,
fieldMetadataCollection: objectMetadata.fields,
});
}
}
return resolvers;
}
}

View File

@ -0,0 +1,92 @@
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import {
FieldMetadata,
FieldMetadataType,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { convertArguments } from 'src/tenant/resolver-builder/utils/convert-arguments.util';
describe('convertArguments', () => {
let fields;
beforeEach(() => {
fields = [
{
name: 'firstName',
targetColumnMap: {
value: 'column_1randomFirstNameKey',
} as FieldMetadataTargetColumnMap,
type: FieldMetadataType.TEXT,
},
{
name: 'age',
targetColumnMap: {
value: 'column_randomAgeKey',
} as FieldMetadataTargetColumnMap,
type: FieldMetadataType.TEXT,
},
{
name: 'website',
targetColumnMap: {
link: 'column_randomLinkKey',
text: 'column_randomTex7Key',
} as FieldMetadataTargetColumnMap,
type: FieldMetadataType.URL,
},
] as FieldMetadata[];
});
test('should handle non-array arguments', () => {
const args = { firstName: 'John', age: 30 };
const expected = {
column_1randomFirstNameKey: 'John',
column_randomAgeKey: 30,
};
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle array arguments', () => {
const args = [{ firstName: 'John' }, { firstName: 'Jane' }];
const expected = [
{ column_1randomFirstNameKey: 'John' },
{ column_1randomFirstNameKey: 'Jane' },
];
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle nested object arguments', () => {
const args = { website: { link: 'https://www.google.fr', text: 'google' } };
const expected = {
column_randomLinkKey: 'https://www.google.fr',
column_randomTex7Key: 'google',
};
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should ignore fields not in the field metadata', () => {
const args = { firstName: 'John', lastName: 'Doe' };
const expected = { column_1randomFirstNameKey: 'John', lastName: 'Doe' };
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle deeper nested object arguments', () => {
const args = {
user: {
details: {
firstName: 'John',
website: { link: 'https://www.example.com', text: 'example' },
},
},
};
const expected = {
user: {
details: {
column_1randomFirstNameKey: 'John',
column_randomLinkKey: 'https://www.example.com',
column_randomTex7Key: 'example',
},
},
};
expect(convertArguments(args, fields)).toEqual(expected);
});
});

View File

@ -0,0 +1,98 @@
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { convertFieldsToGraphQL } from 'src/tenant/resolver-builder/utils/convert-fields-to-graphql.util';
const normalizeWhitespace = (str) => str.replace(/\s+/g, ' ').trim();
describe('convertFieldsToGraphQL', () => {
let fields;
beforeEach(() => {
fields = [
{
name: 'simpleField',
targetColumnMap: {
value: 'column_RANDOMSTRING1',
} as FieldMetadataTargetColumnMap,
},
{
name: 'complexField',
targetColumnMap: {
link: 'column_RANDOMSTRING2',
text: 'column_RANDOMSTRING3',
} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
});
test('should handle simple fields correctly', () => {
const select = { simpleField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'simpleField: column_RANDOMSTRING1\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle complex fields with multiple values correctly', () => {
const select = { complexField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = `
___complexField_link: column_RANDOMSTRING2
___complexField_text: column_RANDOMSTRING3
`;
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle fields not in the field metadata correctly', () => {
const select = { unknownField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'unknownField\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle nested object fields correctly', () => {
const select = { parentField: { childField: true } };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'parentField {\nchildField\n}\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle nested selections with multiple levels correctly', () => {
const select = {
level1: {
level2: {
simpleField: true,
},
},
};
const result = convertFieldsToGraphQL(select, fields);
const expected =
'level1 {\nlevel2 {\nsimpleField: column_RANDOMSTRING1\n}\n}\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle empty targetColumnMap gracefully', () => {
const emptyField = {
name: 'emptyField',
targetColumnMap: {},
} as FieldMetadata;
fields.push(emptyField);
const select = { emptyField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'emptyField\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should use formatted targetColumnMap values with unique random parts', () => {
const select = { simpleField: true, complexField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = `
simpleField: column_RANDOMSTRING1
___complexField_link: column_RANDOMSTRING2
___complexField_text: column_RANDOMSTRING3
`;
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
});

View File

@ -0,0 +1,61 @@
import { generateArgsInput } from 'src/tenant/resolver-builder/utils/generate-args-input.util';
const normalizeWhitespace = (str) => str.replace(/\s+/g, '');
describe('generateArgsInput', () => {
it('should handle string inputs', () => {
const args = { someKey: 'someValue' };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: "someValue"'),
);
});
it('should handle number inputs', () => {
const args = { someKey: 123 };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: 123'),
);
});
it('should handle boolean inputs', () => {
const args = { someKey: true };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: true'),
);
});
it('should skip undefined values', () => {
const args = { definedKey: 'value', undefinedKey: undefined };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('definedKey: "value"'),
);
});
it('should handle object inputs', () => {
const args = { someKey: { nestedKey: 'nestedValue' } };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: {nestedKey: "nestedValue"}'),
);
});
it('should handle null inputs', () => {
const args = { someKey: null };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: null'),
);
});
it('should remove trailing commas', () => {
const args = { firstKey: 'firstValue', secondKey: 'secondValue' };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('firstKey: "firstValue", secondKey: "secondValue"'),
);
});
});

View File

@ -0,0 +1,105 @@
import {
isSpecialKey,
handleSpecialKey,
parseResult,
} from 'src/tenant/resolver-builder/utils/parse-result.util';
describe('isSpecialKey', () => {
test('should return true if the key starts with "___"', () => {
expect(isSpecialKey('___specialKey')).toBe(true);
});
test('should return false if the key does not start with "___"', () => {
expect(isSpecialKey('normalKey')).toBe(false);
});
});
describe('handleSpecialKey', () => {
let result;
beforeEach(() => {
result = {};
});
test('should correctly process a special key and add it to the result object', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
expect(result).toEqual({
complexField: {
link: 'value1',
},
});
});
test('should add values under the same newKey if called multiple times', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
handleSpecialKey(result, '___complexField_text', 'value2');
expect(result).toEqual({
complexField: {
link: 'value1',
text: 'value2',
},
});
});
test('should not create a new field if the special key is not correctly formed', () => {
handleSpecialKey(result, '___complexField', 'value1');
expect(result).toEqual({});
});
});
describe('parseResult', () => {
test('should recursively parse an object and handle special keys', () => {
const obj = {
normalField: 'value1',
___specialField_part1: 'value2',
nested: {
___specialFieldNested_part2: 'value3',
},
};
const expectedResult = {
normalField: 'value1',
specialField: {
part1: 'value2',
},
nested: {
specialFieldNested: {
part2: 'value3',
},
},
};
expect(parseResult(obj)).toEqual(expectedResult);
});
test('should handle arrays and parse each element', () => {
const objArray = [
{
___specialField_part1: 'value1',
},
{
___specialField_part2: 'value2',
},
];
const expectedResult = [
{
specialField: {
part1: 'value1',
},
},
{
specialField: {
part2: 'value2',
},
},
];
expect(parseResult(objArray)).toEqual(expectedResult);
});
test('should return the original value if it is not an object or array', () => {
expect(parseResult('stringValue')).toBe('stringValue');
expect(parseResult(12345)).toBe(12345);
});
});

View File

@ -0,0 +1,53 @@
import { stringifyWithoutKeyQuote } from 'src/tenant/resolver-builder/utils/stringify-without-key-quote.util';
describe('stringifyWithoutKeyQuote', () => {
test('should stringify object correctly without quotes around keys', () => {
const obj = { name: 'John', age: 30, isAdmin: false };
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{name:"John",age:30,isAdmin:false}');
});
test('should handle nested objects', () => {
const obj = {
name: 'John',
age: 30,
address: { city: 'New York', zipCode: 10001 },
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,address:{city:"New York",zipCode:10001}}',
);
});
test('should handle arrays', () => {
const obj = {
name: 'John',
age: 30,
hobbies: ['reading', 'movies', 'hiking'],
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,hobbies:["reading","movies","hiking"]}',
);
});
test('should handle empty objects', () => {
const obj = {};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{}');
});
test('should handle numbers, strings, and booleans', () => {
const num = 10;
const str = 'Hello';
const bool = false;
expect(stringifyWithoutKeyQuote(num)).toBe('10');
expect(stringifyWithoutKeyQuote(str)).toBe('"Hello"');
expect(stringifyWithoutKeyQuote(bool)).toBe('false');
});
test('should handle null and undefined', () => {
expect(stringifyWithoutKeyQuote(null)).toBe('null');
expect(stringifyWithoutKeyQuote(undefined)).toBe(undefined);
});
});

View File

@ -0,0 +1,53 @@
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
export const convertArguments = (
args: any,
fields: FieldMetadataInterface[],
): any => {
const fieldsMap = new Map(
fields.map((metadata) => [metadata.name, metadata]),
);
const processObject = (obj: any): any => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => processObject(item));
}
const newObj = {};
for (const [key, value] of Object.entries(obj)) {
const fieldMetadata = fieldsMap.get(key);
if (
fieldMetadata &&
typeof value === 'object' &&
value !== null &&
Object.values(fieldMetadata.targetColumnMap).length > 1
) {
for (const [subKey, subValue] of Object.entries(value)) {
const mappedKey = fieldMetadata.targetColumnMap[subKey];
if (mappedKey) {
newObj[mappedKey] = subValue;
}
}
} else if (fieldMetadata) {
const mappedKey = fieldMetadata.targetColumnMap.value;
if (mappedKey) {
newObj[mappedKey] = value;
}
} else {
newObj[key] = processObject(value);
}
}
return newObj;
};
return processObject(args);
};

View File

@ -0,0 +1,60 @@
import isEmpty from 'lodash.isempty';
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
export const convertFieldsToGraphQL = (
select: any,
fields: FieldMetadataInterface[],
acc = '',
) => {
const fieldsMap = new Map(
fields.map((metadata) => [metadata.name, metadata]),
);
for (const [key, value] of Object.entries(select)) {
let fieldAlias = key;
if (fieldsMap.has(key)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const metadata = fieldsMap.get(key)!;
if (!metadata) {
throw new Error(`Field ${key} not found in fieldsMap`);
}
const entries = Object.entries(metadata.targetColumnMap);
if (entries.length > 0) {
// If there is only one value, use it as the alias
if (entries.length === 1) {
const alias = entries[0][1];
fieldAlias = `${key}: ${alias}`;
} else {
// Otherwise it means it's a special type with multiple values, so we need fetch all fields
fieldAlias = `
${entries
.map(([key, value]) => `___${metadata.name}_${key}: ${value}`)
.join('\n')}
`;
}
}
}
// Recurse if value is a nested object, otherwise append field or alias
if (
!fieldsMap.has(key) &&
value &&
typeof value === 'object' &&
!isEmpty(value)
) {
acc += `${key} {\n`;
acc = convertFieldsToGraphQL(value, fields, acc); // recursive call with updated accumulator
acc += `}\n`;
} else {
acc += `${fieldAlias}\n`;
}
}
return acc;
};

View File

@ -0,0 +1,30 @@
import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util';
export const generateArgsInput = (args: any) => {
let argsString = '';
for (const key in args) {
// Check if the value is not undefined
if (args[key] === undefined) {
continue;
}
if (typeof args[key] === 'string') {
// If it's a string, add quotes
argsString += `${key}: "${args[key]}", `;
} else if (typeof args[key] === 'object' && args[key] !== null) {
// If it's an object (and not null), stringify it
argsString += `${key}: ${stringifyWithoutKeyQuote(args[key])}, `;
} else {
// For other types (number, boolean), add as is
argsString += `${key}: ${args[key]}, `;
}
}
// Remove trailing comma and space, if present
if (argsString.endsWith(', ')) {
argsString = argsString.slice(0, -2);
}
return argsString;
};

View File

@ -0,0 +1,51 @@
export const isSpecialKey = (key: string): boolean => {
return key.startsWith('___');
};
export const handleSpecialKey = (
result: any,
key: string,
value: any,
): void => {
const parts = key.split('_').filter((part) => part);
// If parts don't contain enough information, return without altering result
if (parts.length < 2) {
return;
}
const newKey = parts.slice(0, -1).join('');
const subKey = parts[parts.length - 1];
if (!result[newKey]) {
result[newKey] = {};
}
result[newKey][subKey] = value;
};
export const parseResult = (obj: any): any => {
if (obj === null || typeof obj !== 'object' || typeof obj === 'function') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => parseResult(item));
}
const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
result[key] = parseResult(obj[key]);
} else if (isSpecialKey(key)) {
handleSpecialKey(result, key, obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
};

View File

@ -0,0 +1,6 @@
export const stringifyWithoutKeyQuote = (obj: any) => {
const jsonString = JSON.stringify(obj);
const jsonWithoutQuotes = jsonString?.replace(/"(\w+)"\s*:/g, '$1:');
return jsonWithoutQuotes;
};