feat: schema-builder and resolver-builder can handle relations (#2398)
* feat: wip add relation * feat: add relation for custom and standards objects * fix: use enum instead of magic string * fix: remove dead code & fix tests * fix: typo * fix: BooleanFilter is missing * fix: Malformed result error
This commit is contained in:
@ -73,14 +73,32 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadata> {
|
|||||||
public async getObjectMetadataFromWorkspaceId(workspaceId: string) {
|
public async getObjectMetadataFromWorkspaceId(workspaceId: string) {
|
||||||
return this.objectMetadataRepository.find({
|
return this.objectMetadataRepository.find({
|
||||||
where: { workspaceId },
|
where: { workspaceId },
|
||||||
relations: ['fields'],
|
relations: [
|
||||||
|
'fields',
|
||||||
|
'fields.fromRelationMetadata',
|
||||||
|
'fields.fromRelationMetadata.fromObjectMetadata',
|
||||||
|
'fields.fromRelationMetadata.toObjectMetadata',
|
||||||
|
'fields.fromRelationMetadata.toObjectMetadata.fields',
|
||||||
|
'fields.toRelationMetadata',
|
||||||
|
'fields.toRelationMetadata.fromObjectMetadata',
|
||||||
|
'fields.toRelationMetadata.toObjectMetadata',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
|
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
|
||||||
return this.objectMetadataRepository.find({
|
return this.objectMetadataRepository.find({
|
||||||
where: { dataSourceId },
|
where: { dataSourceId },
|
||||||
relations: ['fields'],
|
relations: [
|
||||||
|
'fields',
|
||||||
|
'fields.fromRelationMetadata',
|
||||||
|
'fields.fromRelationMetadata.fromObjectMetadata',
|
||||||
|
'fields.fromRelationMetadata.toObjectMetadata',
|
||||||
|
'fields.fromRelationMetadata.toObjectMetadata.fields',
|
||||||
|
'fields.toRelationMetadata',
|
||||||
|
'fields.toRelationMetadata.fromObjectMetadata',
|
||||||
|
'fields.toRelationMetadata.toObjectMetadata',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,16 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { RelationType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
import { BeforeCreateOneRelation } from 'src/metadata/relation-metadata/hooks/before-create-one-relation.hook';
|
import { BeforeCreateOneRelation } from 'src/metadata/relation-metadata/hooks/before-create-one-relation.hook';
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
@BeforeCreateOne(BeforeCreateOneRelation)
|
@BeforeCreateOne(BeforeCreateOneRelation)
|
||||||
export class CreateRelationInput {
|
export class CreateRelationInput {
|
||||||
@IsEnum(RelationType)
|
@IsEnum(RelationMetadataType)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Field()
|
@Field()
|
||||||
relationType: RelationType;
|
relationType: RelationMetadataType;
|
||||||
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@ -17,10 +17,12 @@ import {
|
|||||||
Relation,
|
Relation,
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
} from '@ptc-org/nestjs-query-graphql';
|
||||||
|
|
||||||
|
import { RelationMetadataInterface } from 'src/tenant/schema-builder/interfaces/relation-metadata.interface';
|
||||||
|
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
export enum RelationType {
|
export enum RelationMetadataType {
|
||||||
ONE_TO_ONE = 'ONE_TO_ONE',
|
ONE_TO_ONE = 'ONE_TO_ONE',
|
||||||
ONE_TO_MANY = 'ONE_TO_MANY',
|
ONE_TO_MANY = 'ONE_TO_MANY',
|
||||||
MANY_TO_MANY = 'MANY_TO_MANY',
|
MANY_TO_MANY = 'MANY_TO_MANY',
|
||||||
@ -41,14 +43,14 @@ export enum RelationType {
|
|||||||
})
|
})
|
||||||
@Relation('fromObjectMetadata', () => ObjectMetadata)
|
@Relation('fromObjectMetadata', () => ObjectMetadata)
|
||||||
@Relation('toObjectMetadata', () => ObjectMetadata)
|
@Relation('toObjectMetadata', () => ObjectMetadata)
|
||||||
export class RelationMetadata {
|
export class RelationMetadata implements RelationMetadataInterface {
|
||||||
@IDField(() => ID)
|
@IDField(() => ID)
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
relationType: RelationType;
|
relationType: RelationMetadataType;
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
@Column({ nullable: false, type: 'uuid' })
|
@Column({ nullable: false, type: 'uuid' })
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Repository } from 'typeorm';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
RelationMetadata,
|
RelationMetadata,
|
||||||
RelationType,
|
RelationMetadataType,
|
||||||
} from 'src/metadata/relation-metadata/relation-metadata.entity';
|
} from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
||||||
import { FieldMetadataService } from 'src/metadata/field-metadata/services/field-metadata.service';
|
import { FieldMetadataService } from 'src/metadata/field-metadata/services/field-metadata.service';
|
||||||
@ -36,7 +36,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
|
|||||||
override async createOne(
|
override async createOne(
|
||||||
record: CreateRelationInput,
|
record: CreateRelationInput,
|
||||||
): Promise<RelationMetadata> {
|
): Promise<RelationMetadata> {
|
||||||
if (record.relationType === RelationType.MANY_TO_MANY) {
|
if (record.relationType === RelationMetadataType.MANY_TO_MANY) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Many to many relations are not supported yet',
|
'Many to many relations are not supported yet',
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
// 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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// `),
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
it('should pass', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArgsAliasFactory {
|
||||||
|
create(
|
||||||
|
args: Record<string, any>,
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[],
|
||||||
|
): Record<string, any> {
|
||||||
|
const fieldMetadataMap = new Map(
|
||||||
|
fieldMetadataCollection.map((fieldMetadata) => [
|
||||||
|
fieldMetadata.name,
|
||||||
|
fieldMetadata,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.createArgsObjectRecursive(args, fieldMetadataMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createArgsObjectRecursive(
|
||||||
|
args: Record<string, any>,
|
||||||
|
fieldMetadataMap: Map<string, FieldMetadataInterface>,
|
||||||
|
) {
|
||||||
|
// If it's not an object, we don't need to do anything
|
||||||
|
if (typeof args !== 'object' || args === null) {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array, we need to map all items
|
||||||
|
if (Array.isArray(args)) {
|
||||||
|
return args.map((arg) =>
|
||||||
|
this.createArgsObjectRecursive(arg, fieldMetadataMap),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newArgs = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
const fieldMetadata = fieldMetadataMap.get(key);
|
||||||
|
|
||||||
|
// If it's a special complex field, we need to map all columns
|
||||||
|
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) {
|
||||||
|
newArgs[mappedKey] = subValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fieldMetadata) {
|
||||||
|
// Otherwise we just need to map the value
|
||||||
|
const mappedKey = fieldMetadata.targetColumnMap.value;
|
||||||
|
|
||||||
|
if (mappedKey) {
|
||||||
|
newArgs[mappedKey] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Recurse if value is a nested object, otherwise append field or alias
|
||||||
|
newArgs[key] = this.createArgsObjectRecursive(value, fieldMetadataMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
import { stringifyWithoutKeyQuote } from 'src/tenant/query-builder/utils/stringify-without-key-quote.util';
|
||||||
|
|
||||||
|
import { ArgsAliasFactory } from './args-alias.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArgsStringFactory {
|
||||||
|
constructor(private readonly argsAliasFactory: ArgsAliasFactory) {}
|
||||||
|
create(
|
||||||
|
initialArgs: Record<string, any> | undefined,
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[],
|
||||||
|
): string | null {
|
||||||
|
if (!initialArgs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let argsString = '';
|
||||||
|
const computedArgs = this.argsAliasFactory.create(
|
||||||
|
initialArgs,
|
||||||
|
fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key in computedArgs) {
|
||||||
|
// Check if the value is not undefined
|
||||||
|
if (computedArgs[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof computedArgs[key] === 'string') {
|
||||||
|
// If it's a string, add quotes
|
||||||
|
argsString += `${key}: "${computedArgs[key]}", `;
|
||||||
|
} else if (
|
||||||
|
typeof computedArgs[key] === 'object' &&
|
||||||
|
computedArgs[key] !== null
|
||||||
|
) {
|
||||||
|
// If it's an object (and not null), stringify it
|
||||||
|
argsString += `${key}: ${stringifyWithoutKeyQuote(
|
||||||
|
computedArgs[key],
|
||||||
|
)}, `;
|
||||||
|
} else {
|
||||||
|
// For other types (number, boolean), add as is
|
||||||
|
argsString += `${key}: ${computedArgs[key]}, `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing comma and space, if present
|
||||||
|
if (argsString.endsWith(', ')) {
|
||||||
|
argsString = argsString.slice(0, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return argsString;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GraphQLResolveInfo } from 'graphql';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
import {
|
||||||
|
deduceRelationDirection,
|
||||||
|
RelationDirection,
|
||||||
|
} from 'src/tenant/utils/deduce-relation-direction.util';
|
||||||
|
import { getFieldArgumentsByKey } from 'src/tenant/query-builder/utils/get-field-arguments-by-key.util';
|
||||||
|
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
import { ArgsStringFactory } from './args-string.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompositeFieldAliasFactory {
|
||||||
|
private logger = new Logger(CompositeFieldAliasFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(forwardRef(() => FieldsStringFactory))
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsStringFactory: ArgsStringFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(
|
||||||
|
fieldKey: string,
|
||||||
|
fieldValue: any,
|
||||||
|
fieldMetadata: FieldMetadataInterface,
|
||||||
|
info: GraphQLResolveInfo,
|
||||||
|
) {
|
||||||
|
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
throw new Error(`Field ${fieldMetadata.name} is not a composite field`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (fieldMetadata.type) {
|
||||||
|
case FieldMetadataType.RELATION:
|
||||||
|
return this.createRelationAlias(
|
||||||
|
fieldKey,
|
||||||
|
fieldValue,
|
||||||
|
fieldMetadata,
|
||||||
|
info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRelationAlias(
|
||||||
|
fieldKey: string,
|
||||||
|
fieldValue: any,
|
||||||
|
fieldMetadata: FieldMetadataInterface,
|
||||||
|
info: GraphQLResolveInfo,
|
||||||
|
) {
|
||||||
|
const relationMetadata =
|
||||||
|
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
|
||||||
|
|
||||||
|
if (!relationMetadata) {
|
||||||
|
throw new Error(
|
||||||
|
`Relation metadata not found for field ${fieldMetadata.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTableName = relationMetadata.toObjectMetadata.targetTableName;
|
||||||
|
const relationDirection = deduceRelationDirection(
|
||||||
|
fieldMetadata.objectId,
|
||||||
|
relationMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If it's a relation destination is of kind MANY, we need to add the collection suffix and extract the args
|
||||||
|
if (
|
||||||
|
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY &&
|
||||||
|
relationDirection === RelationDirection.FROM
|
||||||
|
) {
|
||||||
|
const args = getFieldArgumentsByKey(info, fieldKey);
|
||||||
|
const argsString = this.argsStringFactory.create(
|
||||||
|
args,
|
||||||
|
relationMetadata.toObjectMetadata.fields,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
${fieldKey}: ${targetTableName}Collection${
|
||||||
|
argsString ? `(${argsString})` : ''
|
||||||
|
} {
|
||||||
|
${this.fieldsStringFactory.createFieldsStringRecursive(
|
||||||
|
info,
|
||||||
|
fieldValue,
|
||||||
|
relationMetadata.toObjectMetadata.fields,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise it means it's a relation destination is of kind ONE
|
||||||
|
return `
|
||||||
|
${fieldKey}: ${targetTableName} {
|
||||||
|
${this.fieldsStringFactory.createFieldsStringRecursive(
|
||||||
|
info,
|
||||||
|
fieldValue,
|
||||||
|
relationMetadata.toObjectMetadata.fields,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import { Record as IRecord } from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import { CreateManyResolverArgs } from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { stringifyWithoutKeyQuote } from 'src/tenant/query-builder/utils/stringify-without-key-quote.util';
|
||||||
|
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
import { ArgsAliasFactory } from './args-alias.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateManyQueryFactory {
|
||||||
|
private readonly logger = new Logger(CreateManyQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsAliasFactory: ArgsAliasFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create<Record extends IRecord = IRecord>(
|
||||||
|
args: CreateManyResolverArgs<Record>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
) {
|
||||||
|
const fieldsString = this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
const computedArgs = this.argsAliasFactory.create(
|
||||||
|
args,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
mutation {
|
||||||
|
insertInto${
|
||||||
|
options.targetTableName
|
||||||
|
}Collection(objects: ${stringifyWithoutKeyQuote(
|
||||||
|
computedArgs.data.map((datum) => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
...datum,
|
||||||
|
})),
|
||||||
|
)}) {
|
||||||
|
affectedCount
|
||||||
|
records {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import { DeleteOneResolverArgs } from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteOneQueryFactory {
|
||||||
|
private readonly logger = new Logger(DeleteOneQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(private readonly fieldsStringFactory: FieldsStringFactory) {}
|
||||||
|
|
||||||
|
create(args: DeleteOneResolverArgs, options: QueryBuilderOptions) {
|
||||||
|
const fieldsString = this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
mutation {
|
||||||
|
deleteFrom${options.targetTableName}Collection(filter: { id: { eq: "${args.id}" } }) {
|
||||||
|
affectedCount
|
||||||
|
records {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/src/tenant/query-builder/factories/factories.ts
Normal file
23
server/src/tenant/query-builder/factories/factories.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ArgsAliasFactory } from './args-alias.factory';
|
||||||
|
import { ArgsStringFactory } from './args-string.factory';
|
||||||
|
import { CompositeFieldAliasFactory } from './composite-field-alias.factory';
|
||||||
|
import { CreateManyQueryFactory } from './create-many-query.factory';
|
||||||
|
import { DeleteOneQueryFactory } from './delete-one-query.factory';
|
||||||
|
import { FieldAliasFacotry } from './field-alias.factory';
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
import { FindManyQueryFactory } from './find-many-query.factory';
|
||||||
|
import { FindOneQueryFactory } from './find-one-query.factory';
|
||||||
|
import { UpdateOneQueryFactory } from './update-one-query.factory';
|
||||||
|
|
||||||
|
export const queryBuilderFactories = [
|
||||||
|
ArgsAliasFactory,
|
||||||
|
ArgsStringFactory,
|
||||||
|
CompositeFieldAliasFactory,
|
||||||
|
CreateManyQueryFactory,
|
||||||
|
DeleteOneQueryFactory,
|
||||||
|
FieldAliasFacotry,
|
||||||
|
FieldsStringFactory,
|
||||||
|
FindManyQueryFactory,
|
||||||
|
FindOneQueryFactory,
|
||||||
|
UpdateOneQueryFactory,
|
||||||
|
];
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldAliasFacotry {
|
||||||
|
private readonly logger = new Logger(FieldAliasFacotry.name);
|
||||||
|
|
||||||
|
create(fieldKey: string, fieldMetadata: FieldMetadataInterface) {
|
||||||
|
const entries = Object.entries(fieldMetadata.targetColumnMap);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 1) {
|
||||||
|
// If there is only one value, use it as the alias
|
||||||
|
const alias = entries[0][1];
|
||||||
|
|
||||||
|
return `${fieldKey}: ${alias}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise it means it's a special type with multiple values, so we need map all columns
|
||||||
|
return `
|
||||||
|
${entries
|
||||||
|
.map(([key, value]) => `___${fieldMetadata.name}_${key}: ${value}`)
|
||||||
|
.join('\n')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GraphQLResolveInfo } from 'graphql';
|
||||||
|
import graphqlFields from 'graphql-fields';
|
||||||
|
import isEmpty from 'lodash.isempty';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
|
import { FieldAliasFacotry } from './field-alias.factory';
|
||||||
|
import { CompositeFieldAliasFactory } from './composite-field-alias.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldsStringFactory {
|
||||||
|
private readonly logger = new Logger(FieldsStringFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldAliasFactory: FieldAliasFacotry,
|
||||||
|
private readonly compositeFieldAliasFactory: CompositeFieldAliasFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(
|
||||||
|
info: GraphQLResolveInfo,
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[],
|
||||||
|
) {
|
||||||
|
const selectedFields: Record<string, any> = graphqlFields(info);
|
||||||
|
|
||||||
|
return this.createFieldsStringRecursive(
|
||||||
|
info,
|
||||||
|
selectedFields,
|
||||||
|
fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createFieldsStringRecursive(
|
||||||
|
info: GraphQLResolveInfo,
|
||||||
|
selectedFields: Record<string, any>,
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[],
|
||||||
|
accumulator = '',
|
||||||
|
): string {
|
||||||
|
const fieldMetadataMap = new Map(
|
||||||
|
fieldMetadataCollection.map((metadata) => [metadata.name, metadata]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [fieldKey, fieldValue] of Object.entries(selectedFields)) {
|
||||||
|
let fieldAlias: string | null;
|
||||||
|
|
||||||
|
if (fieldMetadataMap.has(fieldKey)) {
|
||||||
|
// We're sure that the field exists in the map after this if condition
|
||||||
|
// ES6 should tackle that more properly
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const fieldMetadata = fieldMetadataMap.get(fieldKey)!;
|
||||||
|
|
||||||
|
// If the field is a composite field, we need to create a special alias
|
||||||
|
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
const alias = this.compositeFieldAliasFactory.create(
|
||||||
|
fieldKey,
|
||||||
|
fieldValue,
|
||||||
|
fieldMetadata,
|
||||||
|
info,
|
||||||
|
);
|
||||||
|
|
||||||
|
fieldAlias = alias;
|
||||||
|
} else {
|
||||||
|
// Otherwise we just need to create a simple alias
|
||||||
|
const alias = this.fieldAliasFactory.create(fieldKey, fieldMetadata);
|
||||||
|
|
||||||
|
fieldAlias = alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldAlias ??= fieldKey;
|
||||||
|
|
||||||
|
// Recurse if value is a nested object, otherwise append field or alias
|
||||||
|
if (
|
||||||
|
!fieldMetadataMap.has(fieldKey) &&
|
||||||
|
fieldValue &&
|
||||||
|
typeof fieldValue === 'object' &&
|
||||||
|
!isEmpty(fieldValue)
|
||||||
|
) {
|
||||||
|
accumulator += `${fieldKey} {\n`;
|
||||||
|
accumulator = this.createFieldsStringRecursive(
|
||||||
|
info,
|
||||||
|
fieldValue,
|
||||||
|
fieldMetadataCollection,
|
||||||
|
accumulator,
|
||||||
|
);
|
||||||
|
accumulator += `}\n`;
|
||||||
|
} else {
|
||||||
|
accumulator += `${fieldAlias}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import {
|
||||||
|
RecordFilter,
|
||||||
|
RecordOrderBy,
|
||||||
|
} from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import { FindManyResolverArgs } from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { ArgsStringFactory } from './args-string.factory';
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindManyQueryFactory {
|
||||||
|
private readonly logger = new Logger(FindManyQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsStringFactory: ArgsStringFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create<
|
||||||
|
Filter extends RecordFilter = RecordFilter,
|
||||||
|
OrderBy extends RecordOrderBy = RecordOrderBy,
|
||||||
|
>(args: FindManyResolverArgs<Filter, OrderBy>, options: QueryBuilderOptions) {
|
||||||
|
const fieldsString = this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
const argsString = this.argsStringFactory.create(
|
||||||
|
args,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
query {
|
||||||
|
${options.targetTableName}Collection${
|
||||||
|
argsString ? `(${argsString})` : ''
|
||||||
|
} {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import { RecordFilter } from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import { FindOneResolverArgs } from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { ArgsStringFactory } from './args-string.factory';
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindOneQueryFactory {
|
||||||
|
private readonly logger = new Logger(FindOneQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsStringFactory: ArgsStringFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create<Filter extends RecordFilter = RecordFilter>(
|
||||||
|
args: FindOneResolverArgs<Filter>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
) {
|
||||||
|
const fieldsString = this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
const argsString = this.argsStringFactory.create(
|
||||||
|
args,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
query {
|
||||||
|
${options.targetTableName}Collection${
|
||||||
|
argsString ? `(${argsString})` : ''
|
||||||
|
} {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import { Record as IRecord } from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import { UpdateOneResolverArgs } from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { stringifyWithoutKeyQuote } from 'src/tenant/query-builder/utils/stringify-without-key-quote.util';
|
||||||
|
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
import { ArgsAliasFactory } from './args-alias.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UpdateOneQueryFactory {
|
||||||
|
private readonly logger = new Logger(UpdateOneQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsAliasFactory: ArgsAliasFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create<Record extends IRecord = IRecord>(
|
||||||
|
args: UpdateOneResolverArgs<Record>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
) {
|
||||||
|
const fieldsString = this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
const computedArgs = this.argsAliasFactory.create(
|
||||||
|
args,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
mutation {
|
||||||
|
update${
|
||||||
|
options.targetTableName
|
||||||
|
}Collection(set: ${stringifyWithoutKeyQuote(
|
||||||
|
computedArgs.data,
|
||||||
|
)}, filter: { id: { eq: "${computedArgs.id}" } }) {
|
||||||
|
affectedCount
|
||||||
|
records {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { GraphQLResolveInfo } from 'graphql';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
export interface QueryBuilderOptions {
|
||||||
|
targetTableName: string;
|
||||||
|
info: GraphQLResolveInfo;
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[];
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
69
server/src/tenant/query-builder/query-builder.factory.ts
Normal file
69
server/src/tenant/query-builder/query-builder.factory.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderOptions } from 'src/tenant/query-builder/interfaces/query-builder-options.interface';
|
||||||
|
import {
|
||||||
|
Record as IRecord,
|
||||||
|
RecordFilter,
|
||||||
|
RecordOrderBy,
|
||||||
|
} from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import {
|
||||||
|
FindManyResolverArgs,
|
||||||
|
FindOneResolverArgs,
|
||||||
|
CreateManyResolverArgs,
|
||||||
|
UpdateOneResolverArgs,
|
||||||
|
DeleteOneResolverArgs,
|
||||||
|
} from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { FindManyQueryFactory } from './factories/find-many-query.factory';
|
||||||
|
import { FindOneQueryFactory } from './factories/find-one-query.factory';
|
||||||
|
import { CreateManyQueryFactory } from './factories/create-many-query.factory';
|
||||||
|
import { UpdateOneQueryFactory } from './factories/update-one-query.factory';
|
||||||
|
import { DeleteOneQueryFactory } from './factories/delete-one-query.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueryBuilderFactory {
|
||||||
|
private readonly logger = new Logger(QueryBuilderFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly findManyQueryFactory: FindManyQueryFactory,
|
||||||
|
private readonly findOneQueryFactory: FindOneQueryFactory,
|
||||||
|
private readonly createManyQueryFactory: CreateManyQueryFactory,
|
||||||
|
private readonly updateOneQueryFactory: UpdateOneQueryFactory,
|
||||||
|
private readonly deleteOneQueryFactory: DeleteOneQueryFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findMany<
|
||||||
|
Filter extends RecordFilter = RecordFilter,
|
||||||
|
OrderBy extends RecordOrderBy = RecordOrderBy,
|
||||||
|
>(
|
||||||
|
args: FindManyResolverArgs<Filter, OrderBy>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
): string {
|
||||||
|
return this.findManyQueryFactory.create<Filter, OrderBy>(args, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
findOne<Filter extends RecordFilter = RecordFilter>(
|
||||||
|
args: FindOneResolverArgs<Filter>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
): string {
|
||||||
|
return this.findOneQueryFactory.create<Filter>(args, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMany<Record extends IRecord = IRecord>(
|
||||||
|
args: CreateManyResolverArgs<Record>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
): string {
|
||||||
|
return this.createManyQueryFactory.create<Record>(args, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOne<Record extends IRecord = IRecord>(
|
||||||
|
initialArgs: UpdateOneResolverArgs<Record>,
|
||||||
|
options: QueryBuilderOptions,
|
||||||
|
): string {
|
||||||
|
return this.updateOneQueryFactory.create<Record>(initialArgs, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteOne(args: DeleteOneResolverArgs, options: QueryBuilderOptions): string {
|
||||||
|
return this.deleteOneQueryFactory.create(args, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/src/tenant/query-builder/query-builder.module.ts
Normal file
12
server/src/tenant/query-builder/query-builder.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderFactory } from './query-builder.factory';
|
||||||
|
|
||||||
|
import { queryBuilderFactories } from './factories/factories';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
providers: [...queryBuilderFactories, QueryBuilderFactory],
|
||||||
|
exports: [QueryBuilderFactory],
|
||||||
|
})
|
||||||
|
export class QueryBuilderModule {}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { stringifyWithoutKeyQuote } from 'src/tenant/resolver-builder/utils/stringify-without-key-quote.util';
|
import { stringifyWithoutKeyQuote } from 'src/tenant/query-builder/utils/stringify-without-key-quote.util';
|
||||||
|
|
||||||
describe('stringifyWithoutKeyQuote', () => {
|
describe('stringifyWithoutKeyQuote', () => {
|
||||||
test('should stringify object correctly without quotes around keys', () => {
|
test('should stringify object correctly without quotes around keys', () => {
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
GraphQLResolveInfo,
|
||||||
|
SelectionSetNode,
|
||||||
|
Kind,
|
||||||
|
SelectionNode,
|
||||||
|
FieldNode,
|
||||||
|
InlineFragmentNode,
|
||||||
|
ValueNode,
|
||||||
|
} from 'graphql';
|
||||||
|
|
||||||
|
const isFieldNode = (node: SelectionNode): node is FieldNode =>
|
||||||
|
node.kind === Kind.FIELD;
|
||||||
|
|
||||||
|
const isInlineFragmentNode = (
|
||||||
|
node: SelectionNode,
|
||||||
|
): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
|
||||||
|
|
||||||
|
const findFieldNode = (
|
||||||
|
selectionSet: SelectionSetNode | undefined,
|
||||||
|
key: string,
|
||||||
|
): FieldNode | null => {
|
||||||
|
if (!selectionSet) return null;
|
||||||
|
|
||||||
|
let field: FieldNode | null = null;
|
||||||
|
|
||||||
|
for (const selection of selectionSet.selections) {
|
||||||
|
// We've found the field
|
||||||
|
if (isFieldNode(selection) && selection.name.value === key) {
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively search for the field in nested selections
|
||||||
|
if (
|
||||||
|
(isFieldNode(selection) || isInlineFragmentNode(selection)) &&
|
||||||
|
selection.selectionSet
|
||||||
|
) {
|
||||||
|
field = findFieldNode(selection.selectionSet, key);
|
||||||
|
|
||||||
|
// If we find the field in a nested selection, stop searching
|
||||||
|
if (field) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseValueNode = (
|
||||||
|
valueNode: ValueNode,
|
||||||
|
variables: GraphQLResolveInfo['variableValues'],
|
||||||
|
) => {
|
||||||
|
switch (valueNode.kind) {
|
||||||
|
case Kind.VARIABLE:
|
||||||
|
return variables[valueNode.name.value];
|
||||||
|
case Kind.INT:
|
||||||
|
case Kind.FLOAT:
|
||||||
|
return Number(valueNode.value);
|
||||||
|
case Kind.STRING:
|
||||||
|
case Kind.BOOLEAN:
|
||||||
|
case Kind.ENUM:
|
||||||
|
return valueNode.value;
|
||||||
|
case Kind.LIST:
|
||||||
|
return valueNode.values.map((value) => parseValueNode(value, variables));
|
||||||
|
case Kind.OBJECT:
|
||||||
|
return valueNode.fields.reduce((obj, field) => {
|
||||||
|
obj[field.name.value] = parseValueNode(field.value, variables);
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFieldArgumentsByKey = (
|
||||||
|
info: GraphQLResolveInfo,
|
||||||
|
fieldKey: string,
|
||||||
|
): Record<string, any> => {
|
||||||
|
// Start from the first top-level field node and search recursively
|
||||||
|
const targetField = findFieldNode(info.fieldNodes[0].selectionSet, fieldKey);
|
||||||
|
|
||||||
|
// If the field is not found, throw an error
|
||||||
|
if (!targetField) {
|
||||||
|
throw new Error(`Field "${fieldKey}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the arguments from the field we've found
|
||||||
|
const args: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (targetField.arguments && targetField.arguments.length) {
|
||||||
|
for (const arg of targetField.arguments) {
|
||||||
|
args[arg.name.value] = parseValueNode(arg.value, info.variableValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
};
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { Record as IRecord } from 'src/tenant/query-builder/interfaces/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,10 @@
|
|||||||
|
import { GraphQLResolveInfo } from 'graphql';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
|
export interface QueryRunnerOptions {
|
||||||
|
targetTableName: string;
|
||||||
|
workspaceId: string;
|
||||||
|
info: GraphQLResolveInfo;
|
||||||
|
fieldMetadataCollection: FieldMetadataInterface[];
|
||||||
|
}
|
||||||
13
server/src/tenant/query-runner/query-runner.module.ts
Normal file
13
server/src/tenant/query-runner/query-runner.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueryBuilderModule } from 'src/tenant/query-builder/query-builder.module';
|
||||||
|
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
||||||
|
|
||||||
|
import { QueryRunnerService } from './query-runner.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [QueryBuilderModule, DataSourceModule],
|
||||||
|
providers: [QueryRunnerService],
|
||||||
|
exports: [QueryRunnerService],
|
||||||
|
})
|
||||||
|
export class QueryRunnerModule {}
|
||||||
160
server/src/tenant/query-runner/query-runner.service.ts
Normal file
160
server/src/tenant/query-runner/query-runner.service.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
|
||||||
|
import {
|
||||||
|
Record as IRecord,
|
||||||
|
RecordFilter,
|
||||||
|
RecordOrderBy,
|
||||||
|
} from 'src/tenant/query-builder/interfaces/record.interface';
|
||||||
|
import {
|
||||||
|
CreateManyResolverArgs,
|
||||||
|
CreateOneResolverArgs,
|
||||||
|
DeleteOneResolverArgs,
|
||||||
|
FindManyResolverArgs,
|
||||||
|
FindOneResolverArgs,
|
||||||
|
UpdateOneResolverArgs,
|
||||||
|
} from 'src/tenant/query-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { QueryBuilderFactory } from 'src/tenant/query-builder/query-builder.factory';
|
||||||
|
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||||
|
import { parseResult } from 'src/tenant/query-runner/utils/parse-result.util';
|
||||||
|
|
||||||
|
import { QueryRunnerOptions } from './interfaces/query-runner-optionts.interface';
|
||||||
|
import {
|
||||||
|
PGGraphQLMutation,
|
||||||
|
PGGraphQLResult,
|
||||||
|
} from './interfaces/pg-graphql.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueryRunnerService {
|
||||||
|
private readonly logger = new Logger(QueryRunnerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly queryBuilderFactory: QueryBuilderFactory,
|
||||||
|
private readonly dataSourceService: DataSourceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findMany<
|
||||||
|
Record extends IRecord = IRecord,
|
||||||
|
Filter extends RecordFilter = RecordFilter,
|
||||||
|
OrderBy extends RecordOrderBy = RecordOrderBy,
|
||||||
|
>(
|
||||||
|
args: FindManyResolverArgs<Filter, OrderBy>,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<IConnection<Record> | undefined> {
|
||||||
|
const { workspaceId, targetTableName } = options;
|
||||||
|
const query = this.queryBuilderFactory.findMany(args, options);
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
|
||||||
|
return this.parseResult<IConnection<Record>>(result, targetTableName, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne<
|
||||||
|
Record extends IRecord = IRecord,
|
||||||
|
Filter extends RecordFilter = RecordFilter,
|
||||||
|
>(
|
||||||
|
args: FindOneResolverArgs<Filter>,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<Record | undefined> {
|
||||||
|
if (!args.filter || Object.keys(args.filter).length === 0) {
|
||||||
|
throw new BadRequestException('Missing filter argument');
|
||||||
|
}
|
||||||
|
const { workspaceId, targetTableName } = options;
|
||||||
|
const query = this.queryBuilderFactory.findOne(args, options);
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
const parsedResult = this.parseResult<IConnection<Record>>(
|
||||||
|
result,
|
||||||
|
targetTableName,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedResult?.edges?.[0]?.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany<Record extends IRecord = IRecord>(
|
||||||
|
args: CreateManyResolverArgs<Record>,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<Record[] | undefined> {
|
||||||
|
const { workspaceId, targetTableName } = options;
|
||||||
|
const query = this.queryBuilderFactory.createMany(args, options);
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
|
||||||
|
return this.parseResult<PGGraphQLMutation<Record>>(
|
||||||
|
result,
|
||||||
|
targetTableName,
|
||||||
|
'insertInto',
|
||||||
|
)?.records;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOne<Record extends IRecord = IRecord>(
|
||||||
|
args: CreateOneResolverArgs<Record>,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<Record | undefined> {
|
||||||
|
const records = await this.createMany({ data: [args.data] }, options);
|
||||||
|
|
||||||
|
return records?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOne<Record extends IRecord = IRecord>(
|
||||||
|
args: UpdateOneResolverArgs<Record>,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<Record | undefined> {
|
||||||
|
const { workspaceId, targetTableName } = options;
|
||||||
|
const query = this.queryBuilderFactory.updateOne(args, options);
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
|
||||||
|
return this.parseResult<PGGraphQLMutation<Record>>(
|
||||||
|
result,
|
||||||
|
targetTableName,
|
||||||
|
'update',
|
||||||
|
)?.records?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOne<Record extends IRecord = IRecord>(
|
||||||
|
args: DeleteOneResolverArgs,
|
||||||
|
options: QueryRunnerOptions,
|
||||||
|
): Promise<Record | undefined> {
|
||||||
|
const { workspaceId, targetTableName } = options;
|
||||||
|
const query = this.queryBuilderFactory.deleteOne(args, options);
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
|
||||||
|
return this.parseResult<PGGraphQLMutation<Record>>(
|
||||||
|
result,
|
||||||
|
targetTableName,
|
||||||
|
'deleteFrom',
|
||||||
|
)?.records?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
targetTableName: string,
|
||||||
|
command: string,
|
||||||
|
): Result {
|
||||||
|
const entityKey = `${command}${targetTableName}Collection`;
|
||||||
|
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new BadRequestException('Malformed result from GraphQL query');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import {
|
|||||||
isSpecialKey,
|
isSpecialKey,
|
||||||
handleSpecialKey,
|
handleSpecialKey,
|
||||||
parseResult,
|
parseResult,
|
||||||
} from 'src/tenant/resolver-builder/utils/parse-result.util';
|
} from 'src/tenant/query-runner/utils/parse-result.util';
|
||||||
|
|
||||||
describe('isSpecialKey', () => {
|
describe('isSpecialKey', () => {
|
||||||
test('should return true if the key starts with "___"', () => {
|
test('should return true if the key starts with "___"', () => {
|
||||||
@ -7,8 +7,7 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.service';
|
||||||
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateManyResolverFactory
|
export class CreateManyResolverFactory
|
||||||
@ -16,20 +15,18 @@ export class CreateManyResolverFactory
|
|||||||
{
|
{
|
||||||
public static methodName = 'createMany' as const;
|
public static methodName = 'createMany' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<CreateManyResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<CreateManyResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.createMany(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.createMany(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,7 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.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()
|
@Injectable()
|
||||||
export class CreateOneResolverFactory
|
export class CreateOneResolverFactory
|
||||||
@ -17,21 +15,18 @@ export class CreateOneResolverFactory
|
|||||||
{
|
{
|
||||||
public static methodName = 'createOne' as const;
|
public static methodName = 'createOne' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<CreateOneResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<CreateOneResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.createOne(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection:
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
internalContext.fieldMetadataCollection as FieldMetadata[],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.createOne(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,7 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.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()
|
@Injectable()
|
||||||
export class DeleteOneResolverFactory
|
export class DeleteOneResolverFactory
|
||||||
@ -17,21 +15,18 @@ export class DeleteOneResolverFactory
|
|||||||
{
|
{
|
||||||
public static methodName = 'deleteOne' as const;
|
public static methodName = 'deleteOne' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<DeleteOneResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<DeleteOneResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.deleteOne(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection:
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
internalContext.fieldMetadataCollection as FieldMetadata[],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.deleteOne(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,7 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.service';
|
||||||
import { PGGraphQLQueryRunner } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-runner';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FindManyResolverFactory
|
export class FindManyResolverFactory
|
||||||
@ -16,20 +15,18 @@ export class FindManyResolverFactory
|
|||||||
{
|
{
|
||||||
public static methodName = 'findMany' as const;
|
public static methodName = 'findMany' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<FindManyResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<FindManyResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.findMany(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.findMany(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,29 +7,24 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.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()
|
@Injectable()
|
||||||
export class FindOneResolverFactory implements ResolverBuilderFactoryInterface {
|
export class FindOneResolverFactory implements ResolverBuilderFactoryInterface {
|
||||||
public static methodName = 'findOne' as const;
|
public static methodName = 'findOne' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<FindOneResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<FindOneResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.findOne(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection:
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
internalContext.fieldMetadataCollection as FieldMetadata[],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.findOne(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,7 @@ import {
|
|||||||
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
|
||||||
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
import { ResolverBuilderFactoryInterface } from 'src/tenant/resolver-builder/interfaces/resolver-builder-factory.interface';
|
||||||
|
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { QueryRunnerService } from 'src/tenant/query-runner/query-runner.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()
|
@Injectable()
|
||||||
export class UpdateOneResolverFactory
|
export class UpdateOneResolverFactory
|
||||||
@ -17,21 +15,18 @@ export class UpdateOneResolverFactory
|
|||||||
{
|
{
|
||||||
public static methodName = 'updateOne' as const;
|
public static methodName = 'updateOne' as const;
|
||||||
|
|
||||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
constructor(private readonly queryRunnerService: QueryRunnerService) {}
|
||||||
|
|
||||||
create(context: SchemaBuilderContext): Resolver<UpdateOneResolverArgs> {
|
create(context: SchemaBuilderContext): Resolver<UpdateOneResolverArgs> {
|
||||||
const internalContext = context;
|
const internalContext = context;
|
||||||
|
|
||||||
return (_source, args, context, info) => {
|
return (_source, args, context, info) => {
|
||||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
return this.queryRunnerService.updateOne(args, {
|
||||||
targetTableName: internalContext.targetTableName,
|
targetTableName: internalContext.targetTableName,
|
||||||
workspaceId: internalContext.workspaceId,
|
workspaceId: internalContext.workspaceId,
|
||||||
info,
|
info,
|
||||||
fieldMetadataCollection:
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
internalContext.fieldMetadataCollection as FieldMetadata[],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return runner.updateOne(args);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
import { QueryRunnerModule } from 'src/tenant/query-runner/query-runner.module';
|
||||||
|
|
||||||
import { ResolverFactory } from './resolver.factory';
|
import { ResolverFactory } from './resolver.factory';
|
||||||
|
|
||||||
import { resolverBuilderFactories } from './factories/factories';
|
import { resolverBuilderFactories } from './factories/factories';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DataSourceModule],
|
imports: [QueryRunnerModule],
|
||||||
providers: [...resolverBuilderFactories, ResolverFactory],
|
providers: [...resolverBuilderFactories, ResolverFactory],
|
||||||
exports: [ResolverFactory],
|
exports: [ResolverFactory],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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"'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -36,7 +36,7 @@ export class ConnectionTypeDefinitionFactory {
|
|||||||
type: new GraphQLObjectType({
|
type: new GraphQLObjectType({
|
||||||
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
|
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
|
||||||
description: objectMetadata.description,
|
description: objectMetadata.description,
|
||||||
fields: this.generateFields(objectMetadata, options),
|
fields: () => this.generateFields(objectMetadata, options),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export class EdgeTypeDefinitionFactory {
|
|||||||
type: new GraphQLObjectType({
|
type: new GraphQLObjectType({
|
||||||
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
|
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
|
||||||
description: objectMetadata.description,
|
description: objectMetadata.description,
|
||||||
fields: this.generateFields(objectMetadata, options),
|
fields: () => this.generateFields(objectMetadata, options),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GraphQLFieldConfigArgumentMap,
|
||||||
|
GraphQLFieldConfigMap,
|
||||||
|
GraphQLObjectType,
|
||||||
|
} from 'graphql';
|
||||||
|
|
||||||
|
import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-schema-optionts.interface';
|
||||||
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
import { TypeDefinitionsStorage } from 'src/tenant/schema-builder/storages/type-definitions.storage';
|
||||||
|
import { objectContainsCompositeField } from 'src/tenant/schema-builder/utils/object-contains-composite-field';
|
||||||
|
import { getResolverArgs } from 'src/tenant/schema-builder/utils/get-resolver-args.util';
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
import {
|
||||||
|
RelationDirection,
|
||||||
|
deduceRelationDirection,
|
||||||
|
} from 'src/tenant/utils/deduce-relation-direction.util';
|
||||||
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
|
||||||
|
import { RelationTypeFactory } from './relation-type.factory';
|
||||||
|
import { ArgsFactory } from './args.factory';
|
||||||
|
|
||||||
|
export enum ObjectTypeDefinitionKind {
|
||||||
|
Connection = 'Connection',
|
||||||
|
Edge = 'Edge',
|
||||||
|
Plain = '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectTypeDefinition {
|
||||||
|
target: string;
|
||||||
|
kind: ObjectTypeDefinitionKind;
|
||||||
|
type: GraphQLObjectType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExtendObjectTypeDefinitionFactory {
|
||||||
|
private readonly logger = new Logger(ExtendObjectTypeDefinitionFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly relationTypeFactory: RelationTypeFactory,
|
||||||
|
private readonly argsFactory: ArgsFactory,
|
||||||
|
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public create(
|
||||||
|
objectMetadata: ObjectMetadataInterface,
|
||||||
|
options: BuildSchemaOptions,
|
||||||
|
): ObjectTypeDefinition {
|
||||||
|
const kind = ObjectTypeDefinitionKind.Plain;
|
||||||
|
const gqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
|
||||||
|
objectMetadata.id,
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
const containsCompositeField = objectContainsCompositeField(objectMetadata);
|
||||||
|
|
||||||
|
if (!gqlType) {
|
||||||
|
this.logger.error(
|
||||||
|
`Could not find a GraphQL type for ${objectMetadata.id.toString()}`,
|
||||||
|
{
|
||||||
|
objectMetadata,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Could not find a GraphQL type for ${objectMetadata.id.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check to avoid extending an object that does not need to be extended
|
||||||
|
if (!containsCompositeField) {
|
||||||
|
this.logger.error(
|
||||||
|
`This object does not need to be extended: ${objectMetadata.id.toString()}`,
|
||||||
|
{
|
||||||
|
objectMetadata,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`This object does not need to be extended: ${objectMetadata.id.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract current object config to extend it
|
||||||
|
const config = gqlType.toConfig();
|
||||||
|
|
||||||
|
// Recreate the same object type with the new fields
|
||||||
|
return {
|
||||||
|
target: objectMetadata.id,
|
||||||
|
kind,
|
||||||
|
type: new GraphQLObjectType({
|
||||||
|
...config,
|
||||||
|
fields: () => ({
|
||||||
|
...config.fields,
|
||||||
|
...this.generateFields(objectMetadata, options),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateFields(
|
||||||
|
objectMetadata: ObjectMetadataInterface,
|
||||||
|
options: BuildSchemaOptions,
|
||||||
|
): GraphQLFieldConfigMap<any, any> {
|
||||||
|
const fields: GraphQLFieldConfigMap<any, any> = {};
|
||||||
|
|
||||||
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
|
// Ignore non composite fields as they are already defined
|
||||||
|
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (fieldMetadata.type) {
|
||||||
|
case FieldMetadataType.RELATION: {
|
||||||
|
const relationMetadata =
|
||||||
|
fieldMetadata.fromRelationMetadata ??
|
||||||
|
fieldMetadata.toRelationMetadata;
|
||||||
|
|
||||||
|
if (!relationMetadata) {
|
||||||
|
this.logger.error(
|
||||||
|
`Could not find a relation metadata for ${fieldMetadata.id}`,
|
||||||
|
{
|
||||||
|
fieldMetadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Could not find a relation metadata for ${fieldMetadata.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationDirection = deduceRelationDirection(
|
||||||
|
fieldMetadata.objectId,
|
||||||
|
relationMetadata,
|
||||||
|
);
|
||||||
|
const relationType = this.relationTypeFactory.create(
|
||||||
|
fieldMetadata,
|
||||||
|
relationMetadata,
|
||||||
|
relationDirection,
|
||||||
|
);
|
||||||
|
let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined;
|
||||||
|
|
||||||
|
// Args are only needed when relation is of kind `oneToMany` and the relation direction is `from`
|
||||||
|
if (
|
||||||
|
relationMetadata.relationType ===
|
||||||
|
RelationMetadataType.ONE_TO_MANY &&
|
||||||
|
relationDirection === RelationDirection.FROM
|
||||||
|
) {
|
||||||
|
const args = getResolverArgs('findMany');
|
||||||
|
|
||||||
|
argsType = this.argsFactory.create(
|
||||||
|
{
|
||||||
|
args,
|
||||||
|
objectMetadata: relationMetadata.toObjectMetadata,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields[fieldMetadata.name] = {
|
||||||
|
type: relationType,
|
||||||
|
args: argsType,
|
||||||
|
description: fieldMetadata.description,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,9 @@ import { EdgeTypeDefinitionFactory } from './edge-type-definition.factory';
|
|||||||
import { MutationTypeFactory } from './mutation-type.factory';
|
import { MutationTypeFactory } from './mutation-type.factory';
|
||||||
import { OrderByTypeFactory } from './order-by-type.factory';
|
import { OrderByTypeFactory } from './order-by-type.factory';
|
||||||
import { OrderByTypeDefinitionFactory } from './order-by-type-definition.factory';
|
import { OrderByTypeDefinitionFactory } from './order-by-type-definition.factory';
|
||||||
|
import { RelationTypeFactory } from './relation-type.factory';
|
||||||
|
import { ExtendObjectTypeDefinitionFactory } from './extend-object-type-definition.factory';
|
||||||
|
import { OrphanedTypesFactory } from './orphaned-types.factory';
|
||||||
|
|
||||||
export const schemaBuilderFactories = [
|
export const schemaBuilderFactories = [
|
||||||
ArgsFactory,
|
ArgsFactory,
|
||||||
@ -21,6 +24,8 @@ export const schemaBuilderFactories = [
|
|||||||
InputTypeDefinitionFactory,
|
InputTypeDefinitionFactory,
|
||||||
OutputTypeFactory,
|
OutputTypeFactory,
|
||||||
ObjectTypeDefinitionFactory,
|
ObjectTypeDefinitionFactory,
|
||||||
|
RelationTypeFactory,
|
||||||
|
ExtendObjectTypeDefinitionFactory,
|
||||||
FilterTypeFactory,
|
FilterTypeFactory,
|
||||||
FilterTypeDefinitionFactory,
|
FilterTypeDefinitionFactory,
|
||||||
OrderByTypeFactory,
|
OrderByTypeFactory,
|
||||||
@ -32,4 +37,5 @@ export const schemaBuilderFactories = [
|
|||||||
RootTypeFactory,
|
RootTypeFactory,
|
||||||
QueryTypeFactory,
|
QueryTypeFactory,
|
||||||
MutationTypeFactory,
|
MutationTypeFactory,
|
||||||
|
OrphanedTypesFactory,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-s
|
|||||||
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
import { pascalCase } from 'src/utils/pascal-case';
|
import { pascalCase } from 'src/utils/pascal-case';
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
|
||||||
import { TypeMapperService } from 'src/tenant/schema-builder/services/type-mapper.service';
|
import { TypeMapperService } from 'src/tenant/schema-builder/services/type-mapper.service';
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
import { FilterTypeFactory } from './filter-type.factory';
|
import { FilterTypeFactory } from './filter-type.factory';
|
||||||
import {
|
import {
|
||||||
@ -67,7 +67,12 @@ export class FilterTypeDefinitionFactory {
|
|||||||
): GraphQLInputFieldConfigMap {
|
): GraphQLInputFieldConfigMap {
|
||||||
const fields: GraphQLInputFieldConfigMap = {};
|
const fields: GraphQLInputFieldConfigMap = {};
|
||||||
|
|
||||||
objectMetadata.fields.forEach((fieldMetadata: FieldMetadata) => {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
|
// Composite field types are generated during extensin of object type definition
|
||||||
|
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const type = this.filterTypeFactory.create(fieldMetadata, options, {
|
const type = this.filterTypeFactory.create(fieldMetadata, options, {
|
||||||
nullable: fieldMetadata.isNullable,
|
nullable: fieldMetadata.isNullable,
|
||||||
});
|
});
|
||||||
@ -78,7 +83,7 @@ export class FilterTypeDefinitionFactory {
|
|||||||
// TODO: Add default value
|
// TODO: Add default value
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-s
|
|||||||
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
import { pascalCase } from 'src/utils/pascal-case';
|
import { pascalCase } from 'src/utils/pascal-case';
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
import { InputTypeFactory } from './input-type.factory';
|
import { InputTypeFactory } from './input-type.factory';
|
||||||
|
|
||||||
@ -52,7 +52,12 @@ export class InputTypeDefinitionFactory {
|
|||||||
): GraphQLInputFieldConfigMap {
|
): GraphQLInputFieldConfigMap {
|
||||||
const fields: GraphQLInputFieldConfigMap = {};
|
const fields: GraphQLInputFieldConfigMap = {};
|
||||||
|
|
||||||
objectMetadata.fields.forEach((fieldMetadata: FieldMetadata) => {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
|
// Composite field types are generated during extensin of object type definition
|
||||||
|
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const type = this.inputTypeFactory.create(fieldMetadata, kind, options, {
|
const type = this.inputTypeFactory.create(fieldMetadata, kind, options, {
|
||||||
nullable: fieldMetadata.isNullable,
|
nullable: fieldMetadata.isNullable,
|
||||||
});
|
});
|
||||||
@ -63,7 +68,7 @@ export class InputTypeDefinitionFactory {
|
|||||||
// TODO: Add default value
|
// TODO: Add default value
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
|
|||||||
import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-schema-optionts.interface';
|
import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-schema-optionts.interface';
|
||||||
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
|
||||||
import { pascalCase } from 'src/utils/pascal-case';
|
import { pascalCase } from 'src/utils/pascal-case';
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
import { OutputTypeFactory } from './output-type.factory';
|
import { OutputTypeFactory } from './output-type.factory';
|
||||||
|
|
||||||
@ -49,7 +49,12 @@ export class ObjectTypeDefinitionFactory {
|
|||||||
): GraphQLFieldConfigMap<any, any> {
|
): GraphQLFieldConfigMap<any, any> {
|
||||||
const fields: GraphQLFieldConfigMap<any, any> = {};
|
const fields: GraphQLFieldConfigMap<any, any> = {};
|
||||||
|
|
||||||
objectMetadata.fields.forEach((fieldMetadata: FieldMetadata) => {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
|
// Composite field types are generated during extensin of object type definition
|
||||||
|
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const type = this.outputTypeFactory.create(fieldMetadata, kind, options, {
|
const type = this.outputTypeFactory.create(fieldMetadata, kind, options, {
|
||||||
nullable: fieldMetadata.isNullable,
|
nullable: fieldMetadata.isNullable,
|
||||||
});
|
});
|
||||||
@ -58,7 +63,7 @@ export class ObjectTypeDefinitionFactory {
|
|||||||
type,
|
type,
|
||||||
description: fieldMetadata.description,
|
description: fieldMetadata.description,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { BuildSchemaOptions } from 'src/tenant/schema-builder/interfaces/build-s
|
|||||||
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
import { pascalCase } from 'src/utils/pascal-case';
|
import { pascalCase } from 'src/utils/pascal-case';
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InputTypeDefinition,
|
InputTypeDefinition,
|
||||||
@ -43,7 +43,12 @@ export class OrderByTypeDefinitionFactory {
|
|||||||
): GraphQLInputFieldConfigMap {
|
): GraphQLInputFieldConfigMap {
|
||||||
const fields: GraphQLInputFieldConfigMap = {};
|
const fields: GraphQLInputFieldConfigMap = {};
|
||||||
|
|
||||||
objectMetadata.fields.forEach((fieldMetadata: FieldMetadata) => {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
|
// Composite field types are generated during extensin of object type definition
|
||||||
|
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const type = this.orderByTypeFactory.create(fieldMetadata, options, {
|
const type = this.orderByTypeFactory.create(fieldMetadata, options, {
|
||||||
nullable: fieldMetadata.isNullable,
|
nullable: fieldMetadata.isNullable,
|
||||||
});
|
});
|
||||||
@ -54,7 +59,7 @@ export class OrderByTypeDefinitionFactory {
|
|||||||
// TODO: Add default value
|
// TODO: Add default value
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GraphQLNamedType } from 'graphql';
|
||||||
|
|
||||||
|
import { TypeDefinitionsStorage } from 'src/tenant/schema-builder/storages/type-definitions.storage';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrphanedTypesFactory {
|
||||||
|
constructor(
|
||||||
|
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public create(): GraphQLNamedType[] {
|
||||||
|
const objectTypeDefs =
|
||||||
|
this.typeDefinitionsStorage.getAllObjectTypeDefinitions();
|
||||||
|
const inputTypeDefs =
|
||||||
|
this.typeDefinitionsStorage.getAllInputTypeDefinitions();
|
||||||
|
const classTypeDefs = [...objectTypeDefs, ...inputTypeDefs];
|
||||||
|
|
||||||
|
return [...classTypeDefs.map(({ type }) => type)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GraphQLOutputType } from 'graphql';
|
||||||
|
|
||||||
|
import { FieldMetadataInterface } from 'src/tenant/schema-builder/interfaces/field-metadata.interface';
|
||||||
|
import { RelationMetadataInterface } from 'src/tenant/schema-builder/interfaces/relation-metadata.interface';
|
||||||
|
|
||||||
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
import { TypeDefinitionsStorage } from 'src/tenant/schema-builder/storages/type-definitions.storage';
|
||||||
|
import { RelationDirection } from 'src/tenant/utils/deduce-relation-direction.util';
|
||||||
|
|
||||||
|
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RelationTypeFactory {
|
||||||
|
private readonly logger = new Logger(RelationTypeFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public create(
|
||||||
|
fieldMetadata: FieldMetadataInterface,
|
||||||
|
relationMetadata: RelationMetadataInterface,
|
||||||
|
relationDirection: RelationDirection,
|
||||||
|
): GraphQLOutputType {
|
||||||
|
let relationQqlType: GraphQLOutputType | undefined = undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
relationDirection === RelationDirection.FROM &&
|
||||||
|
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY
|
||||||
|
) {
|
||||||
|
relationQqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
|
||||||
|
relationMetadata.toObjectMetadataId,
|
||||||
|
ObjectTypeDefinitionKind.Connection,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const relationObjectId =
|
||||||
|
relationDirection === RelationDirection.FROM
|
||||||
|
? relationMetadata.toObjectMetadataId
|
||||||
|
: relationMetadata.fromObjectMetadataId;
|
||||||
|
|
||||||
|
relationQqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
|
||||||
|
relationObjectId,
|
||||||
|
ObjectTypeDefinitionKind.Plain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationQqlType) {
|
||||||
|
this.logger.error(
|
||||||
|
`Could not find a relation type for ${fieldMetadata.id}`,
|
||||||
|
{
|
||||||
|
fieldMetadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(`Could not find a relation type for ${fieldMetadata.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relationQqlType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,24 +4,23 @@ import { GraphQLSchema } from 'graphql';
|
|||||||
|
|
||||||
import { ResolverBuilderMethods } from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
|
import { ResolverBuilderMethods } from 'src/tenant/resolver-builder/interfaces/resolvers-builder.interface';
|
||||||
|
|
||||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
|
||||||
|
|
||||||
import { TypeDefinitionsGenerator } from './type-definitions.generator';
|
import { TypeDefinitionsGenerator } from './type-definitions.generator';
|
||||||
|
|
||||||
import { BuildSchemaOptions } from './interfaces/build-schema-optionts.interface';
|
import { BuildSchemaOptions } from './interfaces/build-schema-optionts.interface';
|
||||||
import { QueryTypeFactory } from './factories/query-type.factory';
|
import { QueryTypeFactory } from './factories/query-type.factory';
|
||||||
import { MutationTypeFactory } from './factories/mutation-type.factory';
|
import { MutationTypeFactory } from './factories/mutation-type.factory';
|
||||||
import { ObjectMetadataInterface } from './interfaces/object-metadata.interface';
|
import { ObjectMetadataInterface } from './interfaces/object-metadata.interface';
|
||||||
|
import { OrphanedTypesFactory } from './factories/orphaned-types.factory';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GraphQLSchemaFactory {
|
export class GraphQLSchemaFactory {
|
||||||
private readonly logger = new Logger(GraphQLSchemaFactory.name);
|
private readonly logger = new Logger(GraphQLSchemaFactory.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly objectMetadataService: ObjectMetadataService,
|
|
||||||
private readonly typeDefinitionsGenerator: TypeDefinitionsGenerator,
|
private readonly typeDefinitionsGenerator: TypeDefinitionsGenerator,
|
||||||
private readonly queryTypeFactory: QueryTypeFactory,
|
private readonly queryTypeFactory: QueryTypeFactory,
|
||||||
private readonly mutationTypeFactory: MutationTypeFactory,
|
private readonly mutationTypeFactory: MutationTypeFactory,
|
||||||
|
private readonly orphanedTypesFactory: OrphanedTypesFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@ -44,6 +43,7 @@ export class GraphQLSchemaFactory {
|
|||||||
[...resolverBuilderMethods.mutations],
|
[...resolverBuilderMethods.mutations],
|
||||||
options,
|
options,
|
||||||
),
|
),
|
||||||
|
types: this.orphanedTypesFactory.create(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return schema;
|
return schema;
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { GraphQLBoolean, GraphQLInputObjectType } from 'graphql';
|
||||||
|
|
||||||
|
export const BooleanFilterType = new GraphQLInputObjectType({
|
||||||
|
name: 'BooleanFilter',
|
||||||
|
fields: {
|
||||||
|
eq: { type: GraphQLBoolean },
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -7,3 +7,4 @@ export * from './int-filter.input-type';
|
|||||||
export * from './string-filter.input-type';
|
export * from './string-filter.input-type';
|
||||||
export * from './time-filter.input-type';
|
export * from './time-filter.input-type';
|
||||||
export * from './uuid-filter.input-type';
|
export * from './uuid-filter.input-type';
|
||||||
|
export * from './boolean-filter.input-type';
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
|
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
|
||||||
|
|
||||||
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
import { RelationMetadata } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
|
||||||
export interface FieldMetadataInterface<
|
export interface FieldMetadataInterface<
|
||||||
T extends FieldMetadataType | 'default' = 'default',
|
T extends FieldMetadataType | 'default' = 'default',
|
||||||
@ -10,6 +11,9 @@ export interface FieldMetadataInterface<
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
targetColumnMap: FieldMetadataTargetColumnMap<T>;
|
targetColumnMap: FieldMetadataTargetColumnMap<T>;
|
||||||
|
objectId: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isNullable?: boolean;
|
isNullable?: boolean;
|
||||||
|
fromRelationMetadata?: RelationMetadata;
|
||||||
|
toRelationMetadata?: RelationMetadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { FieldMetadataInterface } from './field-metadata.interface';
|
import { FieldMetadataInterface } from './field-metadata.interface';
|
||||||
|
import { RelationMetadataInterface } from './relation-metadata.interface';
|
||||||
|
|
||||||
export interface ObjectMetadataInterface {
|
export interface ObjectMetadataInterface {
|
||||||
id: string;
|
id: string;
|
||||||
@ -8,5 +9,7 @@ export interface ObjectMetadataInterface {
|
|||||||
labelPlural: string;
|
labelPlural: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
targetTableName: string;
|
targetTableName: string;
|
||||||
|
fromRelations: RelationMetadataInterface[];
|
||||||
|
toRelations: RelationMetadataInterface[];
|
||||||
fields: FieldMetadataInterface[];
|
fields: FieldMetadataInterface[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
|
||||||
|
import { ObjectMetadataInterface } from './object-metadata.interface';
|
||||||
|
import { FieldMetadataInterface } from './field-metadata.interface';
|
||||||
|
|
||||||
|
export interface RelationMetadataInterface {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
relationType: RelationMetadataType;
|
||||||
|
|
||||||
|
fromObjectMetadataId: string;
|
||||||
|
fromObjectMetadata: ObjectMetadataInterface;
|
||||||
|
|
||||||
|
toObjectMetadataId: string;
|
||||||
|
toObjectMetadata: ObjectMetadataInterface;
|
||||||
|
|
||||||
|
fromFieldMetadataId: string;
|
||||||
|
fromFieldMetadata: FieldMetadataInterface;
|
||||||
|
|
||||||
|
toFieldMetadataId: string;
|
||||||
|
toFieldMetadata: FieldMetadataInterface;
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export const moneyObjectDefinition = {
|
|||||||
{
|
{
|
||||||
id: 'amount',
|
id: 'amount',
|
||||||
type: FieldMetadataType.NUMBER,
|
type: FieldMetadataType.NUMBER,
|
||||||
|
objectId: FieldMetadataType.MONEY.toString(),
|
||||||
name: 'amount',
|
name: 'amount',
|
||||||
label: 'Amount',
|
label: 'Amount',
|
||||||
targetColumnMap: { value: 'amount' },
|
targetColumnMap: { value: 'amount' },
|
||||||
@ -21,9 +22,12 @@ export const moneyObjectDefinition = {
|
|||||||
{
|
{
|
||||||
id: 'currency',
|
id: 'currency',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
|
objectId: FieldMetadataType.MONEY.toString(),
|
||||||
name: 'currency',
|
name: 'currency',
|
||||||
label: 'Currency',
|
label: 'Currency',
|
||||||
targetColumnMap: { value: 'currency' },
|
targetColumnMap: { value: 'currency' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
fromRelations: [],
|
||||||
|
toRelations: [],
|
||||||
} as ObjectMetadataInterface;
|
} as ObjectMetadataInterface;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const urlObjectDefinition = {
|
|||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
|
objectId: FieldMetadataType.URL.toString(),
|
||||||
name: 'text',
|
name: 'text',
|
||||||
label: 'Text',
|
label: 'Text',
|
||||||
targetColumnMap: { value: 'text' },
|
targetColumnMap: { value: 'text' },
|
||||||
@ -20,9 +21,12 @@ export const urlObjectDefinition = {
|
|||||||
{
|
{
|
||||||
id: 'link',
|
id: 'link',
|
||||||
type: FieldMetadataType.TEXT,
|
type: FieldMetadataType.TEXT,
|
||||||
|
objectId: FieldMetadataType.URL.toString(),
|
||||||
name: 'link',
|
name: 'link',
|
||||||
label: 'Link',
|
label: 'Link',
|
||||||
targetColumnMap: { value: 'link' },
|
targetColumnMap: { value: 'link' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
fromRelations: [],
|
||||||
|
toRelations: [],
|
||||||
} as ObjectMetadataInterface;
|
} as ObjectMetadataInterface;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
DateFilterType,
|
DateFilterType,
|
||||||
FloatFilterType,
|
FloatFilterType,
|
||||||
IntFilterType,
|
IntFilterType,
|
||||||
|
BooleanFilterType,
|
||||||
} from 'src/tenant/schema-builder/graphql-types/input';
|
} from 'src/tenant/schema-builder/graphql-types/input';
|
||||||
import { OrderByDirectionType } from 'src/tenant/schema-builder/graphql-types/enum';
|
import { OrderByDirectionType } from 'src/tenant/schema-builder/graphql-types/enum';
|
||||||
|
|
||||||
@ -85,7 +86,7 @@ export class TypeMapperService {
|
|||||||
[FieldMetadataType.PHONE, StringFilterType],
|
[FieldMetadataType.PHONE, StringFilterType],
|
||||||
[FieldMetadataType.EMAIL, StringFilterType],
|
[FieldMetadataType.EMAIL, StringFilterType],
|
||||||
[FieldMetadataType.DATE, dateFilter],
|
[FieldMetadataType.DATE, dateFilter],
|
||||||
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
|
[FieldMetadataType.BOOLEAN, BooleanFilterType],
|
||||||
[FieldMetadataType.NUMBER, numberScalar],
|
[FieldMetadataType.NUMBER, numberScalar],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import { FilterTypeDefinitionFactory } from './factories/filter-type-definition.
|
|||||||
import { ConnectionTypeDefinitionFactory } from './factories/connection-type-definition.factory';
|
import { ConnectionTypeDefinitionFactory } from './factories/connection-type-definition.factory';
|
||||||
import { EdgeTypeDefinitionFactory } from './factories/edge-type-definition.factory';
|
import { EdgeTypeDefinitionFactory } from './factories/edge-type-definition.factory';
|
||||||
import { OrderByTypeDefinitionFactory } from './factories/order-by-type-definition.factory';
|
import { OrderByTypeDefinitionFactory } from './factories/order-by-type-definition.factory';
|
||||||
|
import { ExtendObjectTypeDefinitionFactory } from './factories/extend-object-type-definition.factory';
|
||||||
|
import { objectContainsCompositeField } from './utils/object-contains-composite-field';
|
||||||
|
|
||||||
// Create a default field for each custom table default column
|
// Create a default field for each custom table default column
|
||||||
const defaultFields = customTableDefaultColumns.map((column) => {
|
const defaultFields = customTableDefaultColumns.map((column) => {
|
||||||
@ -44,6 +46,7 @@ export class TypeDefinitionsGenerator {
|
|||||||
private readonly orderByTypeDefinitionFactory: OrderByTypeDefinitionFactory,
|
private readonly orderByTypeDefinitionFactory: OrderByTypeDefinitionFactory,
|
||||||
private readonly edgeTypeDefinitionFactory: EdgeTypeDefinitionFactory,
|
private readonly edgeTypeDefinitionFactory: EdgeTypeDefinitionFactory,
|
||||||
private readonly connectionTypeDefinitionFactory: ConnectionTypeDefinitionFactory,
|
private readonly connectionTypeDefinitionFactory: ConnectionTypeDefinitionFactory,
|
||||||
|
private readonly extendObjectTypeDefinitionFactory: ExtendObjectTypeDefinitionFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
generate(
|
generate(
|
||||||
@ -87,6 +90,10 @@ export class TypeDefinitionsGenerator {
|
|||||||
this.generateObjectTypeDefs(dynamicObjectMetadataCollection, options);
|
this.generateObjectTypeDefs(dynamicObjectMetadataCollection, options);
|
||||||
this.generatePaginationTypeDefs(dynamicObjectMetadataCollection, options);
|
this.generatePaginationTypeDefs(dynamicObjectMetadataCollection, options);
|
||||||
this.generateInputTypeDefs(dynamicObjectMetadataCollection, options);
|
this.generateInputTypeDefs(dynamicObjectMetadataCollection, options);
|
||||||
|
this.generateExtendedObjectTypeDefs(
|
||||||
|
dynamicObjectMetadataCollection,
|
||||||
|
options,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateObjectTypeDefs(
|
private generateObjectTypeDefs(
|
||||||
@ -194,6 +201,21 @@ export class TypeDefinitionsGenerator {
|
|||||||
this.typeDefinitionsStorage.addInputTypes(inputTypeDefs);
|
this.typeDefinitionsStorage.addInputTypes(inputTypeDefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateExtendedObjectTypeDefs(
|
||||||
|
objectMetadataCollection: ObjectMetadataInterface[],
|
||||||
|
options: BuildSchemaOptions,
|
||||||
|
) {
|
||||||
|
// Generate extended object type defs only for objects that contain composite fields
|
||||||
|
const objectMetadataCollectionWithCompositeFields =
|
||||||
|
objectMetadataCollection.filter(objectContainsCompositeField);
|
||||||
|
const objectTypeDefs = objectMetadataCollectionWithCompositeFields.map(
|
||||||
|
(objectMetadata) =>
|
||||||
|
this.extendObjectTypeDefinitionFactory.create(objectMetadata, options),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typeDefinitionsStorage.addObjectTypes(objectTypeDefs);
|
||||||
|
}
|
||||||
|
|
||||||
private mergeFieldsWithDefaults(
|
private mergeFieldsWithDefaults(
|
||||||
fields: FieldMetadataInterface[],
|
fields: FieldMetadataInterface[],
|
||||||
): FieldMetadataInterface[] {
|
): FieldMetadataInterface[] {
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { ObjectMetadataInterface } from 'src/tenant/schema-builder/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
|
import { isCompositeFieldMetadataType } from 'src/tenant/utils/is-composite-field-metadata-type.util';
|
||||||
|
|
||||||
|
export const objectContainsCompositeField = (
|
||||||
|
objectMetadata: ObjectMetadataInterface,
|
||||||
|
): boolean => {
|
||||||
|
return objectMetadata.fields.some((field) =>
|
||||||
|
isCompositeFieldMetadataType(field.type),
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,9 +12,9 @@ import { ResolverBuilderModule } from './resolver-builder/resolver-builder.modul
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MetadataModule,
|
MetadataModule,
|
||||||
SchemaBuilderModule,
|
|
||||||
DataSourceMetadataModule,
|
DataSourceMetadataModule,
|
||||||
ObjectMetadataModule,
|
ObjectMetadataModule,
|
||||||
|
SchemaBuilderModule,
|
||||||
ResolverBuilderModule,
|
ResolverBuilderModule,
|
||||||
],
|
],
|
||||||
providers: [TenantService],
|
providers: [TenantService],
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
import { RelationMetadataInterface } from 'src/tenant/schema-builder/interfaces/relation-metadata.interface';
|
||||||
|
|
||||||
|
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
|
||||||
|
import {
|
||||||
|
deduceRelationDirection,
|
||||||
|
RelationDirection,
|
||||||
|
} from 'src/tenant/utils/deduce-relation-direction.util';
|
||||||
|
|
||||||
|
describe('deduceRelationDirection', () => {
|
||||||
|
it('should return FROM when the current object ID matches fromObjectMetadataId', () => {
|
||||||
|
const currentObjectId = 'from_object_id';
|
||||||
|
const relationMetadata = {
|
||||||
|
id: 'relation_id',
|
||||||
|
fromObjectMetadataId: currentObjectId,
|
||||||
|
toObjectMetadataId: 'to_object_id',
|
||||||
|
fromFieldMetadataId: 'from_field_id',
|
||||||
|
toFieldMetadataId: 'to_field_id',
|
||||||
|
relationType: RelationMetadataType.ONE_TO_ONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deduceRelationDirection(
|
||||||
|
currentObjectId,
|
||||||
|
relationMetadata as RelationMetadataInterface,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(RelationDirection.FROM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return TO when the current object ID matches toObjectMetadataId', () => {
|
||||||
|
// Arrange
|
||||||
|
const currentObjectId = 'to_object_id';
|
||||||
|
const relationMetadata = {
|
||||||
|
id: 'relation_id',
|
||||||
|
fromObjectMetadataId: 'from_object_id',
|
||||||
|
toObjectMetadataId: currentObjectId,
|
||||||
|
fromFieldMetadataId: 'from_field_id',
|
||||||
|
toFieldMetadataId: 'to_field_id',
|
||||||
|
relationType: RelationMetadataType.ONE_TO_ONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deduceRelationDirection(
|
||||||
|
currentObjectId,
|
||||||
|
relationMetadata as RelationMetadataInterface,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(RelationDirection.TO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error when the current object ID does not match any object metadata ID', () => {
|
||||||
|
const currentObjectId = 'unrelated_object_id';
|
||||||
|
const relationMetadata = {
|
||||||
|
id: 'relation_id',
|
||||||
|
fromObjectMetadataId: 'from_object_id',
|
||||||
|
toObjectMetadataId: 'to_object_id',
|
||||||
|
fromFieldMetadataId: 'from_field_id',
|
||||||
|
toFieldMetadataId: 'to_field_id',
|
||||||
|
relationType: RelationMetadataType.ONE_TO_ONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
deduceRelationDirection(
|
||||||
|
currentObjectId,
|
||||||
|
relationMetadata as RelationMetadataInterface,
|
||||||
|
),
|
||||||
|
).toThrow(
|
||||||
|
`Relation metadata ${relationMetadata.id} is not related to object ${currentObjectId}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
server/src/tenant/utils/deduce-relation-direction.util.ts
Normal file
23
server/src/tenant/utils/deduce-relation-direction.util.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { RelationMetadataInterface } from 'src/tenant/schema-builder/interfaces/relation-metadata.interface';
|
||||||
|
|
||||||
|
export enum RelationDirection {
|
||||||
|
FROM = 'from',
|
||||||
|
TO = 'to',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deduceRelationDirection = (
|
||||||
|
currentObjectId: string,
|
||||||
|
relationMetadata: RelationMetadataInterface,
|
||||||
|
): RelationDirection => {
|
||||||
|
if (relationMetadata.fromObjectMetadataId === currentObjectId) {
|
||||||
|
return RelationDirection.FROM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationMetadata.toObjectMetadataId === currentObjectId) {
|
||||||
|
return RelationDirection.TO;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Relation metadata ${relationMetadata.id} is not related to object ${currentObjectId}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
|
||||||
|
export const isCompositeFieldMetadataType = (type: FieldMetadataType) => {
|
||||||
|
return type === FieldMetadataType.RELATION;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user