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:
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
28
server/src/tenant/resolver-builder/factories/factories.ts
Normal file
28
server/src/tenant/resolver-builder/factories/factories.ts
Normal 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;
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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[];
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
100
server/src/tenant/resolver-builder/resolver.factory.ts
Normal file
100
server/src/tenant/resolver-builder/resolver.factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
@ -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"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
export const stringifyWithoutKeyQuote = (obj: any) => {
|
||||
const jsonString = JSON.stringify(obj);
|
||||
const jsonWithoutQuotes = jsonString?.replace(/"(\w+)"\s*:/g, '$1:');
|
||||
|
||||
return jsonWithoutQuotes;
|
||||
};
|
||||
Reference in New Issue
Block a user