diff --git a/server/package.json b/server/package.json index a812e9290..d3adbf20a 100644 --- a/server/package.json +++ b/server/package.json @@ -82,6 +82,7 @@ "lodash.kebabcase": "^4.1.1", "lodash.merge": "^4.6.2", "lodash.snakecase": "^4.1.1", + "lodash.upperfirst": "^4.3.1", "ms": "^2.1.3", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", @@ -115,6 +116,7 @@ "@types/lodash.isobject": "^3.0.7", "@types/lodash.kebabcase": "^4.1.7", "@types/lodash.snakecase": "^4.1.7", + "@types/lodash.upperfirst": "^4.3.7", "@types/ms": "^0.7.31", "@types/node": "^16.0.0", "@types/passport-google-oauth20": "^2.0.11", diff --git a/server/src/metadata/data-source-metadata/data-source-metadata.entity.ts b/server/src/metadata/data-source-metadata/data-source-metadata.entity.ts index d8ce822b1..0af0aa0ed 100644 --- a/server/src/metadata/data-source-metadata/data-source-metadata.entity.ts +++ b/server/src/metadata/data-source-metadata/data-source-metadata.entity.ts @@ -23,8 +23,8 @@ export class DataSourceMetadata { @Column({ type: 'enum', enum: ['postgres'], default: 'postgres' }) type: DataSourceType; - @Column({ nullable: true, name: 'display_name' }) - displayName: string; + @Column({ nullable: true, name: 'label' }) + label: string; @Column({ default: false, name: 'is_remote' }) isRemote: boolean; diff --git a/server/src/metadata/field-metadata/dtos/create-field.input.ts b/server/src/metadata/field-metadata/dtos/create-field.input.ts index 7b2eb6683..ddca1427c 100644 --- a/server/src/metadata/field-metadata/dtos/create-field.input.ts +++ b/server/src/metadata/field-metadata/dtos/create-field.input.ts @@ -13,7 +13,22 @@ export class CreateFieldInput { @IsString() @IsNotEmpty() @Field() - displayName: string; + nameSingular: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + namePlural?: string; + + @IsString() + @IsNotEmpty() + @Field() + labelSingular: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + labelPlural?: string; // Todo: use a type enum and share with typeorm entity @IsEnum([ diff --git a/server/src/metadata/field-metadata/dtos/update-field.input.ts b/server/src/metadata/field-metadata/dtos/update-field.input.ts index 03e892140..2c9a912ac 100644 --- a/server/src/metadata/field-metadata/dtos/update-field.input.ts +++ b/server/src/metadata/field-metadata/dtos/update-field.input.ts @@ -7,7 +7,22 @@ export class UpdateFieldInput { @IsString() @IsOptional() @Field({ nullable: true }) - displayName: string; + nameSingular?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + namePlural?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + labelSingular?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + labelPlural?: string; @IsString() @IsOptional() diff --git a/server/src/metadata/field-metadata/field-metadata.entity.ts b/server/src/metadata/field-metadata/field-metadata.entity.ts index 3f48b2cd5..afeda82d1 100644 --- a/server/src/metadata/field-metadata/field-metadata.entity.ts +++ b/server/src/metadata/field-metadata/field-metadata.entity.ts @@ -50,11 +50,23 @@ export class FieldMetadata { type: string; @Field() - @Column({ nullable: false, name: 'display_name' }) - displayName: string; + @Column({ nullable: false, name: 'name_singular' }) + nameSingular: string; - @Column({ nullable: false, name: 'target_column_name' }) - targetColumnName: string; + @Field() + @Column({ nullable: true, name: 'name_plural' }) + namePlural: string; + + @Field() + @Column({ nullable: false, name: 'label_singular' }) + labelSingular: string; + + @Field() + @Column({ nullable: true, name: 'label_plural' }) + labelPlural: string; + + @Column({ nullable: false, name: 'target_column_map', type: 'jsonb' }) + targetColumnMap: FieldMetadataTargetColumnMap; @Field({ nullable: true }) @Column({ nullable: true, name: 'description', type: 'text' }) @@ -68,9 +80,6 @@ export class FieldMetadata { @Column({ nullable: true, name: 'placeholder' }) placeholder: string; - @Column({ nullable: true, name: 'target_column_map', type: 'jsonb' }) - targetColumnMap: FieldMetadataTargetColumnMap; - @Column('text', { nullable: true, array: true }) enums: string[]; diff --git a/server/src/metadata/field-metadata/services/field-metadata.service.ts b/server/src/metadata/field-metadata/services/field-metadata.service.ts index 0b5d5465d..c7a2aadf9 100644 --- a/server/src/metadata/field-metadata/services/field-metadata.service.ts +++ b/server/src/metadata/field-metadata/services/field-metadata.service.ts @@ -11,17 +11,12 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; import { convertFieldMetadataToColumnChanges, - convertMetadataTypeToColumnType, - generateColumnName, generateTargetColumnMap, } from 'src/metadata/field-metadata/utils/field-metadata.util'; import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service'; import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service'; import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service'; -import { - TenantMigrationColumnChange, - TenantMigrationTableChange, -} from 'src/metadata/tenant-migration/tenant-migration.entity'; +import { TenantMigrationTableChange } from 'src/metadata/tenant-migration/tenant-migration.entity'; @Injectable() export class FieldMetadataService extends TypeOrmQueryService { @@ -49,7 +44,8 @@ export class FieldMetadataService extends TypeOrmQueryService { const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({ where: { - displayName: record.displayName, + nameSingular: record.nameSingular, + namePlural: record.namePlural, objectId: record.objectId, workspaceId: record.workspaceId, }, @@ -61,7 +57,6 @@ export class FieldMetadataService extends TypeOrmQueryService { const createdFieldMetadata = await super.createOne({ ...record, - targetColumnName: generateColumnName(record.displayName), // deprecated targetColumnMap: generateTargetColumnMap(record.type), }); @@ -69,15 +64,7 @@ export class FieldMetadataService extends TypeOrmQueryService { { name: objectMetadata.targetTableName, change: 'alter', - columns: [ - ...convertFieldMetadataToColumnChanges(createdFieldMetadata), - // Deprecated - { - name: createdFieldMetadata.targetColumnName, - type: convertMetadataTypeToColumnType(record.type), - change: 'create', - } satisfies TenantMigrationColumnChange, - ], + columns: convertFieldMetadataToColumnChanges(createdFieldMetadata), } satisfies TenantMigrationTableChange, ]); @@ -87,13 +74,4 @@ export class FieldMetadataService extends TypeOrmQueryService { return createdFieldMetadata; } - - public async getFieldMetadataByDisplayNameAndObjectId( - name: string, - objectId: string, - ): Promise { - return await this.fieldMetadataRepository.findOne({ - where: { displayName: name, objectId }, - }); - } } diff --git a/server/src/metadata/field-metadata/utils/field-metadata.util.ts b/server/src/metadata/field-metadata/utils/field-metadata.util.ts index 1d4c0d1cf..65e0c4899 100644 --- a/server/src/metadata/field-metadata/utils/field-metadata.util.ts +++ b/server/src/metadata/field-metadata/utils/field-metadata.util.ts @@ -35,17 +35,17 @@ export function generateTargetColumnMap( case 'boolean': case 'date': return { - value: uuidToBase36(v4()), + value: `column_${uuidToBase36(v4())}`, }; case 'url': return { - text: uuidToBase36(v4()), - link: uuidToBase36(v4()), + text: `column_${uuidToBase36(v4())}`, + link: `column_${uuidToBase36(v4())}`, }; case 'money': return { - amount: uuidToBase36(v4()), - currency: uuidToBase36(v4()), + amount: `column_${uuidToBase36(v4())}`, + currency: `column_${uuidToBase36(v4())}`, }; default: throw new Error(`Unknown type ${type}`); diff --git a/server/src/metadata/migrations/1697126636202-MetadataNameLabelRefactoring.ts b/server/src/metadata/migrations/1697126636202-MetadataNameLabelRefactoring.ts new file mode 100644 index 000000000..1f0bc5094 --- /dev/null +++ b/server/src/metadata/migrations/1697126636202-MetadataNameLabelRefactoring.ts @@ -0,0 +1,149 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MetadataNameLabelRefactoring1697126636202 + implements MigrationInterface +{ + name = 'MetadataNameLabelRefactoring1697126636202'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."data_source_metadata" RENAME COLUMN "display_name" TO "label"`, + ); + await queryRunner.query( + `CREATE TABLE "metadata"."tenant_migrations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "migrations" jsonb, "applied_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_cb644cbc7f5092850f25eecb465" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "display_name"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "display_name_singular"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "display_name_plural"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "display_name"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "target_column_name"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "name_singular" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "UQ_8b063d2a685474dbae56cd685d2" UNIQUE ("name_singular")`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "name_plural" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "UQ_a2387e1b21120110b7e3db83da1" UNIQUE ("name_plural")`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "label_singular" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "label_plural" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "name_singular" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "name_plural" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "label_singular" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "label_plural" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4()`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "FK_38179b299795e48887fc99f937a"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4()`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4()`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ALTER COLUMN "target_column_map" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "FK_38179b299795e48887fc99f937a" FOREIGN KEY ("object_id") REFERENCES "metadata"."object_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "FK_38179b299795e48887fc99f937a"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ALTER COLUMN "target_column_map" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ALTER COLUMN "id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ALTER COLUMN "id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "FK_38179b299795e48887fc99f937a" FOREIGN KEY ("object_id") REFERENCES "metadata"."object_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "label_plural"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "label_singular"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "name_plural"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" DROP COLUMN "name_singular"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "label_plural"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "label_singular"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "UQ_a2387e1b21120110b7e3db83da1"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "name_plural"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "UQ_8b063d2a685474dbae56cd685d2"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" DROP COLUMN "name_singular"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "target_column_name" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."field_metadata" ADD "display_name" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "display_name_plural" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "display_name_singular" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."object_metadata" ADD "display_name" character varying NOT NULL`, + ); + await queryRunner.query(`DROP TABLE "metadata"."tenant_migrations"`); + await queryRunner.query( + `ALTER TABLE "metadata"."data_source_metadata" RENAME COLUMN "label" TO "display_name"`, + ); + } +} diff --git a/server/src/metadata/object-metadata/dtos/create-object.input.ts b/server/src/metadata/object-metadata/dtos/create-object.input.ts index 24f488739..4c0ac158e 100644 --- a/server/src/metadata/object-metadata/dtos/create-object.input.ts +++ b/server/src/metadata/object-metadata/dtos/create-object.input.ts @@ -4,21 +4,25 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; @InputType() export class CreateObjectInput { - // Deprecated @IsString() @IsNotEmpty() @Field() - displayName: string; + nameSingular: string; @IsString() - @IsOptional() - @Field({ nullable: true }) - displayNameSingular?: string; + @IsNotEmpty() + @Field() + namePlural: string; @IsString() - @IsOptional() - @Field({ nullable: true }) - displayNamePlural?: string; + @IsNotEmpty() + @Field() + labelSingular: string; + + @IsString() + @IsNotEmpty() + @Field() + labelPlural: string; @IsString() @IsOptional() diff --git a/server/src/metadata/object-metadata/dtos/update-object.input.ts b/server/src/metadata/object-metadata/dtos/update-object.input.ts index 0e2477367..7693ee6ff 100644 --- a/server/src/metadata/object-metadata/dtos/update-object.input.ts +++ b/server/src/metadata/object-metadata/dtos/update-object.input.ts @@ -4,21 +4,25 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator'; @InputType() export class UpdateObjectInput { - // Deprecated @IsString() @IsOptional() - @Field({ nullable: true }) - displayName: string; + @Field() + nameSingular: string; @IsString() @IsOptional() - @Field({ nullable: true }) - displayNameSingular?: string; + @Field() + namePlural: string; @IsString() @IsOptional() - @Field({ nullable: true }) - displayNamePlural?: string; + @Field() + labelSingular: string; + + @IsString() + @IsOptional() + @Field() + labelPlural: string; @IsString() @IsOptional() diff --git a/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts b/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts index 0892c0f86..34cd63375 100644 --- a/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts +++ b/server/src/metadata/object-metadata/hooks/before-create-one-object.hook.ts @@ -30,7 +30,7 @@ export class BeforeCreateOneObject ); instance.input.dataSourceId = lastDataSourceMetadata.id; - instance.input.targetTableName = instance.input.displayName; + instance.input.targetTableName = instance.input.nameSingular; instance.input.workspaceId = workspaceId; instance.input.isActive = false; instance.input.isCustom = true; diff --git a/server/src/metadata/object-metadata/object-metadata.entity.ts b/server/src/metadata/object-metadata/object-metadata.entity.ts index 15b8b3d44..f4d209c5b 100644 --- a/server/src/metadata/object-metadata/object-metadata.entity.ts +++ b/server/src/metadata/object-metadata/object-metadata.entity.ts @@ -40,21 +40,25 @@ export class ObjectMetadata { @PrimaryGeneratedColumn('uuid') id: string; + @Field() @Column({ nullable: false, name: 'data_source_id' }) dataSourceId: string; - // Deprecated @Field() - @Column({ nullable: false, name: 'display_name' }) - displayName: string; + @Column({ nullable: false, name: 'name_singular', unique: true }) + nameSingular: string; - @Field({ nullable: true }) - @Column({ nullable: true, name: 'display_name_singular' }) - displayNameSingular: string; + @Field() + @Column({ nullable: false, name: 'name_plural', unique: true }) + namePlural: string; - @Field({ nullable: true }) - @Column({ nullable: true, name: 'display_name_plural' }) - displayNamePlural: string; + @Field() + @Column({ nullable: false, name: 'label_singular' }) + labelSingular: string; + + @Field() + @Column({ nullable: false, name: 'label_plural' }) + labelPlural: string; @Field({ nullable: true }) @Column({ nullable: true, name: 'description', type: 'text' }) diff --git a/server/src/metadata/object-metadata/services/object-metadata.service.ts b/server/src/metadata/object-metadata/services/object-metadata.service.ts index 8d23217f1..a007e1754 100644 --- a/server/src/metadata/object-metadata/services/object-metadata.service.ts +++ b/server/src/metadata/object-metadata/services/object-metadata.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -22,17 +22,6 @@ export class ObjectMetadataService extends TypeOrmQueryService { } override async createOne(record: ObjectMetadata): Promise { - const objectAlreadyExists = await this.objectMetadataRepository.findOne({ - where: { - displayName: record.displayName, // deprecated, use singular and plural - workspaceId: record.workspaceId, - }, - }); - - if (objectAlreadyExists) { - throw new ConflictException('Object already exists'); - } - const createdObjectMetadata = await super.createOne(record); await this.tenantMigrationService.createMigration( diff --git a/server/src/tenant/entity-resolver/entity-resolver.service.ts b/server/src/tenant/entity-resolver/entity-resolver.service.ts index ac603583e..37ee902d3 100644 --- a/server/src/tenant/entity-resolver/entity-resolver.service.ts +++ b/server/src/tenant/entity-resolver/entity-resolver.service.ts @@ -6,7 +6,7 @@ import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schem import { DataSourceService } from 'src/metadata/data-source/data-source.service'; -import { PGGraphQLQueryRunner } from './utils/pg-graphql-query-runner.util'; +import { PGGraphQLQueryRunner } from './pg-graphql/pg-graphql-query-runner.util'; @Injectable() export class EntityResolverService { @@ -14,11 +14,10 @@ export class EntityResolverService { async findMany(context: SchemaBuilderContext, info: GraphQLResolveInfo) { const runner = new PGGraphQLQueryRunner(this.dataSourceService, { - entityName: context.entityName, tableName: context.tableName, workspaceId: context.workspaceId, info, - fieldAliases: context.fieldAliases, + fields: context.fields, }); return runner.findMany(); @@ -30,11 +29,10 @@ export class EntityResolverService { info: GraphQLResolveInfo, ) { const runner = new PGGraphQLQueryRunner(this.dataSourceService, { - entityName: context.entityName, tableName: context.tableName, workspaceId: context.workspaceId, info, - fieldAliases: context.fieldAliases, + fields: context.fields, }); return runner.findOne(args); @@ -56,11 +54,10 @@ export class EntityResolverService { info: GraphQLResolveInfo, ) { const runner = new PGGraphQLQueryRunner(this.dataSourceService, { - entityName: context.entityName, tableName: context.tableName, workspaceId: context.workspaceId, info, - fieldAliases: context.fieldAliases, + fields: context.fields, }); return runner.createMany(args); @@ -72,11 +69,10 @@ export class EntityResolverService { info: GraphQLResolveInfo, ) { const runner = new PGGraphQLQueryRunner(this.dataSourceService, { - entityName: context.entityName, tableName: context.tableName, workspaceId: context.workspaceId, info, - fieldAliases: context.fieldAliases, + fields: context.fields, }); return runner.updateOne(args); diff --git a/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts b/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts new file mode 100644 index 000000000..2b1703305 --- /dev/null +++ b/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts @@ -0,0 +1,175 @@ +import { GraphQLResolveInfo } from 'graphql'; + +import { + FieldMetadata, + FieldMetadataTargetColumnMap, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { + PGGraphQLQueryBuilder, + PGGraphQLQueryBuilderOptions, +} from 'src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util'; + +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 fields = [ + { + nameSingular: 'name', + targetColumnMap: { + value: 'column_name', + } as FieldMetadataTargetColumnMap, + }, + { + nameSingular: 'age', + targetColumnMap: { + value: 'column_age', + } as FieldMetadataTargetColumnMap, + }, + { + nameSingular: 'complexField', + targetColumnMap: { + subField1: 'column_subField1', + subField2: 'column_subField2', + } as FieldMetadataTargetColumnMap, + }, + ] as FieldMetadata[]; + + mockOptions = { + tableName: 'TestTable', + info: {} as GraphQLResolveInfo, + fields, + }; + + queryBuilder = new PGGraphQLQueryBuilder(mockOptions); + }); + + test('findMany generates correct query with complex and nested fields', () => { + 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('findOne generates correct query with complex and nested fields', () => { + const args = { id: '1' }; + const query = queryBuilder.findOne(args); + expect(normalizeWhitespace(query)).toBe( + normalizeWhitespace(` + query { + TestTableCollection(filter: { id: { eq: "1" } }) { + 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 + } + } + } + `), + ); + }); +}); diff --git a/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts new file mode 100644 index 000000000..253a35176 --- /dev/null +++ b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts @@ -0,0 +1,103 @@ +import { GraphQLResolveInfo } from 'graphql'; +import graphqlFields from 'graphql-fields'; +import { v4 as uuidv4 } from 'uuid'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { stringifyWithoutKeyQuote } from 'src/tenant/entity-resolver/utils/stringify-without-key-quote.util'; +import { convertFieldsToGraphQL } from 'src/tenant/entity-resolver/utils/convert-fields-to-graphql.util'; +import { convertArguments } from 'src/tenant/entity-resolver/utils/convert-arguments.util'; + +type CommandArgs = { + findMany: null; + findOne: { id: string }; + createMany: { data: any[] }; + updateOne: { id: string; data: any }; +}; + +export interface PGGraphQLQueryBuilderOptions { + tableName: string; + info: GraphQLResolveInfo; + fields: FieldMetadata[]; +} + +export class PGGraphQLQueryBuilder { + private options: PGGraphQLQueryBuilderOptions; + + constructor(options: PGGraphQLQueryBuilderOptions) { + this.options = options; + } + + private getFieldsString(): string { + const select = graphqlFields(this.options.info); + + return convertFieldsToGraphQL(select, this.options.fields); + } + + // Define command setters + findMany() { + const { tableName } = this.options; + const fieldsString = this.getFieldsString(); + + return ` + query { + ${tableName}Collection { + ${fieldsString} + } + } + `; + } + + findOne({ id }: CommandArgs['findOne']) { + const { tableName } = this.options; + const fieldsString = this.getFieldsString(); + + return ` + query { + ${tableName}Collection(filter: { id: { eq: "${id}" } }) { + ${fieldsString} + } + } + `; + } + + createMany({ data }: CommandArgs['createMany']) { + const { tableName } = this.options; + const fieldsString = this.getFieldsString(); + const args = convertArguments(data, this.options.fields); + + return ` + mutation { + insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote( + args.map((datum) => ({ + id: uuidv4(), + ...datum, + })), + )}) { + affectedCount + records { + ${fieldsString} + } + } + } + `; + } + + updateOne({ id, data }: CommandArgs['updateOne']) { + const { tableName } = this.options; + const fieldsString = this.getFieldsString(); + const args = convertArguments(data, this.options.fields); + + return ` + mutation { + update${tableName}Collection(set: ${stringifyWithoutKeyQuote( + args, + )}, filter: { id: { eq: "${id}" } }) { + affectedCount + records { + ${fieldsString} + } + } + } + `; + } +} diff --git a/server/src/tenant/entity-resolver/utils/pg-graphql-query-runner.util.ts b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts similarity index 70% rename from server/src/tenant/entity-resolver/utils/pg-graphql-query-runner.util.ts rename to server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts index 953d21911..392449401 100644 --- a/server/src/tenant/entity-resolver/utils/pg-graphql-query-runner.util.ts +++ b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts @@ -3,16 +3,16 @@ import { BadRequestException } from '@nestjs/common'; import { GraphQLResolveInfo } from 'graphql'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; -import { pascalCase } from 'src/utils/pascal-case'; +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { parseResult } from 'src/tenant/entity-resolver/utils/parse-result.util'; import { PGGraphQLQueryBuilder } from './pg-graphql-query-builder.util'; interface QueryRunnerOptions { - entityName: string; tableName: string; workspaceId: string; info: GraphQLResolveInfo; - fieldAliases: Record; + fields: FieldMetadata[]; } export class PGGraphQLQueryRunner { @@ -24,10 +24,9 @@ export class PGGraphQLQueryRunner { options: QueryRunnerOptions, ) { this.queryBuilder = new PGGraphQLQueryBuilder({ - entityName: options.entityName, tableName: options.tableName, info: options.info, - fieldAliases: options.fieldAliases, + fields: options.fields, }); this.options = options; } @@ -47,36 +46,37 @@ export class PGGraphQLQueryRunner { `); } - private parseResults(graphqlResult: any, command: string): any { - const entityKey = `${command}${pascalCase(this.options.entityName)}`; + private parseResult(graphqlResult: any, command: string): any { + const tableName = this.options.tableName; + const entityKey = `${command}${tableName}Collection`; const result = graphqlResult?.[0]?.resolve?.data?.[entityKey]; if (!result) { throw new BadRequestException('Malformed result from GraphQL query'); } - return result; + return parseResult(result); } async findMany(): Promise { - const query = this.queryBuilder.findMany().build(); + const query = this.queryBuilder.findMany(); const result = await this.execute(query, this.options.workspaceId); - return this.parseResults(result, 'findMany'); + return this.parseResult(result, ''); } async findOne(args: { id: string }): Promise { - const query = this.queryBuilder.findOne(args).build(); + const query = this.queryBuilder.findOne(args); const result = await this.execute(query, this.options.workspaceId); - return this.parseResults(result, 'findOne'); + return this.parseResult(result, ''); } async createMany(args: { data: any[] }): Promise { - const query = this.queryBuilder.createMany(args).build(); + const query = this.queryBuilder.createMany(args); const result = await this.execute(query, this.options.workspaceId); - return this.parseResults(result, 'createMany')?.records; + return this.parseResult(result, 'insertInto')?.records; } async createOne(args: { data: any }): Promise { @@ -86,9 +86,9 @@ export class PGGraphQLQueryRunner { } async updateOne(args: { id: string; data: any }): Promise { - const query = this.queryBuilder.updateOne(args).build(); + const query = this.queryBuilder.updateOne(args); const result = await this.execute(query, this.options.workspaceId); - return this.parseResults(result, 'updateOne')?.records?.[0]; + return this.parseResult(result, 'update')?.records?.[0]; } } diff --git a/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts new file mode 100644 index 000000000..918b5df65 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts @@ -0,0 +1,69 @@ +import { + FieldMetadata, + FieldMetadataTargetColumnMap, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { convertArguments } from 'src/tenant/entity-resolver/utils/convert-arguments.util'; + +describe('convertArguments', () => { + let fields; + + beforeEach(() => { + fields = [ + { + nameSingular: 'firstName', + targetColumnMap: { + value: 'column_1randomFirstNameKey', + } as FieldMetadataTargetColumnMap, + type: 'text', + }, + { + nameSingular: 'age', + targetColumnMap: { + value: 'column_randomAgeKey', + } as FieldMetadataTargetColumnMap, + type: 'text', + }, + { + nameSingular: 'website', + targetColumnMap: { + link: 'column_randomLinkKey', + text: 'column_randomTex7Key', + } as FieldMetadataTargetColumnMap, + type: '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); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/convert-fields-to-graphql.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/convert-fields-to-graphql.spec.ts new file mode 100644 index 000000000..ba65b1559 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/convert-fields-to-graphql.spec.ts @@ -0,0 +1,99 @@ +import { + FieldMetadata, + FieldMetadataTargetColumnMap, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { convertFieldsToGraphQL } from 'src/tenant/entity-resolver/utils/convert-fields-to-graphql.util'; + +const normalizeWhitespace = (str) => str.replace(/\s+/g, ' ').trim(); + +describe('convertFieldsToGraphQL', () => { + let fields; + + beforeEach(() => { + fields = [ + { + nameSingular: 'simpleField', + targetColumnMap: { + value: 'column_RANDOMSTRING1', + } as FieldMetadataTargetColumnMap, + }, + { + nameSingular: '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 = { + nameSingular: '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)); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts new file mode 100644 index 000000000..7b7b114a6 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts @@ -0,0 +1,58 @@ +import { + FieldMetadata, + FieldMetadataTargetColumnMap, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { getFieldAliases } from 'src/tenant/entity-resolver/utils/get-fields-aliases.util'; + +describe('getFieldAliases', () => { + let fields: FieldMetadata[]; + + beforeEach(() => { + // Setup sample field metadata + fields = [ + { + nameSingular: 'singleValueField', + namePlural: 'singleValueFields', + targetColumnMap: { + value: 'column_singleValue', + } as FieldMetadataTargetColumnMap, + }, + { + nameSingular: 'multipleValuesField', + namePlural: 'multipleValuesFields', + targetColumnMap: { + link: 'column_value1', + text: 'column_value2', + } as FieldMetadataTargetColumnMap, + }, + ] as FieldMetadata[]; + }); + + test('should return correct aliases for fields with a single value in targetColumnMap', () => { + const aliases = getFieldAliases(fields); + expect(aliases).toHaveProperty('singleValueField', 'column_singleValue'); + }); + + test('should return correct aliases for fields with multiple values in targetColumnMap', () => { + const aliases = getFieldAliases(fields); + expect(aliases).toHaveProperty('column_value1', 'column_value1'); + }); + + test('should handle empty fields array', () => { + const aliases = getFieldAliases([]); + expect(aliases).toEqual({}); + }); + + test('should not create aliases for fields without targetColumnMap values', () => { + const fieldsWithEmptyMap = [ + ...fields, + { + nameSingular: 'emptyField', + namePlural: 'emptyFields', + targetColumnMap: {} as FieldMetadataTargetColumnMap, + }, + ] as FieldMetadata[]; + const aliases = getFieldAliases(fieldsWithEmptyMap); + expect(aliases).not.toHaveProperty('emptyField'); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/parse-result.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/parse-result.spec.ts new file mode 100644 index 000000000..38a1b33a2 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/parse-result.spec.ts @@ -0,0 +1,105 @@ +import { + isSpecialKey, + handleSpecialKey, + parseResult, +} from 'src/tenant/entity-resolver/utils/parse-result.util'; + +describe('isSpecialKey', () => { + test('should return true if the key starts with "___"', () => { + expect(isSpecialKey('___specialKey')).toBe(true); + }); + + test('should return false if the key does not start with "___"', () => { + expect(isSpecialKey('normalKey')).toBe(false); + }); +}); + +describe('handleSpecialKey', () => { + let result; + + beforeEach(() => { + result = {}; + }); + + test('should correctly process a special key and add it to the result object', () => { + handleSpecialKey(result, '___complexField_link', 'value1'); + expect(result).toEqual({ + complexField: { + link: 'value1', + }, + }); + }); + + test('should add values under the same newKey if called multiple times', () => { + handleSpecialKey(result, '___complexField_link', 'value1'); + handleSpecialKey(result, '___complexField_text', 'value2'); + expect(result).toEqual({ + complexField: { + link: 'value1', + text: 'value2', + }, + }); + }); + + test('should not create a new field if the special key is not correctly formed', () => { + handleSpecialKey(result, '___complexField', 'value1'); + expect(result).toEqual({}); + }); +}); + +describe('parseResult', () => { + test('should recursively parse an object and handle special keys', () => { + const obj = { + normalField: 'value1', + ___specialField_part1: 'value2', + nested: { + ___specialFieldNested_part2: 'value3', + }, + }; + + const expectedResult = { + normalField: 'value1', + specialField: { + part1: 'value2', + }, + nested: { + specialFieldNested: { + part2: 'value3', + }, + }, + }; + + expect(parseResult(obj)).toEqual(expectedResult); + }); + + test('should handle arrays and parse each element', () => { + const objArray = [ + { + ___specialField_part1: 'value1', + }, + { + ___specialField_part2: 'value2', + }, + ]; + + const expectedResult = [ + { + specialField: { + part1: 'value1', + }, + }, + { + specialField: { + part2: 'value2', + }, + }, + ]; + + expect(parseResult(objArray)).toEqual(expectedResult); + }); + + test('should return the original value if it is not an object or array', () => { + expect(parseResult('stringValue')).toBe('stringValue'); + expect(parseResult(12345)).toBe(12345); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/stringify-without-key-quote.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/stringify-without-key-quote.spec.ts new file mode 100644 index 000000000..ce1d8c40b --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/stringify-without-key-quote.spec.ts @@ -0,0 +1,53 @@ +import { stringifyWithoutKeyQuote } from 'src/tenant/entity-resolver/utils/stringify-without-key-quote.util'; + +describe('stringifyWithoutKeyQuote', () => { + test('should stringify object correctly without quotes around keys', () => { + const obj = { name: 'John', age: 30, isAdmin: false }; + const result = stringifyWithoutKeyQuote(obj); + expect(result).toBe('{name:"John",age:30,isAdmin:false}'); + }); + + test('should handle nested objects', () => { + const obj = { + name: 'John', + age: 30, + address: { city: 'New York', zipCode: 10001 }, + }; + const result = stringifyWithoutKeyQuote(obj); + expect(result).toBe( + '{name:"John",age:30,address:{city:"New York",zipCode:10001}}', + ); + }); + + test('should handle arrays', () => { + const obj = { + name: 'John', + age: 30, + hobbies: ['reading', 'movies', 'hiking'], + }; + const result = stringifyWithoutKeyQuote(obj); + expect(result).toBe( + '{name:"John",age:30,hobbies:["reading","movies","hiking"]}', + ); + }); + + test('should handle empty objects', () => { + const obj = {}; + const result = stringifyWithoutKeyQuote(obj); + expect(result).toBe('{}'); + }); + + test('should handle numbers, strings, and booleans', () => { + const num = 10; + const str = 'Hello'; + const bool = false; + expect(stringifyWithoutKeyQuote(num)).toBe('10'); + expect(stringifyWithoutKeyQuote(str)).toBe('"Hello"'); + expect(stringifyWithoutKeyQuote(bool)).toBe('false'); + }); + + test('should handle null and undefined', () => { + expect(stringifyWithoutKeyQuote(null)).toBe('null'); + expect(stringifyWithoutKeyQuote(undefined)).toBe(undefined); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts b/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts new file mode 100644 index 000000000..dcff7a7b1 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts @@ -0,0 +1,38 @@ +import isEmpty from 'lodash.isempty'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; + +export const convertArguments = (args: any, fields: FieldMetadata[]): any => { + const fieldsMap = new Map( + // TODO: Handle plural for fields when we add relations + fields.map((metadata) => [metadata.nameSingular, metadata]), + ); + + if (Array.isArray(args)) { + return args.map((arg) => convertArguments(arg, fields)); + } + + const newArgs = {}; + + for (const [key, value] of Object.entries(args)) { + if (fieldsMap.has(key)) { + const fieldMetadata = fieldsMap.get(key)!; + + if (typeof value === 'object' && value !== null && !isEmpty(value)) { + for (const [subKey, subValue] of Object.entries(value)) { + if (fieldMetadata.targetColumnMap[subKey]) { + newArgs[fieldMetadata.targetColumnMap[subKey]] = subValue; + } + } + } else { + if (fieldMetadata.targetColumnMap.value) { + newArgs[fieldMetadata.targetColumnMap.value] = value; + } + } + } else { + newArgs[key] = value; + } + } + + return newArgs; +}; diff --git a/server/src/tenant/entity-resolver/utils/convert-fields-to-graphql.util.ts b/server/src/tenant/entity-resolver/utils/convert-fields-to-graphql.util.ts index 530bddca9..3ebeb405f 100644 --- a/server/src/tenant/entity-resolver/utils/convert-fields-to-graphql.util.ts +++ b/server/src/tenant/entity-resolver/utils/convert-fields-to-graphql.util.ts @@ -1,21 +1,55 @@ import isEmpty from 'lodash.isempty'; +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; + export const convertFieldsToGraphQL = ( - fields: any, - fieldAliases: Record, + select: any, + fields: FieldMetadata[], acc = '', ) => { - for (const [key, value] of Object.entries(fields)) { - if (value && !isEmpty(value)) { + const fieldsMap = new Map( + // TODO: Handle plural for fields when we add relations + fields.map((metadata) => [metadata.nameSingular, metadata]), + ); + + for (const [key, value] of Object.entries(select)) { + let fieldAlias = key; + + if (fieldsMap.has(key)) { + const metadata = fieldsMap.get(key)!; + 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.nameSingular}_${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, fieldAliases, acc); + acc = convertFieldsToGraphQL(value, fields, acc); // recursive call with updated accumulator acc += `}\n`; } else { - if (fieldAliases[key]) { - acc += `${key}: ${fieldAliases[key]}\n`; - } else { - acc += `${key}\n`; - } + acc += `${fieldAlias}\n`; } } diff --git a/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts b/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts new file mode 100644 index 000000000..fa78c5df7 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts @@ -0,0 +1,22 @@ +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; + +export const getFieldAliases = (fields: FieldMetadata[]) => { + const fieldAliases = fields.reduce((acc, column) => { + const values = Object.values(column.targetColumnMap); + + if (values.length === 1) { + return { + ...acc, + // TODO: Handle plural for fields when we add relations + [column.nameSingular]: values[0], + }; + } else { + return { + ...acc, + [values[0]]: values[0], + }; + } + }, {}); + + return fieldAliases; +}; diff --git a/server/src/tenant/entity-resolver/utils/parse-result.util.ts b/server/src/tenant/entity-resolver/utils/parse-result.util.ts new file mode 100644 index 000000000..a6a303e24 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/parse-result.util.ts @@ -0,0 +1,51 @@ +export const isSpecialKey = (key: string): boolean => { + return key.startsWith('___'); +}; + +export const handleSpecialKey = ( + result: any, + key: string, + value: any, +): void => { + const parts = key.split('_').filter((part) => part); + + // If parts don't contain enough information, return without altering result + if (parts.length < 2) { + return; + } + + const newKey = parts.slice(0, -1).join(''); + const subKey = parts[parts.length - 1]; + + if (!result[newKey]) { + result[newKey] = {}; + } + + result[newKey][subKey] = value; +}; + +export const parseResult = (obj: any): any => { + if (obj === null || typeof obj !== 'object' || typeof obj === 'function') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => parseResult(item)); + } + + const result: any = {}; + + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + result[key] = parseResult(obj[key]); + } else if (isSpecialKey(key)) { + handleSpecialKey(result, key, obj[key]); + } else { + result[key] = obj[key]; + } + } + } + + return result; +}; diff --git a/server/src/tenant/entity-resolver/utils/pg-graphql-query-builder.util.ts b/server/src/tenant/entity-resolver/utils/pg-graphql-query-builder.util.ts deleted file mode 100644 index 0e270bb08..000000000 --- a/server/src/tenant/entity-resolver/utils/pg-graphql-query-builder.util.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { GraphQLResolveInfo } from 'graphql'; -import graphqlFields from 'graphql-fields'; -import { v4 as uuidv4 } from 'uuid'; - -import { pascalCase } from 'src/utils/pascal-case'; - -import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util'; -import { convertFieldsToGraphQL } from './convert-fields-to-graphql.util'; - -type Command = 'findMany' | 'findOne' | 'createMany' | 'updateOne'; - -type CommandArgs = { - findMany: null; - findOne: { id: string }; - createMany: { data: any[] }; - updateOne: { id: string; data: any }; -}; - -export interface PGGraphQLQueryBuilderOptions { - entityName: string; - tableName: string; - info: GraphQLResolveInfo; - fieldAliases: Record; -} - -export class PGGraphQLQueryBuilder { - private options: PGGraphQLQueryBuilderOptions; - private command: Command; - private commandArgs: any; - - constructor(options: PGGraphQLQueryBuilderOptions) { - this.options = options; - } - - private getFields(): string { - const fields = graphqlFields(this.options.info); - - return convertFieldsToGraphQL(fields, this.options.fieldAliases); - } - - // Define command setters - findMany() { - this.command = 'findMany'; - this.commandArgs = null; - return this; - } - - findOne(args: CommandArgs['findOne']) { - this.command = 'findOne'; - this.commandArgs = args; - return this; - } - - createMany(args: CommandArgs['createMany']) { - this.command = 'createMany'; - this.commandArgs = args; - return this; - } - - updateOne(args: CommandArgs['updateOne']) { - this.command = 'updateOne'; - this.commandArgs = args; - return this; - } - - build() { - const { entityName, tableName } = this.options; - const fields = this.getFields(); - - switch (this.command) { - case 'findMany': - return ` - query FindMany${pascalCase(entityName)} { - findMany${pascalCase(entityName)}: ${tableName}Collection { - ${fields} - } - } - `; - case 'findOne': - return ` - query FindOne${pascalCase(entityName)} { - findOne${pascalCase( - entityName, - )}: ${tableName}Collection(filter: { id: { eq: "${ - this.commandArgs.id - }" } }) { - ${fields} - } - } - `; - case 'createMany': - return ` - mutation CreateMany${pascalCase(entityName)} { - createMany${pascalCase( - entityName, - )}: insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote( - this.commandArgs.data.map((datum) => ({ - id: uuidv4(), - ...datum, - })), - )}) { - affectedCount - records { - ${fields} - } - } - } - `; - case 'updateOne': - return ` - mutation UpdateOne${pascalCase(entityName)} { - updateOne${pascalCase( - entityName, - )}: update${tableName}Collection(set: ${stringifyWithoutKeyQuote( - this.commandArgs.data, - )}, filter: { id: { eq: "${this.commandArgs.id}" } }) { - affectedCount - records { - ${fields} - } - } - } - `; - default: - throw new Error('Invalid command'); - } - } -} diff --git a/server/src/tenant/entity-resolver/utils/stringify-without-key-quote.util.ts b/server/src/tenant/entity-resolver/utils/stringify-without-key-quote.util.ts index ff041ed40..f8fb96450 100644 --- a/server/src/tenant/entity-resolver/utils/stringify-without-key-quote.util.ts +++ b/server/src/tenant/entity-resolver/utils/stringify-without-key-quote.util.ts @@ -1,5 +1,6 @@ export const stringifyWithoutKeyQuote = (obj: any) => { const jsonString = JSON.stringify(obj); - const jsonWithoutQuotes = jsonString.replace(/"(\w+)"\s*:/g, '$1:'); + const jsonWithoutQuotes = jsonString?.replace(/"(\w+)"\s*:/g, '$1:'); + return jsonWithoutQuotes; }; diff --git a/server/src/tenant/schema-builder/interfaces/schema-builder-context.interface.ts b/server/src/tenant/schema-builder/interfaces/schema-builder-context.interface.ts index 9deb6118d..f3f2e9e1d 100644 --- a/server/src/tenant/schema-builder/interfaces/schema-builder-context.interface.ts +++ b/server/src/tenant/schema-builder/interfaces/schema-builder-context.interface.ts @@ -1,6 +1,7 @@ +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; + export interface SchemaBuilderContext { - entityName: string; tableName: string; workspaceId: string; - fieldAliases: Record; + fields: FieldMetadata[]; } diff --git a/server/src/tenant/schema-builder/schema-builder.service.ts b/server/src/tenant/schema-builder/schema-builder.service.ts index 6dbd09166..dbcf4c317 100644 --- a/server/src/tenant/schema-builder/schema-builder.service.ts +++ b/server/src/tenant/schema-builder/schema-builder.service.ts @@ -1,4 +1,4 @@ -import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { GraphQLFieldConfigMap, @@ -9,9 +9,9 @@ import { GraphQLObjectType, GraphQLSchema, } from 'graphql'; +import upperFirst from 'lodash.upperfirst'; import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service'; -import { pascalCase } from 'src/utils/pascal-case'; import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity'; import { generateEdgeType } from './utils/generate-edge-type.util'; @@ -20,6 +20,7 @@ import { generateObjectType } from './utils/generate-object-type.util'; import { generateCreateInputType } from './utils/generate-create-input-type.util'; import { generateUpdateInputType } from './utils/generate-update-input-type.util'; import { SchemaBuilderContext } from './interfaces/schema-builder-context.interface'; +import { cleanEntityName } from './utils/clean-entity-name.util'; @Injectable() export class SchemaBuilderService { @@ -28,31 +29,25 @@ export class SchemaBuilderService { constructor(private readonly entityResolverService: EntityResolverService) {} private generateQueryFieldForEntity( - entityName: string, + entityName: { + singular: string; + plural: string; + }, tableName: string, ObjectType: GraphQLObjectType, objectDefinition: ObjectMetadata, ) { - const fieldAliases = - objectDefinition?.fields.reduce( - (acc, field) => ({ - ...acc, - [field.displayName]: field.targetColumnName, - }), - {}, - ) || {}; const schemaBuilderContext: SchemaBuilderContext = { - entityName, tableName, workspaceId: this.workspaceId, - fieldAliases, + fields: objectDefinition.fields, }; const EdgeType = generateEdgeType(ObjectType); const ConnectionType = generateConnectionType(EdgeType); return { - [`findMany${pascalCase(entityName)}`]: { + [`${entityName.plural}`]: { type: ConnectionType, resolve: async (root, args, context, info) => { return this.entityResolverService.findMany( @@ -61,7 +56,7 @@ export class SchemaBuilderService { ); }, }, - [`findOne${pascalCase(entityName)}`]: { + [`${entityName.singular}`]: { type: ObjectType, args: { id: { type: new GraphQLNonNull(GraphQLID) }, @@ -78,30 +73,24 @@ export class SchemaBuilderService { } private generateMutationFieldForEntity( - entityName: string, + entityName: { + singular: string; + plural: string; + }, tableName: string, ObjectType: GraphQLObjectType, CreateInputType: GraphQLInputObjectType, UpdateInputType: GraphQLInputObjectType, objectDefinition: ObjectMetadata, ) { - const fieldAliases = - objectDefinition?.fields.reduce( - (acc, field) => ({ - ...acc, - [field.displayName]: field.targetColumnName, - }), - {}, - ) || {}; const schemaBuilderContext: SchemaBuilderContext = { - entityName, tableName, workspaceId: this.workspaceId, - fieldAliases, + fields: objectDefinition.fields, }; return { - [`createOne${pascalCase(entityName)}`]: { + [`createOne${upperFirst(entityName.singular)}`]: { type: new GraphQLNonNull(ObjectType), args: { data: { type: new GraphQLNonNull(CreateInputType) }, @@ -114,7 +103,7 @@ export class SchemaBuilderService { ); }, }, - [`createMany${pascalCase(entityName)}`]: { + [`createMany${upperFirst(entityName.singular)}`]: { type: new GraphQLList(ObjectType), args: { data: { @@ -131,7 +120,7 @@ export class SchemaBuilderService { ); }, }, - [`updateOne${pascalCase(entityName)}`]: { + [`updateOne${upperFirst(entityName.singular)}`]: { type: new GraphQLNonNull(ObjectType), args: { id: { type: new GraphQLNonNull(GraphQLID) }, @@ -156,33 +145,29 @@ export class SchemaBuilderService { const mutationFields: any = {}; for (const objectDefinition of objectMetadata) { - if (objectDefinition.fields.length === 0) { - // A graphql type must define one or more fields - continue; - } + const entityName = { + singular: cleanEntityName(objectDefinition.nameSingular), + plural: cleanEntityName(objectDefinition.namePlural), + }; const tableName = objectDefinition?.targetTableName ?? ''; const ObjectType = generateObjectType( - objectDefinition.displayName, + entityName.singular, objectDefinition.fields, ); const CreateInputType = generateCreateInputType( - objectDefinition.displayName, + entityName.singular, objectDefinition.fields, ); const UpdateInputType = generateUpdateInputType( - objectDefinition.displayName, + entityName.singular, objectDefinition.fields, ); - if (!objectDefinition) { - throw new InternalServerErrorException('Object definition not found'); - } - Object.assign( queryFields, this.generateQueryFieldForEntity( - objectDefinition.displayName, + entityName, tableName, ObjectType, objectDefinition, @@ -192,7 +177,7 @@ export class SchemaBuilderService { Object.assign( mutationFields, this.generateMutationFieldForEntity( - objectDefinition.displayName, + entityName, tableName, ObjectType, CreateInputType, diff --git a/server/src/tenant/schema-builder/utils/__tests__/clean-entity-name.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/clean-entity-name.spec.ts new file mode 100644 index 000000000..e964c95d0 --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/clean-entity-name.spec.ts @@ -0,0 +1,22 @@ +import { cleanEntityName } from 'src/tenant/schema-builder/utils/clean-entity-name.util'; + +describe('cleanEntityName', () => { + test('should camelCase strings', () => { + expect(cleanEntityName('hello world')).toBe('helloWorld'); + expect(cleanEntityName('my name is John')).toBe('myNameIsJohn'); + }); + + test('should remove numbers at the beginning', () => { + expect(cleanEntityName('123hello')).toBe('hello'); + expect(cleanEntityName('456hello world')).toBe('helloWorld'); + }); + + test('should remove special characters', () => { + expect(cleanEntityName('hello$world')).toBe('helloWorld'); + expect(cleanEntityName('some#special&chars')).toBe('someSpecialChars'); + }); + + test('should handle empty strings', () => { + expect(cleanEntityName('')).toBe(''); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-connection-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-connection-type.spec.ts new file mode 100644 index 000000000..b68dba2be --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-connection-type.spec.ts @@ -0,0 +1,52 @@ +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql'; + +import { PageInfoType } from 'src/tenant/schema-builder/utils/page-into-type.util'; +import { generateConnectionType } from 'src/tenant/schema-builder/utils/generate-connection-type.util'; + +describe('generateConnectionType', () => { + // Create a mock EdgeType for testing + const mockEdgeType = new GraphQLObjectType({ + name: 'MockEdge', + fields: { + node: { type: GraphQLString }, + cursor: { type: GraphQLString }, + }, + }); + + // Generate a connection type using the mock + const MockConnectionType = generateConnectionType(mockEdgeType); + + test('should generate a GraphQLObjectType', () => { + expect(MockConnectionType).toBeInstanceOf(GraphQLObjectType); + }); + + test('should generate a type with the correct name', () => { + expect(MockConnectionType.name).toBe('MockConnection'); + }); + + test('should include the correct fields', () => { + const fields = MockConnectionType.getFields(); + + expect(fields).toHaveProperty('edges'); + if ( + fields.edges.type instanceof GraphQLList || + fields.edges.type instanceof GraphQLNonNull + ) { + expect(fields.edges.type.ofType).toBe(mockEdgeType); + } else { + fail('edges.type is not an instance of GraphQLList or GraphQLNonNull'); + } + + expect(fields).toHaveProperty('pageInfo'); + if (fields.pageInfo.type instanceof GraphQLNonNull) { + expect(fields.pageInfo.type.ofType).toBe(PageInfoType); + } else { + fail('pageInfo.type is not an instance of GraphQLNonNull'); + } + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-create-input-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-create-input-type.spec.ts new file mode 100644 index 000000000..46760d0e5 --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-create-input-type.spec.ts @@ -0,0 +1,56 @@ +import { + GraphQLID, + GraphQLInputObjectType, + GraphQLInt, + GraphQLNonNull, + GraphQLString, +} from 'graphql'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { generateCreateInputType } from 'src/tenant/schema-builder/utils/generate-create-input-type.util'; + +describe('generateCreateInputType', () => { + test('should generate a GraphQLInputObjectType with correct name', () => { + const columns = []; + const name = 'testType'; + const inputType = generateCreateInputType(name, columns); + expect(inputType).toBeInstanceOf(GraphQLInputObjectType); + expect(inputType.name).toBe('TestTypeCreateInput'); + }); + + test('should include default id field', () => { + const columns = []; + const name = 'testType'; + const inputType = generateCreateInputType(name, columns); + const fields = inputType.getFields(); + expect(fields.id).toBeDefined(); + expect(fields.id.type).toBe(GraphQLID); + }); + + test('should generate fields with correct types and descriptions', () => { + const columns = [ + { + nameSingular: 'firstName', + type: 'text', + isNullable: false, + }, + { + nameSingular: 'age', + type: 'number', + isNullable: true, + }, + ] as FieldMetadata[]; + + const name = 'testType'; + const inputType = generateCreateInputType(name, columns); + const fields = inputType.getFields(); + + if (fields.firstName.type instanceof GraphQLNonNull) { + expect(fields.firstName.type.ofType).toBe(GraphQLString); + } else { + fail('firstName type is not an instance of GraphQLNonNull'); + } + + expect(fields.age.type).toBe(GraphQLInt); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-edge-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-edge-type.spec.ts new file mode 100644 index 000000000..779ab842d --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-edge-type.spec.ts @@ -0,0 +1,38 @@ +import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql'; + +import { generateEdgeType } from 'src/tenant/schema-builder/utils/generate-edge-type.util'; + +describe('generateEdgeType', () => { + // Mock GraphQLObjectType for testing + const mockObjectType = new GraphQLObjectType({ + name: 'MockItem', + fields: { + sampleField: { type: GraphQLString }, + }, + }); + + test('should generate a GraphQLObjectType', () => { + const edgeType = generateEdgeType(mockObjectType); + expect(edgeType).toBeInstanceOf(GraphQLObjectType); + }); + + test('should generate a type with the correct name', () => { + const edgeType = generateEdgeType(mockObjectType); + expect(edgeType.name).toBe('MockItemEdge'); + }); + + test('should have a "node" field of the provided ObjectType', () => { + const edgeType = generateEdgeType(mockObjectType); + const fields = edgeType.getFields(); + expect(fields.node.type).toBe(mockObjectType); + }); + + test('should have a "cursor" field of type GraphQLNonNull(GraphQLString)', () => { + const edgeType = generateEdgeType(mockObjectType); + const fields = edgeType.getFields(); + expect(fields.cursor.type).toBeInstanceOf(GraphQLNonNull); + if (fields.cursor.type instanceof GraphQLNonNull) { + expect(fields.cursor.type.ofType).toBe(GraphQLString); + } + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts new file mode 100644 index 000000000..4af89f41a --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts @@ -0,0 +1,72 @@ +import { + GraphQLID, + GraphQLInt, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { generateObjectType } from 'src/tenant/schema-builder/utils/generate-object-type.util'; + +describe('generateObjectType', () => { + test('should generate a GraphQLObjectType with correct name', () => { + const columns = []; + const name = 'testType'; + const objectType = generateObjectType(name, columns); + expect(objectType).toBeInstanceOf(GraphQLObjectType); + expect(objectType.name).toBe('TestType'); + }); + + test('should include default fields', () => { + const columns = []; + const name = 'testType'; + const objectType = generateObjectType(name, columns); + const fields = objectType.getFields(); + + if (fields.id.type instanceof GraphQLNonNull) { + expect(fields.id.type.ofType).toBe(GraphQLID); + } else { + fail('id.type is not an instance of GraphQLNonNull'); + } + + if (fields.createdAt.type instanceof GraphQLNonNull) { + expect(fields.createdAt.type.ofType).toBe(GraphQLString); + } else { + fail('createdAt.type is not an instance of GraphQLNonNull'); + } + + if (fields.updatedAt.type instanceof GraphQLNonNull) { + expect(fields.updatedAt.type.ofType).toBe(GraphQLString); + } else { + fail('updatedAt.type is not an instance of GraphQLNonNull'); + } + }); + + test('should generate fields based on provided columns', () => { + const columns = [ + { + nameSingular: 'firstName', + type: 'text', + isNullable: false, + }, + { + nameSingular: 'age', + type: 'number', + isNullable: true, + }, + ] as FieldMetadata[]; + + const name = 'testType'; + const objectType = generateObjectType(name, columns); + const fields = objectType.getFields(); + + if (fields.firstName.type instanceof GraphQLNonNull) { + expect(fields.firstName.type.ofType).toBe(GraphQLString); + } else { + fail('firstName.type is not an instance of GraphQLNonNull'); + } + + expect(fields.age.type).toBe(GraphQLInt); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-udpate-input-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-udpate-input-type.spec.ts new file mode 100644 index 000000000..e78ad60ce --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-udpate-input-type.spec.ts @@ -0,0 +1,51 @@ +import { + GraphQLID, + GraphQLInputObjectType, + GraphQLInt, + GraphQLString, +} from 'graphql'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { generateUpdateInputType } from 'src/tenant/schema-builder/utils/generate-update-input-type.util'; + +describe('generateUpdateInputType', () => { + test('should generate a GraphQLInputObjectType with correct name', () => { + const columns = []; + const name = 'testType'; + const inputType = generateUpdateInputType(name, columns); + expect(inputType).toBeInstanceOf(GraphQLInputObjectType); + expect(inputType.name).toBe('TestTypeUpdateInput'); + }); + + test('should include default id field', () => { + const columns = []; + const name = 'testType'; + const inputType = generateUpdateInputType(name, columns); + const fields = inputType.getFields(); + expect(fields.id).toBeDefined(); + expect(fields.id.type).toBe(GraphQLID); + }); + + test('should generate fields with correct types and descriptions', () => { + const columns = [ + { + nameSingular: 'firstName', + type: 'text', + isNullable: true, + }, + { + nameSingular: 'age', + type: 'number', + isNullable: true, + }, + ] as FieldMetadata[]; + + const name = 'testType'; + const inputType = generateUpdateInputType(name, columns); + const fields = inputType.getFields(); + + expect(fields.firstName.type).toBe(GraphQLString); + + expect(fields.age.type).toBe(GraphQLInt); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/map-column-type-to-graphql-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/map-column-type-to-graphql-type.spec.ts new file mode 100644 index 000000000..b9aa7eeba --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/map-column-type-to-graphql-type.spec.ts @@ -0,0 +1,77 @@ +import { + GraphQLBoolean, + GraphQLEnumType, + GraphQLID, + GraphQLInt, + GraphQLString, +} from 'graphql'; + +import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; +import { mapColumnTypeToGraphQLType } from 'src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util'; + +describe('mapColumnTypeToGraphQLType', () => { + test('should map uuid to GraphQLID', () => { + const column = new FieldMetadata(); + column.type = 'uuid'; + expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLID); + }); + + test('should map text, phone, email, and date to GraphQLString', () => { + const types = ['text', 'phone', 'email', 'date']; + types.forEach((type) => { + const column = new FieldMetadata(); + column.type = type; + expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLString); + }); + }); + + test('should map boolean to GraphQLBoolean', () => { + const column = new FieldMetadata(); + column.type = 'boolean'; + expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLBoolean); + }); + + test('should map number to GraphQLInt', () => { + const column = new FieldMetadata(); + column.type = 'number'; + expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLInt); + }); + + test('should create a GraphQLEnumType for enum fields', () => { + const column = new FieldMetadata(); + column.type = 'enum'; + column.nameSingular = 'Status'; + column.enums = ['ACTIVE', 'INACTIVE']; + const result = mapColumnTypeToGraphQLType(column); + + if (result instanceof GraphQLEnumType) { + expect(result.name).toBe('StatusEnum'); + + const values = result.getValues().map((value) => value.value); + expect(values).toContain('ACTIVE'); + expect(values).toContain('INACTIVE'); + } else { + fail('Result is not an instance of GraphQLEnumType'); + } + }); + + test('should map url to UrlObjectType or UrlInputType based on input flag', () => { + const column = new FieldMetadata(); + column.type = 'url'; + expect(mapColumnTypeToGraphQLType(column, false).name).toBe('Url'); + expect(mapColumnTypeToGraphQLType(column, true).name).toBe('UrlInput'); + }); + + test('should map money to MoneyObjectType or MoneyInputType based on input flag', () => { + const column = new FieldMetadata(); + column.type = 'money'; + expect(mapColumnTypeToGraphQLType(column, false).name).toBe('Money'); + expect(mapColumnTypeToGraphQLType(column, true).name).toBe('MoneyInput'); + }); + + test('should default to GraphQLString for unknown types', () => { + const column = new FieldMetadata(); + column.type = 'unknown'; + expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLString); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/clean-entity-name.util.ts b/server/src/tenant/schema-builder/utils/clean-entity-name.util.ts new file mode 100644 index 000000000..f5d6a26dc --- /dev/null +++ b/server/src/tenant/schema-builder/utils/clean-entity-name.util.ts @@ -0,0 +1,17 @@ +import { camelCase } from 'src/utils/camel-case'; + +export const cleanEntityName = (entityName: string) => { + // Remove all leading numbers + let camelCasedEntityName = entityName.replace(/^[0-9]+/, ''); + + // Trim the string + camelCasedEntityName = camelCasedEntityName.trim(); + + // Camel case the string + camelCasedEntityName = camelCase(camelCasedEntityName); + + // Remove all special characters but keep alphabets and numbers + camelCasedEntityName = camelCasedEntityName.replace(/[^a-zA-Z0-9]/g, ''); + + return camelCasedEntityName; +}; diff --git a/server/src/tenant/schema-builder/utils/generate-create-input-type.util.ts b/server/src/tenant/schema-builder/utils/generate-create-input-type.util.ts index dd5acefa9..6fe2a7534 100644 --- a/server/src/tenant/schema-builder/utils/generate-create-input-type.util.ts +++ b/server/src/tenant/schema-builder/utils/generate-create-input-type.util.ts @@ -1,4 +1,4 @@ -import { GraphQLInputObjectType, GraphQLNonNull } from 'graphql'; +import { GraphQLID, GraphQLInputObjectType, GraphQLNonNull } from 'graphql'; import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; @@ -15,14 +15,15 @@ export const generateCreateInputType = ( name: string, columns: FieldMetadata[], ): GraphQLInputObjectType => { - const fields: Record = {}; + const fields: Record = { + id: { type: GraphQLID }, + }; columns.forEach((column) => { - const graphqlType = mapColumnTypeToGraphQLType(column); + const graphqlType = mapColumnTypeToGraphQLType(column, true); - fields[column.displayName] = { + fields[column.nameSingular] = { type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType, - description: column.targetColumnName, }; }); diff --git a/server/src/tenant/schema-builder/utils/generate-object-type.util.ts b/server/src/tenant/schema-builder/utils/generate-object-type.util.ts index acf7e9482..07cdb672e 100644 --- a/server/src/tenant/schema-builder/utils/generate-object-type.util.ts +++ b/server/src/tenant/schema-builder/utils/generate-object-type.util.ts @@ -33,9 +33,8 @@ export const generateObjectType = ( columns.forEach((column) => { const graphqlType = mapColumnTypeToGraphQLType(column); - fields[column.displayName] = { + fields[column.nameSingular] = { type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType, - description: column.targetColumnName, }; }); diff --git a/server/src/tenant/schema-builder/utils/generate-update-input-type.util.ts b/server/src/tenant/schema-builder/utils/generate-update-input-type.util.ts index 8b79fe18d..874b4eac6 100644 --- a/server/src/tenant/schema-builder/utils/generate-update-input-type.util.ts +++ b/server/src/tenant/schema-builder/utils/generate-update-input-type.util.ts @@ -1,4 +1,4 @@ -import { GraphQLInputObjectType } from 'graphql'; +import { GraphQLID, GraphQLInputObjectType } from 'graphql'; import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; @@ -15,14 +15,15 @@ export const generateUpdateInputType = ( name: string, columns: FieldMetadata[], ): GraphQLInputObjectType => { - const fields: Record = {}; + const fields: Record = { + id: { type: GraphQLID }, + }; columns.forEach((column) => { - const graphqlType = mapColumnTypeToGraphQLType(column); + const graphqlType = mapColumnTypeToGraphQLType(column, true); // No GraphQLNonNull wrapping here, so all fields are optional - fields[column.displayName] = { + fields[column.nameSingular] = { type: graphqlType, - description: column.targetColumnName, }; }); diff --git a/server/src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util.ts b/server/src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util.ts index 7ea92eede..1523ffdad 100644 --- a/server/src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util.ts +++ b/server/src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util.ts @@ -2,23 +2,61 @@ import { GraphQLBoolean, GraphQLEnumType, GraphQLID, + GraphQLInputObjectType, GraphQLInt, + GraphQLObjectType, GraphQLString, } from 'graphql'; import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; +const UrlObjectType = new GraphQLObjectType({ + name: 'Url', + fields: { + text: { type: GraphQLString }, + link: { type: GraphQLString }, + }, +}); + +const UrlInputType = new GraphQLInputObjectType({ + name: 'UrlInput', + fields: { + text: { type: GraphQLString }, + link: { type: GraphQLString }, + }, +}); + +const MoneyObjectType = new GraphQLObjectType({ + name: 'Money', + fields: { + amount: { type: GraphQLInt }, + currency: { type: GraphQLString }, + }, +}); + +const MoneyInputType = new GraphQLInputObjectType({ + name: 'MoneyInput', + fields: { + amount: { type: GraphQLInt }, + currency: { type: GraphQLString }, + }, +}); + /** * Map the column type from field-metadata to its corresponding GraphQL type. * @param columnType Type of the column in the database. */ -export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => { +export const mapColumnTypeToGraphQLType = ( + column: FieldMetadata, + input = false, +) => { switch (column.type) { case 'uuid': return GraphQLID; case 'text': - case 'url': + case 'phone': + case 'email': case 'date': return GraphQLString; case 'boolean': @@ -27,9 +65,7 @@ export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => { return GraphQLInt; case 'enum': { if (column.enums && column.enums.length > 0) { - const enumName = `${pascalCase(column.objectId)}${pascalCase( - column.displayName, - )}Enum`; + const enumName = `${pascalCase(column.nameSingular)}Enum`; return new GraphQLEnumType({ name: enumName, @@ -39,6 +75,12 @@ export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => { }); } } + case 'url': { + return input ? UrlInputType : UrlObjectType; + } + case 'money': { + return input ? MoneyInputType : MoneyObjectType; + } default: return GraphQLString; } diff --git a/server/src/utils/pascal-case.ts b/server/src/utils/pascal-case.ts index 92f5b67ab..ecb13cf81 100644 --- a/server/src/utils/pascal-case.ts +++ b/server/src/utils/pascal-case.ts @@ -1,15 +1,10 @@ import isObject from 'lodash.isobject'; import lodashCamelCase from 'lodash.camelcase'; +import upperFirst from 'lodash.upperfirst'; import { PascalCase, PascalCasedPropertiesDeep } from 'type-fest'; -export const capitalizeFirstLetter = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); -}; - export const pascalCase = (text: T) => - capitalizeFirstLetter( - lodashCamelCase(text as unknown as string), - ) as PascalCase; + upperFirst(lodashCamelCase(text as unknown as string)) as PascalCase; export const pascalCaseDeep = (value: T): PascalCasedPropertiesDeep => { // Check if it's an array diff --git a/server/yarn.lock b/server/yarn.lock index af7f101b2..6f509e9db 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -3070,6 +3070,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.upperfirst@^4.3.7": + version "4.3.7" + resolved "https://registry.yarnpkg.com/@types/lodash.upperfirst/-/lodash.upperfirst-4.3.7.tgz#4c19bb87fbeedc13f182c9042f5b61e323d32993" + integrity sha512-CrBjoB4lO6h7tXNMBUl1eh/w0KdMosiEOXOoD5DMECsA/kDWo/WQfOt1KyGKVvgwK3I6cKAY6z8LymKiMazLFg== + dependencies: + "@types/lodash" "*" + "@types/lodash@*": version "4.14.195" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632" @@ -7048,6 +7055,11 @@ lodash.union@^4.6.0: resolved "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz" integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== +lodash.upperfirst@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" + integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== + lodash@4.17.21, lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"