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:
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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[];
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
51
server/src/tenant/entity-resolver/utils/parse-result.util.ts
Normal file
51
server/src/tenant/entity-resolver/utils/parse-result.util.ts
Normal 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;
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user