feat: conditional schema based on column map instead of column field (#1978)

* feat: wip conditional schema based on column map instead of column field

* feat: conditionalSchema columnMap and singular plural

* fix: remove uuid fix

* feat: add name and label (singular/plural) drop old tableColumnName
This commit is contained in:
Jérémy M
2023-10-12 18:28:27 +02:00
committed by GitHub
parent 8fbad7d3ba
commit 4e993316a6
44 changed files with 1577 additions and 311 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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([

View File

@ -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()

View File

@ -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[];

View File

@ -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<FieldMetadata> {
@ -49,7 +44,8 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadata> {
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<FieldMetadata> {
const createdFieldMetadata = await super.createOne({
...record,
targetColumnName: generateColumnName(record.displayName), // deprecated
targetColumnMap: generateTargetColumnMap(record.type),
});
@ -69,15 +64,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadata> {
{
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<FieldMetadata> {
return createdFieldMetadata;
}
public async getFieldMetadataByDisplayNameAndObjectId(
name: string,
objectId: string,
): Promise<FieldMetadata | null> {
return await this.fieldMetadataRepository.findOne({
where: { displayName: name, objectId },
});
}
}

View File

@ -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}`);

View File

@ -0,0 +1,149 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MetadataNameLabelRefactoring1697126636202
implements MigrationInterface
{
name = 'MetadataNameLabelRefactoring1697126636202';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}

View File

@ -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()

View File

@ -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()

View File

@ -30,7 +30,7 @@ export class BeforeCreateOneObject<T extends ObjectMetadata>
);
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;

View File

@ -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' })

View File

@ -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<ObjectMetadata> {
}
override async createOne(record: ObjectMetadata): Promise<ObjectMetadata> {
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(

View File

@ -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);

View File

@ -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
}
}
}
`),
);
});
});

View File

@ -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}
}
}
}
`;
}
}

View File

@ -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<string, string>;
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<any[]> {
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<any> {
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<any[]> {
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<any> {
@ -86,9 +86,9 @@ export class PGGraphQLQueryRunner {
}
async updateOne(args: { id: string; data: any }): Promise<any> {
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];
}
}

View File

@ -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);
});
});

View File

@ -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));
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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<string, string>,
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`;
}
}

View File

@ -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;
};

View File

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

View File

@ -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<string, string>;
}
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');
}
}
}

View File

@ -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;
};

View File

@ -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<string, string>;
fields: FieldMetadata[];
}

View File

@ -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,

View File

@ -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('');
});
});

View File

@ -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');
}
});
});

View File

@ -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);
});
});

View File

@ -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);
}
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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<string, any> = {};
const fields: Record<string, any> = {
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,
};
});

View File

@ -33,9 +33,8 @@ export const generateObjectType = <TSource = any, TContext = any>(
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
fields[column.displayName] = {
fields[column.nameSingular] = {
type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType,
description: column.targetColumnName,
};
});

View File

@ -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<string, any> = {};
const fields: Record<string, any> = {
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,
};
});

View File

@ -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;
}

View File

@ -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 = <T>(text: T) =>
capitalizeFirstLetter(
lodashCamelCase(text as unknown as string),
) as PascalCase<T>;
upperFirst(lodashCamelCase(text as unknown as string)) as PascalCase<T>;
export const pascalCaseDeep = <T>(value: T): PascalCasedPropertiesDeep<T> => {
// Check if it's an array

View File

@ -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"