feat: refactor folder structure (#4498)

* feat: wip refactor folder structure

* Fix

* fix position

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-03-15 14:40:58 +01:00
committed by GitHub
parent 52f1b3ac98
commit 94487f6737
760 changed files with 3215 additions and 3155 deletions

View File

@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
DataSourceOptions,
OneToMany,
} from 'typeorm';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
export type DataSourceType = DataSourceOptions['type'];
@Entity('dataSource')
export class DataSourceEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
label: string;
@Column({ nullable: true })
url: string;
@Column({ nullable: true })
schema: string;
@Column({ type: 'enum', enum: ['postgres'], default: 'postgres' })
type: DataSourceType;
@Column({ default: false })
isRemote: boolean;
@OneToMany(() => ObjectMetadataEntity, (object) => object.dataSource, {
cascade: true,
})
objects: ObjectMetadataEntity[];
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DataSourceService } from './data-source.service';
import { DataSourceEntity } from './data-source.entity';
@Module({
imports: [TypeOrmModule.forFeature([DataSourceEntity], 'metadata')],
providers: [DataSourceService],
exports: [DataSourceService],
})
export class DataSourceModule {}

View File

@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindManyOptions, Repository } from 'typeorm';
import { DataSourceEntity } from './data-source.entity';
@Injectable()
export class DataSourceService {
constructor(
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceMetadataRepository: Repository<DataSourceEntity>,
) {}
async createDataSourceMetadata(
workspaceId: string,
workspaceSchema: string,
): Promise<DataSourceEntity> {
// TODO: Double check if this is the correct way to do this
const dataSource = await this.dataSourceMetadataRepository.findOne({
where: { workspaceId },
});
if (dataSource) {
return dataSource;
}
return this.dataSourceMetadataRepository.save({
workspaceId,
schema: workspaceSchema,
});
}
async getManyDataSourceMetadata(
options: FindManyOptions<DataSourceEntity> = {},
): Promise<DataSourceEntity[]> {
return this.dataSourceMetadataRepository.find(options);
}
async getDataSourcesMetadataFromWorkspaceId(
workspaceId: string,
): Promise<DataSourceEntity[]> {
return this.dataSourceMetadataRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
}
async getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId: string,
): Promise<DataSourceEntity> {
return this.dataSourceMetadataRepository.findOneOrFail({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
}
async delete(workspaceId: string): Promise<void> {
await this.dataSourceMetadataRepository.delete({ workspaceId });
}
}

View File

@ -0,0 +1,26 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
export function IsValidName(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsValidName',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return /^(?!(?:not|or|and|Int|Float|Boolean|String|ID)$)[^'\"\\;.=*/]+$/.test(
value,
);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} has failed the name validation check`;
},
},
});
};
}

View File

@ -0,0 +1,82 @@
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
export const currencyFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
const inferredFieldMetadata = fieldMetadata as
| FieldMetadataInterface<FieldMetadataType.CURRENCY>
| undefined;
const targetColumnMap = inferredFieldMetadata
? generateTargetColumnMap(
inferredFieldMetadata.type,
inferredFieldMetadata.isCustom ?? false,
inferredFieldMetadata.name,
)
: {
amountMicros: 'amountMicros',
currencyCode: 'currencyCode',
};
return [
{
id: 'amountMicros',
type: FieldMetadataType.NUMERIC,
objectMetadataId: FieldMetadataType.CURRENCY.toString(),
name: 'amountMicros',
label: 'AmountMicros',
targetColumnMap: {
value: targetColumnMap.amountMicros,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.amountMicros ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.NUMERIC>,
{
id: 'currencyCode',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.CURRENCY.toString(),
name: 'currencyCode',
label: 'Currency Code',
targetColumnMap: {
value: targetColumnMap.currencyCode,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.currencyCode ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
];
};
export const currencyObjectDefinition = {
id: FieldMetadataType.CURRENCY.toString(),
nameSingular: 'currency',
namePlural: 'currency',
labelSingular: 'Currency',
labelPlural: 'Currency',
targetTableName: '',
fields: currencyFields(),
fromRelations: [],
toRelations: [],
isActive: true,
isSystem: true,
isCustom: false,
} satisfies ObjectMetadataInterface;
export type CurrencyMetadata = {
amountMicros: number;
currencyCode: string;
};

View File

@ -0,0 +1,82 @@
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
export const fullNameFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
const inferredFieldMetadata = fieldMetadata as
| FieldMetadataInterface<FieldMetadataType.FULL_NAME>
| undefined;
const targetColumnMap = inferredFieldMetadata
? generateTargetColumnMap(
inferredFieldMetadata.type,
inferredFieldMetadata.isCustom ?? false,
inferredFieldMetadata.name,
)
: {
firstName: 'firstName',
lastName: 'lastName',
};
return [
{
id: 'firstName',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.FULL_NAME.toString(),
name: 'firstName',
label: 'First Name',
targetColumnMap: {
value: targetColumnMap.firstName,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.firstName ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'lastName',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.FULL_NAME.toString(),
name: 'lastName',
label: 'Last Name',
targetColumnMap: {
value: targetColumnMap.lastName,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.lastName ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
];
};
export const fullNameObjectDefinition = {
id: FieldMetadataType.FULL_NAME.toString(),
nameSingular: 'fullName',
namePlural: 'fullName',
labelSingular: 'FullName',
labelPlural: 'FullName',
targetTableName: '',
fields: fullNameFields(),
fromRelations: [],
toRelations: [],
isActive: true,
isSystem: true,
isCustom: false,
} satisfies ObjectMetadataInterface;
export type FullNameMetadata = {
firstName: string;
lastName: string;
};

View File

@ -0,0 +1,19 @@
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { currencyFields } from 'src/engine-metadata/field-metadata/composite-types/currency.composite-type';
import { fullNameFields } from 'src/engine-metadata/field-metadata/composite-types/full-name.composite-type';
import { linkFields } from 'src/engine-metadata/field-metadata/composite-types/link.composite-type';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export type CompositeFieldsDefinitionFunction = (
fieldMetadata?: FieldMetadataInterface,
) => FieldMetadataInterface[];
export const compositeDefinitions = new Map<
string,
CompositeFieldsDefinitionFunction
>([
[FieldMetadataType.LINK, linkFields],
[FieldMetadataType.CURRENCY, currencyFields],
[FieldMetadataType.FULL_NAME, fullNameFields],
]);

View File

@ -0,0 +1,82 @@
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
export const linkFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
const inferredFieldMetadata = fieldMetadata as
| FieldMetadataInterface<FieldMetadataType.LINK>
| undefined;
const targetColumnMap = inferredFieldMetadata
? generateTargetColumnMap(
inferredFieldMetadata.type,
inferredFieldMetadata.isCustom ?? false,
inferredFieldMetadata.name,
)
: {
label: 'label',
url: 'url',
};
return [
{
id: 'label',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.LINK.toString(),
name: 'label',
label: 'Label',
targetColumnMap: {
value: targetColumnMap.label,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.label ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
{
id: 'url',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.LINK.toString(),
name: 'url',
label: 'Url',
targetColumnMap: {
value: targetColumnMap.url,
},
isNullable: true,
...(inferredFieldMetadata
? {
defaultValue: {
value: inferredFieldMetadata.defaultValue?.url ?? null,
},
}
: {}),
} satisfies FieldMetadataInterface<FieldMetadataType.TEXT>,
];
};
export const linkObjectDefinition = {
id: FieldMetadataType.LINK.toString(),
nameSingular: 'link',
namePlural: 'link',
labelSingular: 'Link',
labelPlural: 'Link',
targetTableName: '',
fields: linkFields(),
fromRelations: [],
toRelations: [],
isActive: true,
isSystem: true,
isCustom: false,
} satisfies ObjectMetadataInterface;
export type LinkMetadata = {
label: string;
url: string;
};

View File

@ -0,0 +1,27 @@
import { Field, InputType, OmitType } from '@nestjs/graphql';
import { IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
@InputType()
export class CreateFieldInput extends OmitType(
FieldMetadataDTO,
['id', 'createdAt', 'updatedAt'] as const,
InputType,
) {
@IsUUID()
@Field()
objectMetadataId: string;
}
@InputType()
export class CreateOneFieldMetadataInput {
@Type(() => CreateFieldInput)
@ValidateNested()
@Field(() => CreateFieldInput, {
description: 'The record to create',
})
field!: CreateFieldInput;
}

View File

@ -0,0 +1,86 @@
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
IsNumberString,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
export class FieldMetadataDefaultValueString {
@ValidateIf((_object, value) => value !== null)
@IsString()
value: string | null;
}
export class FieldMetadataDefaultValueNumber {
@ValidateIf((_object, value) => value !== null)
@IsNumber()
value: number | null;
}
export class FieldMetadataDefaultValueBoolean {
@ValidateIf((_object, value) => value !== null)
@IsBoolean()
value: boolean | null;
}
export class FieldMetadataDefaultValueStringArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
@IsString({ each: true })
value: string[] | null;
}
export class FieldMetadataDefaultValueDateTime {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value: Date | null;
}
export class FieldMetadataDefaultValueLink {
@ValidateIf((_object, value) => value !== null)
@IsString()
label: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
url: string | null;
}
export class FieldMetadataDefaultValueCurrency {
@ValidateIf((_object, value) => value !== null)
@IsNumberString()
amountMicros: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
currencyCode: string | null;
}
export class FieldMetadataDefaultValueFullName {
@ValidateIf((_object, value) => value !== null)
@IsString()
firstName: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
lastName: string | null;
}
export class FieldMetadataDynamicDefaultValueUuid {
@Matches('uuid')
@IsNotEmpty()
@IsString()
type: 'uuid';
}
export class FieldMetadataDynamicDefaultValueNow {
@Matches('now')
@IsNotEmpty()
@IsString()
type: 'now';
}

View File

@ -0,0 +1,136 @@
import {
Field,
HideField,
ID,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-type-json';
import {
Authorize,
BeforeDeleteOne,
FilterableField,
IDField,
QueryOptions,
Relation,
} from '@ptc-org/nestjs-query-graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Validate,
} from 'class-validator';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationMetadataDTO } from 'src/engine-metadata/relation-metadata/dtos/relation-metadata.dto';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { BeforeDeleteOneField } from 'src/engine-metadata/field-metadata/hooks/before-delete-one-field.hook';
import { IsFieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine-metadata/field-metadata/validators/is-field-metadata-options.validator';
import { IsValidName } from 'src/engine-metadata/decorators/is-valid-name.decorator';
registerEnumType(FieldMetadataType, {
name: 'FieldMetadataType',
description: 'Type of the field',
});
@ObjectType('field')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.user?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneField)
@Relation('toRelationMetadata', () => RelationMetadataDTO, {
nullable: true,
})
@Relation('fromRelationMetadata', () => RelationMetadataDTO, {
nullable: true,
})
export class FieldMetadataDTO<
T extends FieldMetadataType | 'default' = 'default',
> {
@IsUUID()
@IsNotEmpty()
@IDField(() => ID)
id: string;
@IsEnum(FieldMetadataType)
@IsNotEmpty()
@Field(() => FieldMetadataType)
type: FieldMetadataType;
@IsString()
@IsNotEmpty()
@Field()
@IsValidName()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isCustom?: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isActive?: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isSystem?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isNullable?: boolean;
@Validate(IsFieldMetadataDefaultValue)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue<T>;
@Validate(IsFieldMetadataOptions)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions<T>;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}

View File

@ -0,0 +1,26 @@
import { IsString, IsNumber, IsOptional, IsNotEmpty } from 'class-validator';
import { IsValidGraphQLEnumName } from 'src/engine-metadata/field-metadata/validators/is-valid-graphql-enum-name.validator';
export class FieldMetadataDefaultOption {
@IsOptional()
@IsString()
id?: string;
@IsNumber()
position: number;
@IsNotEmpty()
@IsString()
label: string;
@IsNotEmpty()
@IsValidGraphQLEnumName()
value: string;
}
export class FieldMetadataComplexOption extends FieldMetadataDefaultOption {
@IsNotEmpty()
@IsString()
color: string;
}

View File

@ -0,0 +1,43 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsEnum, IsNotEmpty } from 'class-validator';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
import { ObjectMetadataDTO } from 'src/engine-metadata/object-metadata/dtos/object-metadata.dto';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export enum RelationDefinitionType {
ONE_TO_ONE = RelationMetadataType.ONE_TO_ONE,
ONE_TO_MANY = RelationMetadataType.ONE_TO_MANY,
MANY_TO_MANY = RelationMetadataType.MANY_TO_MANY,
MANY_TO_ONE = 'MANY_TO_ONE',
}
registerEnumType(RelationDefinitionType, {
name: 'RelationDefinitionType',
description: 'Relation definition type',
});
@ObjectType('RelationDefinition')
export class RelationDefinitionDTO {
@IsNotEmpty()
@Field(() => ObjectMetadataDTO)
sourceObjectMetadata: ObjectMetadataDTO;
@IsNotEmpty()
@Field(() => ObjectMetadataDTO)
targetObjectMetadata: ObjectMetadataDTO;
@IsNotEmpty()
@Field(() => FieldMetadataDTO)
sourceFieldMetadata: FieldMetadataDTO;
@IsNotEmpty()
@Field(() => FieldMetadataDTO)
targetFieldMetadata: FieldMetadataDTO;
@IsEnum(RelationDefinitionType)
@IsNotEmpty()
@Field(() => RelationDefinitionType)
direction: RelationDefinitionType;
}

View File

@ -0,0 +1,40 @@
import {
Field,
HideField,
ID,
InputType,
OmitType,
PartialType,
} from '@nestjs/graphql';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
@InputType()
export class UpdateFieldInput extends OmitType(
PartialType(FieldMetadataDTO, InputType),
['id', 'type', 'createdAt', 'updatedAt'] as const,
) {
@HideField()
id: string;
@HideField()
workspaceId: string;
}
@InputType()
export class UpdateOneFieldMetadataInput {
@IsUUID()
@IsNotEmpty()
@Field(() => ID, { description: 'The id of the record to update' })
id!: string;
@Type(() => UpdateFieldInput)
@ValidateNested()
@Field(() => UpdateFieldInput, {
description: 'The record to update',
})
update!: UpdateFieldInput;
}

View File

@ -0,0 +1,122 @@
import {
Entity,
Unique,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
OneToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataTargetColumnMap } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export enum FieldMetadataType {
UUID = 'UUID',
TEXT = 'TEXT',
PHONE = 'PHONE',
EMAIL = 'EMAIL',
DATE_TIME = 'DATE_TIME',
BOOLEAN = 'BOOLEAN',
NUMBER = 'NUMBER',
NUMERIC = 'NUMERIC',
PROBABILITY = 'PROBABILITY',
LINK = 'LINK',
CURRENCY = 'CURRENCY',
FULL_NAME = 'FULL_NAME',
RATING = 'RATING',
SELECT = 'SELECT',
MULTI_SELECT = 'MULTI_SELECT',
RELATION = 'RELATION',
POSITION = 'POSITION',
}
@Entity('fieldMetadata')
@Unique('IndexOnNameObjectMetadataIdAndWorkspaceIdUnique', [
'name',
'objectMetadataId',
'workspaceId',
])
export class FieldMetadataEntity<
T extends FieldMetadataType | 'default' = 'default',
> implements FieldMetadataInterface<T>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false, type: 'uuid' })
objectMetadataId: string;
@ManyToOne(() => ObjectMetadataEntity, (object) => object.fields, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'objectMetadataId' })
object: ObjectMetadataEntity;
@Column({ nullable: false })
type: FieldMetadataType;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: false, type: 'jsonb' })
targetColumnMap: FieldMetadataTargetColumnMap<T>;
@Column({ nullable: true, type: 'jsonb' })
defaultValue: FieldMetadataDefaultValue<T>;
@Column({ nullable: true, type: 'text' })
description: string;
@Column({ nullable: true })
icon: string;
@Column('jsonb', { nullable: true })
options: FieldMetadataOptions<T>;
@Column({ default: false })
isCustom: boolean;
@Column({ default: false })
isActive: boolean;
@Column({ default: false })
isSystem: boolean;
@Column({ nullable: true, default: true })
isNullable: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@OneToOne(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.fromFieldMetadata,
)
fromRelationMetadata: RelationMetadataEntity;
@OneToOne(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.toFieldMetadata,
)
toRelationMetadata: RelationMetadataEntity;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,78 @@
import { Module } from '@nestjs/common';
import {
NestjsQueryGraphQLModule,
PagingStrategies,
} from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { IsFieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { FieldMetadataResolver } from 'src/engine-metadata/field-metadata/field-metadata.resolver';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
import { IsFieldMetadataOptions } from 'src/engine-metadata/field-metadata/validators/is-field-metadata-options.validator';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { FieldMetadataService } from './field-metadata.service';
import { FieldMetadataEntity } from './field-metadata.entity';
import { CreateFieldInput } from './dtos/create-field.input';
import { UpdateFieldInput } from './dtos/update-field.input';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[FieldMetadataEntity, RelationMetadataEntity],
'metadata',
),
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
ObjectMetadataModule,
DataSourceModule,
TypeORMModule,
],
services: [IsFieldMetadataDefaultValue, FieldMetadataService],
resolvers: [
{
EntityClass: FieldMetadataEntity,
DTOClass: FieldMetadataDTO,
CreateDTOClass: CreateFieldInput,
UpdateDTOClass: UpdateFieldInput,
ServiceClass: FieldMetadataService,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
update: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
delete: { many: { disabled: true } },
guards: [JwtAuthGuard],
},
],
}),
],
providers: [
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataResolver,
],
exports: [FieldMetadataService],
})
export class FieldMetadataModule {}

View File

@ -0,0 +1,52 @@
import { UseGuards } from '@nestjs/common';
import {
Args,
Mutation,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Workspace } from 'src/engine/modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { CreateOneFieldMetadataInput } from 'src/engine-metadata/field-metadata/dtos/create-field.input';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
import { RelationDefinitionDTO } from 'src/engine-metadata/field-metadata/dtos/relation-definition.dto';
import { UpdateOneFieldMetadataInput } from 'src/engine-metadata/field-metadata/dtos/update-field.input';
import { FieldMetadataService } from 'src/engine-metadata/field-metadata/field-metadata.service';
@UseGuards(JwtAuthGuard)
@Resolver(() => FieldMetadataDTO)
export class FieldMetadataResolver {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@Mutation(() => FieldMetadataDTO)
createOneField(
@Args('input') input: CreateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.fieldMetadataService.createOne({
...input.field,
workspaceId,
});
}
@Mutation(() => FieldMetadataDTO)
updateOneField(
@Args('input') input: UpdateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.fieldMetadataService.updateOne(input.id, {
...input.update,
workspaceId,
});
}
@ResolveField(() => RelationDefinitionDTO, { nullable: true })
async relationDefinition(
@Parent() fieldMetadata: FieldMetadataDTO,
): Promise<RelationDefinitionDTO | null> {
return await this.fieldMetadataService.getRelationDefinition(fieldMetadata);
}
}

View File

@ -0,0 +1,420 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { v4 as uuidV4 } from 'uuid';
import { FindOneOptions, Repository } from 'typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { WorkspaceMigrationService } from 'src/engine-metadata/workspace-migration/workspace-migration.service';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { CreateFieldInput } from 'src/engine-metadata/field-metadata/dtos/create-field.input';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
import { UpdateFieldInput } from 'src/engine-metadata/field-metadata/dtos/update-field.input';
import { WorkspaceMigrationFactory } from 'src/engine-metadata/workspace-migration/workspace-migration.factory';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
import { generateNullable } from 'src/engine-metadata/field-metadata/utils/generate-nullable';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
import {
RelationDefinitionDTO,
RelationDefinitionType,
} from 'src/engine-metadata/field-metadata/dtos/relation-definition.dto';
import {
RelationMetadataEntity,
RelationMetadataType,
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import {
FieldMetadataEntity,
FieldMetadataType,
} from './field-metadata.entity';
import { isEnumFieldMetadataType } from './utils/is-enum-field-metadata-type.util';
import { generateRatingOptions } from './utils/generate-rating-optionts.util';
import { generateDefaultValue } from './utils/generate-default-value';
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
constructor(
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
private readonly objectMetadataService: ObjectMetadataService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
) {
super(fieldMetadataRepository);
}
override async createOne(
fieldMetadataInput: CreateFieldInput,
): Promise<FieldMetadataEntity> {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
fieldMetadataInput.workspaceId,
{
where: {
id: fieldMetadataInput.objectMetadataId,
},
},
);
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
// Double check in case the service is directly called
if (isEnumFieldMetadataType(fieldMetadataInput.type)) {
if (
!fieldMetadataInput.options &&
fieldMetadataInput.type !== FieldMetadataType.RATING
) {
throw new BadRequestException('Options are required for enum fields');
}
}
// Generate options for rating fields
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
fieldMetadataInput.options = generateRatingOptions();
}
const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({
where: {
name: fieldMetadataInput.name,
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
},
});
if (fieldAlreadyExists) {
throw new ConflictException('Field already exists');
}
const createdFieldMetadata = await super.createOne({
...fieldMetadataInput,
targetColumnMap: generateTargetColumnMap(
fieldMetadataInput.type,
true,
fieldMetadataInput.name,
),
isNullable: generateNullable(
fieldMetadataInput.type,
fieldMetadataInput.isNullable,
),
defaultValue:
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type),
options: fieldMetadataInput.options
? fieldMetadataInput.options.map((option) => ({
...option,
id: uuidV4(),
}))
: undefined,
isActive: true,
isCustom: true,
});
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdFieldMetadata.name}`),
fieldMetadataInput.workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: 'alter',
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
} satisfies WorkspaceMigrationTableAction,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
fieldMetadataInput.workspaceId,
);
// TODO: Move viewField creation to a cdc scheduler
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
fieldMetadataInput.workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
// TODO: use typeorm repository
const view = await workspaceDataSource?.query(
`SELECT id FROM ${dataSourceMetadata.schema}."view"
WHERE "objectMetadataId" = '${createdFieldMetadata.objectMetadataId}'`,
);
const existingViewFields = await workspaceDataSource?.query(
`SELECT * FROM ${dataSourceMetadata.schema}."viewField"
WHERE "viewId" = '${view[0].id}'`,
);
const lastPosition = existingViewFields
.map((viewField) => viewField.position)
.reduce((acc, position) => {
if (position > acc) {
return position;
}
return acc;
}, -1);
await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."viewField"
("fieldMetadataId", "position", "isVisible", "size", "viewId")
VALUES ('${createdFieldMetadata.id}', '${lastPosition + 1}', true, 180, '${
view[0].id
}')`,
);
return createdFieldMetadata;
}
override async updateOne(
id: string,
fieldMetadataInput: UpdateFieldInput,
): Promise<FieldMetadataEntity> {
const existingFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
id,
workspaceId: fieldMetadataInput.workspaceId,
},
});
if (!existingFieldMetadata) {
throw new NotFoundException('Field does not exist');
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
fieldMetadataInput.workspaceId,
{
where: {
id: existingFieldMetadata?.objectMetadataId,
},
},
);
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
if (
objectMetadata.labelIdentifierFieldMetadataId ===
existingFieldMetadata.id &&
fieldMetadataInput.isActive === false
) {
throw new BadRequestException('Cannot deactivate label identifier field');
}
if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) {
if (!option.id) {
throw new BadRequestException('Option id is required');
}
}
}
const updatableFieldInput =
existingFieldMetadata.isCustom === false
? this.buildUpdatableStandardFieldInput(
fieldMetadataInput,
existingFieldMetadata,
)
: fieldMetadataInput;
const updatedFieldMetadata = await super.updateOne(id, {
...updatableFieldInput,
defaultValue:
// Todo: we need to handle default value for all field types. Right now we are only allowing update for SELECt
existingFieldMetadata.type !== FieldMetadataType.SELECT
? existingFieldMetadata.defaultValue
: updatableFieldInput.defaultValue
? // Todo: we need to rework DefaultValue typing and format to be simpler, there is no need to have this complexity
{ value: updatableFieldInput.defaultValue as unknown as string }
: null,
// If the name is updated, the targetColumnMap should be updated as well
targetColumnMap: updatableFieldInput.name
? generateTargetColumnMap(
existingFieldMetadata.type,
existingFieldMetadata.isCustom,
updatableFieldInput.name,
)
: existingFieldMetadata.targetColumnMap,
});
if (
fieldMetadataInput.name ||
updatableFieldInput.options ||
updatableFieldInput.defaultValue
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${updatedFieldMetadata.name}`),
existingFieldMetadata.workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: 'alter',
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
existingFieldMetadata,
updatedFieldMetadata,
),
} satisfies WorkspaceMigrationTableAction,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
updatedFieldMetadata.workspaceId,
);
}
return updatedFieldMetadata;
}
public async findOneOrFail(
id: string,
options?: FindOneOptions<FieldMetadataEntity>,
) {
const fieldMetadata = await this.fieldMetadataRepository.findOne({
...options,
where: {
...options?.where,
id,
},
});
if (!fieldMetadata) {
throw new NotFoundException('Field does not exist');
}
return fieldMetadata;
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<FieldMetadataEntity>,
) {
return this.fieldMetadataRepository.findOne({
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async deleteFieldsMetadata(workspaceId: string) {
await this.fieldMetadataRepository.delete({ workspaceId });
}
private buildUpdatableStandardFieldInput(
fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: FieldMetadataEntity,
) {
let fieldMetadataInputOverrided = {};
fieldMetadataInputOverrided = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
};
if (existingFieldMetadata.type === FieldMetadataType.SELECT) {
fieldMetadataInputOverrided = {
...fieldMetadataInputOverrided,
options: fieldMetadataInput.options,
};
}
return fieldMetadataInputOverrided as UpdateFieldInput;
}
public async getRelationDefinition(
fieldMetadata: FieldMetadataDTO,
): Promise<RelationDefinitionDTO | null> {
if (fieldMetadata.type !== FieldMetadataType.RELATION) {
return null;
}
const foundRelationMetadata = await this.relationMetadataRepository.findOne(
{
where: [
{ fromFieldMetadataId: fieldMetadata.id },
{ toFieldMetadataId: fieldMetadata.id },
],
relations: [
'fromObjectMetadata',
'toObjectMetadata',
'fromFieldMetadata',
'toFieldMetadata',
],
},
);
if (!foundRelationMetadata) {
throw new Error('RelationMetadata not found');
}
const isRelationFromSource =
foundRelationMetadata.fromFieldMetadata.id === fieldMetadata.id;
// TODO: implement MANY_TO_MANY
if (
foundRelationMetadata.relationType === RelationMetadataType.MANY_TO_MANY
) {
throw new Error(`
Relation type ${foundRelationMetadata.relationType} not supported
`);
}
if (isRelationFromSource) {
const direction =
foundRelationMetadata.relationType === RelationMetadataType.ONE_TO_ONE
? RelationDefinitionType.ONE_TO_ONE
: RelationDefinitionType.ONE_TO_MANY;
return {
sourceObjectMetadata: foundRelationMetadata.fromObjectMetadata,
sourceFieldMetadata: foundRelationMetadata.fromFieldMetadata,
targetObjectMetadata: foundRelationMetadata.toObjectMetadata,
targetFieldMetadata: foundRelationMetadata.toFieldMetadata,
direction,
};
} else {
const direction =
foundRelationMetadata.relationType === RelationMetadataType.ONE_TO_ONE
? RelationDefinitionType.ONE_TO_ONE
: RelationDefinitionType.MANY_TO_ONE;
return {
sourceObjectMetadata: foundRelationMetadata.toObjectMetadata,
sourceFieldMetadata: foundRelationMetadata.toFieldMetadata,
targetObjectMetadata: foundRelationMetadata.fromObjectMetadata,
targetFieldMetadata: foundRelationMetadata.fromFieldMetadata,
direction,
};
}
}
}

View File

@ -0,0 +1,56 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine-metadata/field-metadata/field-metadata.service';
@Injectable()
export class BeforeDeleteOneField implements BeforeDeleteOneHook<any> {
constructor(readonly fieldMetadataService: FieldMetadataService) {}
async run(
instance: DeleteOneInputType,
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
}
if (!fieldMetadata.isCustom) {
throw new BadRequestException("Standard Fields can't be deleted");
}
if (fieldMetadata.isActive) {
throw new BadRequestException("Active fields can't be deleted");
}
if (fieldMetadata.type === FieldMetadataType.RELATION) {
throw new BadRequestException(
"Relation fields can't be deleted, you need to delete the RelationMetadata instead",
);
}
return instance;
}
}

View File

@ -0,0 +1,86 @@
import {
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDynamicDefaultValueNow,
FieldMetadataDynamicDefaultValueUuid,
} from 'src/engine-metadata/field-metadata/dtos/default-value.input';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
type FieldMetadataScalarDefaultValue =
| FieldMetadataDefaultValueString
| FieldMetadataDefaultValueNumber
| FieldMetadataDefaultValueBoolean
| FieldMetadataDefaultValueDateTime;
export type FieldMetadataDynamicDefaultValue =
| FieldMetadataDynamicDefaultValueUuid
| FieldMetadataDynamicDefaultValueNow;
type AllFieldMetadataDefaultValueTypes =
| FieldMetadataScalarDefaultValue
| FieldMetadataDynamicDefaultValue
| FieldMetadataDefaultValueLink
| FieldMetadataDefaultValueCurrency
| FieldMetadataDefaultValueFullName;
type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.UUID]:
| FieldMetadataDefaultValueString
| FieldMetadataDynamicDefaultValueUuid;
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
[FieldMetadataType.DATE_TIME]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDynamicDefaultValueNow;
[FieldMetadataType.BOOLEAN]: FieldMetadataDefaultValueBoolean;
[FieldMetadataType.NUMBER]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.POSITION]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.NUMERIC]: FieldMetadataDefaultValueString;
[FieldMetadataType.PROBABILITY]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.LINK]: FieldMetadataDefaultValueLink;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName;
[FieldMetadataType.RATING]: FieldMetadataDefaultValueString;
[FieldMetadataType.SELECT]: FieldMetadataDefaultValueString;
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray;
};
type DefaultValueByFieldMetadata<T extends FieldMetadataType | 'default'> = [
T,
] extends [keyof FieldMetadataDefaultValueMapping]
? FieldMetadataDefaultValueMapping[T] | null
: T extends 'default'
? AllFieldMetadataDefaultValueTypes | null
: never;
export type FieldMetadataDefaultValue<
T extends FieldMetadataType | 'default' = 'default',
> = DefaultValueByFieldMetadata<T>;
type FieldMetadataDefaultValueExtractNestedType<T> = T extends {
value: infer U;
}
? U
: T extends object
? { [K in keyof T]: T[K] } extends { value: infer V }
? V
: T[keyof T]
: never;
type FieldMetadataDefaultValueExtractedTypes = {
[K in keyof FieldMetadataDefaultValueMapping]: FieldMetadataDefaultValueExtractNestedType<
FieldMetadataDefaultValueMapping[K]
>;
};
export type FieldMetadataDefaultSerializableValue =
| FieldMetadataDefaultValueExtractedTypes[keyof FieldMetadataDefaultValueExtractedTypes]
| FieldMetadataDynamicDefaultValue
| null;

View File

@ -0,0 +1,22 @@
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine-metadata/field-metadata/dtos/options.input';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
type FieldMetadataOptionsMapping = {
[FieldMetadataType.RATING]: FieldMetadataDefaultOption[];
[FieldMetadataType.SELECT]: FieldMetadataComplexOption[];
[FieldMetadataType.MULTI_SELECT]: FieldMetadataComplexOption[];
};
type OptionsByFieldMetadata<T extends FieldMetadataType | 'default'> =
T extends keyof FieldMetadataOptionsMapping
? FieldMetadataOptionsMapping[T]
: T extends 'default'
? FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]
: never;
export type FieldMetadataOptions<
T extends FieldMetadataType | 'default' = 'default',
> = OptionsByFieldMetadata<T>;

View File

@ -0,0 +1,42 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export interface FieldMetadataTargetColumnMapValue {
value: string;
}
export interface FieldMetadataTargetColumnMapLink {
label: string;
url: string;
}
export interface FieldMetadataTargetColumnMapCurrency {
amountMicros: string;
currencyCode: string;
}
export interface FieldMetadataTargetColumnMapFullName {
firstName: string;
lastName: string;
}
type AllFieldMetadataTypes = {
[key: string]: string;
};
type FieldMetadataTypeMapping = {
[FieldMetadataType.LINK]: FieldMetadataTargetColumnMapLink;
[FieldMetadataType.CURRENCY]: FieldMetadataTargetColumnMapCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataTargetColumnMapFullName;
};
type TypeByFieldMetadata<T extends FieldMetadataType | 'default'> = [
T,
] extends [keyof FieldMetadataTypeMapping]
? FieldMetadataTypeMapping[T]
: T extends 'default'
? AllFieldMetadataTypes
: FieldMetadataTargetColumnMapValue;
export type FieldMetadataTargetColumnMap<
T extends FieldMetadataType | 'default' = 'default',
> = TypeByFieldMetadata<T>;

View File

@ -0,0 +1,25 @@
import { FieldMetadataTargetColumnMap } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export interface FieldMetadataInterface<
T extends FieldMetadataType | 'default' = 'default',
> {
id: string;
type: FieldMetadataType;
name: string;
label: string;
targetColumnMap: FieldMetadataTargetColumnMap<T>;
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
objectMetadataId: string;
workspaceId?: string;
description?: string;
isNullable?: boolean;
fromRelationMetadata?: RelationMetadataEntity;
toRelationMetadata?: RelationMetadataEntity;
isCustom?: boolean;
}

View File

@ -0,0 +1,18 @@
import { RelationMetadataInterface } from './relation-metadata.interface';
import { FieldMetadataInterface } from './field-metadata.interface';
export interface ObjectMetadataInterface {
id: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
targetTableName: string;
fromRelations: RelationMetadataInterface[];
toRelations: RelationMetadataInterface[];
fields: FieldMetadataInterface[];
isSystem: boolean;
isCustom: boolean;
isActive: boolean;
}

View File

@ -0,0 +1,22 @@
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { ObjectMetadataInterface } from './object-metadata.interface';
import { FieldMetadataInterface } from './field-metadata.interface';
export interface RelationMetadataInterface {
id: string;
relationType: RelationMetadataType;
fromObjectMetadataId: string;
fromObjectMetadata: ObjectMetadataInterface;
toObjectMetadataId: string;
toObjectMetadata: ObjectMetadataInterface;
fromFieldMetadataId: string;
fromFieldMetadata: FieldMetadataInterface;
toFieldMetadataId: string;
toFieldMetadata: FieldMetadataInterface;
}

View File

@ -0,0 +1,27 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateNullable } from 'src/engine-metadata/field-metadata/utils/generate-nullable';
describe('generateNullable', () => {
it('should generate a nullable value false for TEXT, EMAIL, PHONE no matter what the input is', () => {
expect(generateNullable(FieldMetadataType.TEXT, false)).toEqual(false);
expect(generateNullable(FieldMetadataType.PHONE, false)).toEqual(false);
expect(generateNullable(FieldMetadataType.EMAIL, false)).toEqual(false);
expect(generateNullable(FieldMetadataType.TEXT, true)).toEqual(false);
expect(generateNullable(FieldMetadataType.PHONE, true)).toEqual(false);
expect(generateNullable(FieldMetadataType.EMAIL, true)).toEqual(false);
expect(generateNullable(FieldMetadataType.TEXT)).toEqual(false);
expect(generateNullable(FieldMetadataType.PHONE)).toEqual(false);
expect(generateNullable(FieldMetadataType.EMAIL)).toEqual(false);
});
it('should should return true if no input is given', () => {
expect(generateNullable(FieldMetadataType.DATE_TIME)).toEqual(true);
});
it('should should return the input value if the input value is given', () => {
expect(generateNullable(FieldMetadataType.DATE_TIME, true)).toEqual(true);
expect(generateNullable(FieldMetadataType.DATE_TIME, false)).toEqual(false);
});
});

View File

@ -0,0 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
describe('generateTargetColumnMap', () => {
it('should generate a target column map for a given type', () => {
const textMap = generateTargetColumnMap(
FieldMetadataType.TEXT,
false,
'name',
);
expect(textMap).toEqual({ value: 'name' });
const linkMap = generateTargetColumnMap(
FieldMetadataType.LINK,
false,
'website',
);
expect(linkMap).toEqual({ label: 'websiteLabel', url: 'websiteUrl' });
const currencyMap = generateTargetColumnMap(
FieldMetadataType.CURRENCY,
true,
'price',
);
expect(currencyMap).toEqual({
amountMicros: '_priceAmountMicros',
currencyCode: '_priceCurrencyCode',
});
});
it('should throw an error for an unknown type', () => {
expect(() =>
generateTargetColumnMap('invalid' as FieldMetadataType, false, 'name'),
).toThrow(BadRequestException);
});
});

View File

@ -0,0 +1,46 @@
import { BadRequestException } from '@nestjs/common';
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
describe('serializeDefaultValue', () => {
it('should return null for undefined defaultValue', () => {
expect(serializeDefaultValue()).toBeNull();
});
it('should handle uuid dynamic default value', () => {
expect(serializeDefaultValue({ type: 'uuid' })).toBe(
'public.uuid_generate_v4()',
);
});
it('should handle now dynamic default value', () => {
expect(serializeDefaultValue({ type: 'now' })).toBe('now()');
});
it('should throw BadRequestException for invalid dynamic default value type', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Just for testing purposes
expect(() => serializeDefaultValue({ type: 'invalid' })).toThrow(
BadRequestException,
);
});
it('should handle string static default value', () => {
expect(serializeDefaultValue('test')).toBe("'test'");
});
it('should handle number static default value', () => {
expect(serializeDefaultValue(123)).toBe(123);
});
it('should handle boolean static default value', () => {
expect(serializeDefaultValue(true)).toBe(true);
expect(serializeDefaultValue(false)).toBe(false);
});
it('should handle Date static default value', () => {
const date = new Date('2023-01-01');
expect(serializeDefaultValue(date)).toBe(`'${date.toISOString()}'`);
});
});

View File

@ -0,0 +1,161 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/engine-metadata/field-metadata/utils/validate-default-value-for-type.util';
describe('validateDefaultValueForType', () => {
it('should return true for null defaultValue', () => {
expect(validateDefaultValueForType(FieldMetadataType.TEXT, null)).toBe(
true,
);
});
// Dynamic default values
it('should validate uuid dynamic default value for UUID type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, { type: 'uuid' }),
).toBe(true);
});
it('should validate now dynamic default value for DATE_TIME type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.DATE_TIME, { type: 'now' }),
).toBe(true);
});
it('should return false for mismatched dynamic default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, { type: 'now' }),
).toBe(false);
});
// Static default values
it('should validate string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, { value: 'test' }),
).toBe(true);
});
it('should return false for invalid string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, { value: 123 }),
).toBe(false);
});
it('should validate string default value for PHONE type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PHONE, {
value: '+123456789',
}),
).toBe(true);
});
it('should return false for invalid string default value for PHONE type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PHONE, { value: 123 }),
).toBe(false);
});
it('should validate string default value for EMAIL type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.EMAIL, {
value: 'test@example.com',
}),
).toBe(true);
});
it('should return false for invalid string default value for EMAIL type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.EMAIL, { value: 123 }),
).toBe(false);
});
it('should validate number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, { value: 100 }),
).toBe(true);
});
it('should return false for invalid number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, { value: '100' }),
).toBe(false);
});
it('should validate number default value for PROBABILITY type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PROBABILITY, {
value: 0.5,
}),
).toBe(true);
});
it('should return false for invalid number default value for PROBABILITY type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PROBABILITY, {
value: '50%',
}),
).toBe(false);
});
it('should validate boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, { value: true }),
).toBe(true);
});
it('should return false for invalid boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, { value: 'true' }),
).toBe(false);
});
// LINK type
it('should validate LINK default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.LINK, {
label: 'http://example.com',
url: 'Example',
}),
).toBe(true);
});
it('should return false for invalid LINK default value', () => {
expect(
validateDefaultValueForType(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Just for testing purposes
{ label: 123, url: {} },
FieldMetadataType.LINK,
),
).toBe(false);
});
// CURRENCY type
it('should validate CURRENCY default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
amountMicros: '100',
currencyCode: 'USD',
}),
).toBe(true);
});
it('should return false for invalid CURRENCY default value', () => {
expect(
validateDefaultValueForType(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Just for testing purposes
{ amountMicros: 100, currencyCode: 'USD' },
FieldMetadataType.CURRENCY,
),
).toBe(false);
});
// Unknown type
it('should return false for unknown type', () => {
expect(
validateDefaultValueForType('unknown' as FieldMetadataType, {
value: 'test',
}),
).toBe(false);
});
});

View File

@ -0,0 +1,33 @@
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export function generateDefaultValue(
type: FieldMetadataType,
): FieldMetadataDefaultValue {
switch (type) {
case FieldMetadataType.TEXT:
case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL:
return {
value: '',
};
case FieldMetadataType.FULL_NAME:
return {
firstName: '',
lastName: '',
};
case FieldMetadataType.LINK:
return {
url: '',
label: '',
};
case FieldMetadataType.CURRENCY:
return {
amountMicros: null,
currencyCode: '',
};
default:
return null;
}
}

View File

@ -0,0 +1,15 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export function generateNullable(
type: FieldMetadataType,
inputNullableValue?: boolean,
): boolean {
switch (type) {
case FieldMetadataType.TEXT:
case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL:
return false;
default:
return inputNullableValue ?? true;
}
}

View File

@ -0,0 +1,23 @@
import { v4 as uuidV4 } from 'uuid';
import { FieldMetadataDefaultOption } from 'src/engine-metadata/field-metadata/dtos/options.input';
const range = {
start: 1,
end: 5,
};
export function generateRatingOptions(): FieldMetadataDefaultOption[] {
const options: FieldMetadataDefaultOption[] = [];
for (let i = range.start; i <= range.end; i++) {
options.push({
id: uuidV4(),
label: i.toString(),
value: `RATING_${i}`,
position: i - 1,
});
}
return options;
}

View File

@ -0,0 +1,61 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataTargetColumnMap } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { createCustomColumnName } from 'src/engine-metadata/utils/create-custom-column-name.util';
/**
* Generate a target column map for a given type, this is used to map the field to the correct column(s) in the database.
* This is used to support fields that map to multiple columns in the database.
*
* @param type string
* @returns FieldMetadataTargetColumnMap
*/
export function generateTargetColumnMap(
type: FieldMetadataType,
isCustomField: boolean,
fieldName: string,
): FieldMetadataTargetColumnMap {
const columnName = isCustomField
? createCustomColumnName(fieldName)
: fieldName;
switch (type) {
case FieldMetadataType.UUID:
case FieldMetadataType.TEXT:
case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL:
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
case FieldMetadataType.PROBABILITY:
case FieldMetadataType.BOOLEAN:
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
case FieldMetadataType.POSITION:
return {
value: columnName,
};
case FieldMetadataType.LINK:
return {
label: `${columnName}Label`,
url: `${columnName}Url`,
};
case FieldMetadataType.CURRENCY:
return {
amountMicros: `${columnName}AmountMicros`,
currencyCode: `${columnName}CurrencyCode`,
};
case FieldMetadataType.FULL_NAME:
return {
firstName: `${columnName}FirstName`,
lastName: `${columnName}LastName`,
};
case FieldMetadataType.RELATION:
return {};
default:
throw new BadRequestException(`Unknown type ${type}`);
}
}

View File

@ -0,0 +1,14 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export const isCompositeFieldMetadataType = (
type: FieldMetadataType,
): type is
| FieldMetadataType.LINK
| FieldMetadataType.CURRENCY
| FieldMetadataType.FULL_NAME => {
return (
type === FieldMetadataType.LINK ||
type === FieldMetadataType.CURRENCY ||
type === FieldMetadataType.FULL_NAME
);
};

View File

@ -0,0 +1,16 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export type EnumFieldMetadataUnionType =
| FieldMetadataType.RATING
| FieldMetadataType.SELECT
| FieldMetadataType.MULTI_SELECT;
export const isEnumFieldMetadataType = (
type: FieldMetadataType,
): type is EnumFieldMetadataUnionType => {
return (
type === FieldMetadataType.RATING ||
type === FieldMetadataType.SELECT ||
type === FieldMetadataType.MULTI_SELECT
);
};

View File

@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataDefaultSerializableValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { serializeTypeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-type-default-value.util';
export const serializeDefaultValue = (
defaultValue?: FieldMetadataDefaultSerializableValue,
) => {
if (defaultValue === undefined || defaultValue === null) {
return null;
}
// Dynamic default values
if (
!Array.isArray(defaultValue) &&
typeof defaultValue === 'object' &&
'type' in defaultValue
) {
const serializedTypeDefaultValue = serializeTypeDefaultValue(defaultValue);
if (!serializedTypeDefaultValue) {
throw new BadRequestException('Invalid default value');
}
return serializedTypeDefaultValue;
}
// Static default values
if (typeof defaultValue === 'string') {
return `'${defaultValue}'`;
}
if (typeof defaultValue === 'number') {
return defaultValue;
}
if (typeof defaultValue === 'boolean') {
return defaultValue;
}
if (defaultValue instanceof Date) {
return `'${defaultValue.toISOString()}'`;
}
if (Array.isArray(defaultValue)) {
return defaultValue;
}
if (typeof defaultValue === 'object') {
return `'${JSON.stringify(defaultValue)}'`;
}
throw new BadRequestException('Invalid default value');
};

View File

@ -0,0 +1,18 @@
import { FieldMetadataDynamicDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
export const serializeTypeDefaultValue = (
defaultValue?: FieldMetadataDynamicDefaultValue,
) => {
if (!defaultValue?.type) {
return null;
}
switch (defaultValue.type) {
case 'uuid':
return 'public.uuid_generate_v4()';
case 'now':
return 'now()';
default:
return null;
}
};

View File

@ -0,0 +1,70 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import {
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDynamicDefaultValueNow,
FieldMetadataDynamicDefaultValueUuid,
} from 'src/engine-metadata/field-metadata/dtos/default-value.input';
export const defaultValueValidatorsMap = {
[FieldMetadataType.UUID]: [
FieldMetadataDefaultValueString,
FieldMetadataDynamicDefaultValueUuid,
],
[FieldMetadataType.TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.PHONE]: [FieldMetadataDefaultValueString],
[FieldMetadataType.EMAIL]: [FieldMetadataDefaultValueString],
[FieldMetadataType.DATE_TIME]: [
FieldMetadataDefaultValueDateTime,
FieldMetadataDynamicDefaultValueNow,
],
[FieldMetadataType.BOOLEAN]: [FieldMetadataDefaultValueBoolean],
[FieldMetadataType.NUMBER]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.NUMERIC]: [FieldMetadataDefaultValueString],
[FieldMetadataType.PROBABILITY]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.LINK]: [FieldMetadataDefaultValueLink],
[FieldMetadataType.CURRENCY]: [FieldMetadataDefaultValueCurrency],
[FieldMetadataType.FULL_NAME]: [FieldMetadataDefaultValueFullName],
[FieldMetadataType.RATING]: [FieldMetadataDefaultValueString],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
};
export const validateDefaultValueForType = (
type: FieldMetadataType,
defaultValue: FieldMetadataDefaultValue,
): boolean => {
if (defaultValue === null) return true;
const validators = defaultValueValidatorsMap[type];
if (!validators) return false;
const isValid = validators.some((validator) => {
const defaultValueInstance = plainToInstance<
any,
FieldMetadataDefaultValue
>(validator, defaultValue);
return (
validateSync(defaultValueInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
}).length === 0
);
});
return isValid;
};

View File

@ -0,0 +1,60 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine-metadata/field-metadata/dtos/options.input';
import { isEnumFieldMetadataType } from './is-enum-field-metadata-type.util';
export const optionsValidatorsMap = {
// RATING doesn't need to be provided as it's the backend that will generate the options
[FieldMetadataType.SELECT]: [FieldMetadataComplexOption],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOption],
};
export const validateOptionsForType = (
type: FieldMetadataType,
options: FieldMetadataOptions,
): boolean => {
if (options === null) return true;
if (!Array.isArray(options)) {
return false;
}
if (!isEnumFieldMetadataType(type)) {
return true;
}
if (type === FieldMetadataType.RATING) {
return true;
}
const validators = optionsValidatorsMap[type];
if (!validators) return false;
const isValid = options.every((option) => {
return validators.some((validator) => {
const optionsInstance = plainToInstance<
any,
FieldMetadataDefaultOption | FieldMetadataComplexOption
>(validator, option);
return (
validateSync(optionsInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
}).length === 0
);
});
});
return isValid;
};

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataService } from 'src/engine-metadata/field-metadata/field-metadata.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/engine-metadata/field-metadata/utils/validate-default-value-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataDefaultValue', async: true })
export class IsFieldMetadataDefaultValue
implements ValidatorConstraintInterface
{
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
async validate(
value: FieldMetadataDefaultValue,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataService.findOneOrFail(id);
} catch {
return false;
}
type = fieldMetadata.type;
}
return validateDefaultValueForType(type, value);
}
defaultMessage(): string {
return 'FieldMetadataDefaultValue is not valid';
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { ValidationArguments, ValidatorConstraint } from 'class-validator';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataService } from 'src/engine-metadata/field-metadata/field-metadata.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { validateOptionsForType } from 'src/engine-metadata/field-metadata/utils/validate-options-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataOptions', async: true })
export class IsFieldMetadataOptions {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
async validate(
value: FieldMetadataOptions,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataService.findOneOrFail(id);
} catch {
return false;
}
type = fieldMetadata.type;
}
return validateOptionsForType(type, value);
}
defaultMessage(): string {
return 'FieldMetadataOptions is not valid';
}
}

View File

@ -0,0 +1,26 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
const graphQLEnumNameRegex = /^[_A-Za-z][_0-9A-Za-z]+$/;
export function IsValidGraphQLEnumName(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isValidGraphQLEnumName',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
return typeof value === 'string' && graphQLEnumNameRegex.test(value);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must match the ${graphQLEnumNameRegex} format`;
},
},
});
};
}

View File

@ -0,0 +1,49 @@
import { YogaDriverConfig } from '@graphql-yoga/nestjs';
import GraphQLJSON from 'graphql-type-json';
import { CreateContextFactory } from 'src/engine-graphql-config/factories/create-context.factory';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { useExceptionHandler } from 'src/integrations/exception-handler/hooks/use-exception-handler.hook';
import { useThrottler } from 'src/integrations/throttler/hooks/use-throttler';
import { MetadataModule } from 'src/engine-metadata/metadata.module';
import { renderApolloPlayground } from 'src/engine-workspace/utils/render-apollo-playground.util';
export const metadataModuleFactory = async (
environmentService: EnvironmentService,
exceptionHandlerService: ExceptionHandlerService,
createContextFactory: CreateContextFactory,
): Promise<YogaDriverConfig> => {
const config: YogaDriverConfig = {
context(context) {
return createContextFactory.create(context);
},
autoSchemaFile: true,
include: [MetadataModule],
renderGraphiQL() {
return renderApolloPlayground({ path: 'metadata' });
},
resolvers: { JSON: GraphQLJSON },
plugins: [
useThrottler({
ttl: environmentService.get('API_RATE_LIMITING_TTL'),
limit: environmentService.get('API_RATE_LIMITING_LIMIT'),
identifyFn: (context) => {
return context.user?.id ?? context.req.ip ?? 'anonymous';
},
}),
useExceptionHandler({
exceptionHandlerService,
}),
],
path: '/metadata',
};
if (environmentService.get('DEBUG_MODE')) {
config.renderGraphiQL = () => {
return renderApolloPlayground({ path: 'metadata' });
};
}
return config;
};

View File

@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
import { metadataModuleFactory } from 'src/engine-metadata/metadata.module-factory';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { GraphQLConfigModule } from 'src/engine-graphql-config/graphql-config.module';
import { CreateContextFactory } from 'src/engine-graphql-config/factories/create-context.factory';
import { DataSourceModule } from './data-source/data-source.module';
import { FieldMetadataModule } from './field-metadata/field-metadata.module';
import { ObjectMetadataModule } from './object-metadata/object-metadata.module';
import { RelationMetadataModule } from './relation-metadata/relation-metadata.module';
@Module({
imports: [
GraphQLModule.forRootAsync<YogaDriverConfig>({
driver: YogaDriver,
useFactory: metadataModuleFactory,
imports: [GraphQLConfigModule],
inject: [
EnvironmentService,
ExceptionHandlerService,
CreateContextFactory,
],
}),
DataSourceModule,
FieldMetadataModule,
ObjectMetadataModule,
WorkspaceMigrationRunnerModule,
WorkspaceMigrationModule,
RelationMetadataModule,
],
})
export class MetadataModule {}

View File

@ -0,0 +1,59 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsValidName } from 'src/engine-metadata/decorators/is-valid-name.decorator';
import { BeforeCreateOneObject } from 'src/engine-metadata/object-metadata/hooks/before-create-one-object.hook';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
export class CreateObjectInput {
@IsString()
@IsNotEmpty()
@Field()
@IsValidName()
nameSingular: string;
@IsString()
@IsNotEmpty()
@Field()
@IsValidName()
namePlural: string;
@IsString()
@IsNotEmpty()
@Field()
labelSingular: string;
@IsString()
@IsNotEmpty()
@Field()
labelPlural: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@HideField()
dataSourceId: string;
@HideField()
workspaceId: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
}

View File

@ -0,0 +1,12 @@
import { ID, InputType } from '@nestjs/graphql';
import { BeforeDeleteOne, IDField } from '@ptc-org/nestjs-query-graphql';
import { BeforeDeleteOneObject } from 'src/engine-metadata/object-metadata/hooks/before-delete-one-object.hook';
@InputType()
@BeforeDeleteOne(BeforeDeleteOneObject)
export class DeleteOneObjectInput {
@IDField(() => ID, { description: 'The id of the record to delete.' })
id!: string;
}

View File

@ -0,0 +1,76 @@
import { ObjectType, ID, Field, HideField } from '@nestjs/graphql';
import {
Authorize,
BeforeDeleteOne,
CursorConnection,
FilterableField,
IDField,
QueryOptions,
} from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataDTO } from 'src/engine-metadata/field-metadata/dtos/field-metadata.dto';
import { BeforeDeleteOneObject } from 'src/engine-metadata/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('object')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.user?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneObject)
@CursorConnection('fields', () => FieldMetadataDTO)
export class ObjectMetadataDTO {
@IDField(() => ID)
id: string;
@Field()
dataSourceId: string;
@Field()
nameSingular: string;
@Field()
namePlural: string;
@Field()
labelSingular: string;
@Field()
labelPlural: string;
@Field({ nullable: true })
description: string;
@Field({ nullable: true })
icon: string;
@FilterableField()
isCustom: boolean;
@FilterableField()
isActive: boolean;
@FilterableField()
isSystem: boolean;
@HideField()
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
}

View File

@ -0,0 +1,58 @@
import { Field, InputType } from '@nestjs/graphql';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsValidName } from 'src/engine-metadata/decorators/is-valid-name.decorator';
import { BeforeUpdateOneObject } from 'src/engine-metadata/object-metadata/hooks/before-update-one-object.hook';
@InputType()
@BeforeUpdateOne(BeforeUpdateOneObject)
export class UpdateObjectInput {
@IsString()
@IsOptional()
@Field({ nullable: true })
labelSingular?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
labelPlural?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
@IsValidName()
nameSingular?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
@IsValidName()
namePlural?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isActive?: boolean;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
}

View File

@ -0,0 +1,49 @@
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeCreateOneHook,
CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { CreateObjectInput } from 'src/engine-metadata/object-metadata/dtos/create-object.input';
const coreObjectNames = [
'featureFlag',
'refreshToken',
'workspace',
'user',
'event',
'field',
];
@Injectable()
export class BeforeCreateOneObject<T extends CreateObjectInput>
implements BeforeCreateOneHook<T, any>
{
async run(
instance: CreateOneInputType<T>,
context: any,
): Promise<CreateOneInputType<T>> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
if (
coreObjectNames.includes(instance.input.nameSingular) ||
coreObjectNames.includes(instance.input.namePlural)
) {
throw new ForbiddenException(
'You cannot create an object with this name.',
);
}
instance.input.workspaceId = workspaceId;
return instance;
}
}

View File

@ -0,0 +1,49 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
@Injectable()
export class BeforeDeleteOneObject implements BeforeDeleteOneHook<any> {
constructor(readonly objectMetadataService: ObjectMetadataService) {}
async run(
instance: DeleteOneInputType,
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
throw new BadRequestException("Standard Objects can't be deleted");
}
if (objectMetadata.isActive) {
throw new BadRequestException("Active objects can't be deleted");
}
return instance;
}
}

View File

@ -0,0 +1,146 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
BeforeUpdateOneHook,
UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { Equal, In, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { UpdateObjectInput } from 'src/engine-metadata/object-metadata/dtos/update-object.input';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
@Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectInput>
implements BeforeUpdateOneHook<T, any>
{
constructor(
readonly objectMetadataService: ObjectMetadataService,
// TODO: Should not use the repository here
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
// TODO: this logic could be moved to a policy guard
async run(
instance: UpdateOneInputType<T>,
context: any,
): Promise<UpdateOneInputType<T>> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
if (
Object.keys(instance.update).length === 1 &&
instance.update.hasOwnProperty('isActive') &&
instance.update.isActive !== undefined
) {
return {
id: instance.id,
update: {
isActive: instance.update.isActive,
} as T,
};
}
throw new BadRequestException(
'Only isActive field can be updated for standard objects',
);
}
if (
instance.update.labelIdentifierFieldMetadataId ||
instance.update.imageIdentifierFieldMetadataId
) {
const fields = await this.fieldMetadataRepository.findBy({
workspaceId: Equal(workspaceId),
objectMetadataId: Equal(instance.id.toString()),
id: In(
[
instance.update.labelIdentifierFieldMetadataId,
instance.update.imageIdentifierFieldMetadataId,
].filter((id) => id !== null),
),
});
const fieldIds = fields.map((field) => field.id);
if (
instance.update.labelIdentifierFieldMetadataId &&
!fieldIds.includes(instance.update.labelIdentifierFieldMetadataId)
) {
throw new BadRequestException('This label identifier does not exist');
}
if (
instance.update.imageIdentifierFieldMetadataId &&
!fieldIds.includes(instance.update.imageIdentifierFieldMetadataId)
) {
throw new BadRequestException('This image identifier does not exist');
}
}
this.checkIfFieldIsEditable(instance.update, objectMetadata);
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(
update: UpdateObjectInput,
objectMetadata: ObjectMetadataEntity,
) {
if (
update.nameSingular &&
update.nameSingular !== objectMetadata.nameSingular
) {
throw new BadRequestException(
"Object's nameSingular can't be updated. Please create a new object instead",
);
}
if (
update.labelSingular &&
update.labelSingular !== objectMetadata.labelSingular
) {
throw new BadRequestException(
"Object's labelSingular can't be updated. Please create a new object instead",
);
}
if (update.namePlural && update.namePlural !== objectMetadata.namePlural) {
throw new BadRequestException(
"Object's namePlural can't be updated. Please create a new object instead",
);
}
if (
update.labelPlural &&
update.labelPlural !== objectMetadata.labelPlural
) {
throw new BadRequestException(
"Object's labelPlural can't be updated. Please create a new object instead",
);
}
}
}

View File

@ -0,0 +1,106 @@
import {
Entity,
Unique,
PrimaryGeneratedColumn,
Column,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from 'typeorm';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { DataSourceEntity } from 'src/engine-metadata/data-source/data-source.entity';
@Entity('objectMetadata')
@Unique('IndexOnNameSingularAndWorkspaceIdUnique', [
'nameSingular',
'workspaceId',
])
@Unique('IndexOnNamePluralAndWorkspaceIdUnique', ['namePlural', 'workspaceId'])
export class ObjectMetadataEntity implements ObjectMetadataInterface {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false, type: 'uuid' })
dataSourceId: string;
@Column({ nullable: false })
nameSingular: string;
@Column({ nullable: false })
namePlural: string;
@Column({ nullable: false })
labelSingular: string;
@Column({ nullable: false })
labelPlural: string;
@Column({ nullable: true, type: 'text' })
description: string;
@Column({ nullable: true })
icon: string;
@Column({ nullable: false })
targetTableName: string;
@Column({ default: false })
isCustom: boolean;
@Column({ default: false })
isActive: boolean;
@Column({ default: false })
isSystem: boolean;
@Column({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@Column({ nullable: true })
imageIdentifierFieldMetadataId?: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@OneToMany(() => FieldMetadataEntity, (field) => field.object, {
cascade: true,
})
fields: FieldMetadataEntity[];
@OneToMany(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.fromObjectMetadata,
{
cascade: true,
},
)
fromRelations: RelationMetadataEntity[];
@OneToMany(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.toObjectMetadata,
{
cascade: true,
},
)
toRelations: RelationMetadataEntity[];
@ManyToOne(() => DataSourceEntity, (dataSource) => dataSource.objects, {
onDelete: 'CASCADE',
})
dataSource: DataSourceEntity;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,66 @@
import { Module } from '@nestjs/common';
import {
NestjsQueryGraphQLModule,
PagingStrategies,
} from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { ObjectMetadataResolver } from 'src/engine-metadata/object-metadata/object-metadata.resolver';
import { ObjectMetadataService } from './object-metadata.service';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { CreateObjectInput } from './dtos/create-object.input';
import { UpdateObjectInput } from './dtos/update-object.input';
import { ObjectMetadataDTO } from './dtos/object-metadata.dto';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity, RelationMetadataEntity],
'metadata',
),
DataSourceModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
],
services: [ObjectMetadataService],
resolvers: [
{
EntityClass: ObjectMetadataEntity,
DTOClass: ObjectMetadataDTO,
CreateDTOClass: CreateObjectInput,
UpdateDTOClass: UpdateObjectInput,
ServiceClass: ObjectMetadataService,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
many: { disabled: true },
},
update: {
many: { disabled: true },
},
delete: { disabled: true },
guards: [JwtAuthGuard],
},
],
}),
],
providers: [ObjectMetadataService, ObjectMetadataResolver],
exports: [ObjectMetadataService],
})
export class ObjectMetadataModule {}

View File

@ -0,0 +1,23 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { Workspace } from 'src/engine/modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ObjectMetadataDTO } from 'src/engine-metadata/object-metadata/dtos/object-metadata.dto';
import { DeleteOneObjectInput } from 'src/engine-metadata/object-metadata/dtos/delete-object.input';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
@UseGuards(JwtAuthGuard)
@Resolver(() => ObjectMetadataDTO)
export class ObjectMetadataResolver {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
@Mutation(() => ObjectMetadataDTO)
deleteOneObject(
@Args('input') input: DeleteOneObjectInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.objectMetadataService.deleteOneObject(input, workspaceId);
}
}

View File

@ -0,0 +1,880 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import console from 'console';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { WorkspaceMigrationService } from 'src/engine-metadata/workspace-migration/workspace-migration.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnCreate,
WorkspaceMigrationColumnDrop,
WorkspaceMigrationTableAction,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
import {
RelationMetadataEntity,
RelationMetadataType,
RelationOnDeleteAction,
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { computeCustomName } from 'src/engine-workspace/utils/compute-custom-name.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { DeleteOneObjectInput } from 'src/engine-metadata/object-metadata/dtos/delete-object.input';
import { RelationToDelete } from 'src/engine-metadata/relation-metadata/types/relation-to-delete';
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
import {
activityTargetStandardFieldIds,
attachmentStandardFieldIds,
baseObjectStandardFieldIds,
customObjectStandardFieldIds,
favoriteStandardFieldIds,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { createDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { CreateObjectInput } from './dtos/create-object.input';
@Injectable()
export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(objectMetadataRepository);
}
override async query(
query: Query<ObjectMetadataEntity>,
opts?: QueryOptions<ObjectMetadataEntity> | undefined,
): Promise<ObjectMetadataEntity[]> {
const start = performance.now();
const result = super.query(query, opts);
const end = performance.now();
console.log(`metadata query time: ${end - start} ms`);
return result;
}
public async deleteOneObject(
input: DeleteOneObjectInput,
workspaceId: string,
): Promise<ObjectMetadataEntity> {
const objectMetadata = await this.objectMetadataRepository.findOne({
relations: [
'fromRelations.fromFieldMetadata',
'fromRelations.toFieldMetadata',
'toRelations.fromFieldMetadata',
'toRelations.toFieldMetadata',
'fromRelations.fromObjectMetadata',
'fromRelations.toObjectMetadata',
'toRelations.fromObjectMetadata',
'toRelations.toObjectMetadata',
],
where: {
id: input.id,
workspaceId,
},
});
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
const relationsToDelete: RelationToDelete[] = [];
// TODO: Most of this logic should be moved to relation-metadata.service.ts
for (const relation of [
...objectMetadata.fromRelations,
...objectMetadata.toRelations,
]) {
relationsToDelete.push({
id: relation.id,
fromFieldMetadataId: relation.fromFieldMetadata.id,
toFieldMetadataId: relation.toFieldMetadata.id,
fromFieldMetadataName: relation.fromFieldMetadata.name,
toFieldMetadataName: relation.toFieldMetadata.name,
fromObjectMetadataId: relation.fromObjectMetadata.id,
toObjectMetadataId: relation.toObjectMetadata.id,
fromObjectName: relation.fromObjectMetadata.nameSingular,
toObjectName: relation.toObjectMetadata.nameSingular,
toFieldMetadataIsCustom: relation.toFieldMetadata.isCustom,
toObjectMetadataIsCustom: relation.toObjectMetadata.isCustom,
direction:
relation.fromObjectMetadata.nameSingular ===
objectMetadata.nameSingular
? 'from'
: 'to',
});
}
await this.relationMetadataRepository.delete(
relationsToDelete.map((relation) => relation.id),
);
for (const relationToDelete of relationsToDelete) {
const foreignKeyFieldsToDelete = await this.fieldMetadataRepository.find({
where: {
name: `${relationToDelete.toFieldMetadataName}Id`,
objectMetadataId: relationToDelete.toObjectMetadataId,
workspaceId,
},
});
const foreignKeyFieldsToDeleteIds = foreignKeyFieldsToDelete.map(
(field) => field.id,
);
await this.fieldMetadataRepository.delete([
...foreignKeyFieldsToDeleteIds,
relationToDelete.fromFieldMetadataId,
relationToDelete.toFieldMetadataId,
]);
if (relationToDelete.direction === 'from') {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`delete-${relationToDelete.fromObjectName}-${relationToDelete.toObjectName}`,
),
workspaceId,
[
{
name: computeCustomName(
relationToDelete.toObjectName,
relationToDelete.toObjectMetadataIsCustom,
),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: computeCustomName(
`${relationToDelete.toFieldMetadataName}Id`,
relationToDelete.toFieldMetadataIsCustom,
),
} satisfies WorkspaceMigrationColumnDrop,
],
},
],
);
}
}
await this.objectMetadataRepository.delete(objectMetadata.id);
// DROP TABLE
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${objectMetadata.nameSingular}`),
workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: 'drop',
},
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
return objectMetadata;
}
override async createOne(
objectMetadataInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
objectMetadataInput.workspaceId,
);
if (
objectMetadataInput.nameSingular.toLowerCase() ===
objectMetadataInput.namePlural.toLowerCase()
) {
throw new BadRequestException(
'The singular and plural name cannot be the same for an object',
);
}
const createdObjectMetadata = await super.createOne({
...objectMetadataInput,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: 'DEPRECATED',
isActive: true,
isCustom: true,
isSystem: false,
fields:
// Creating default fields.
// No need to create a custom migration for this though as the default columns are already
// created with default values which is not supported yet by workspace migrations.
[
{
standardId: baseObjectStandardFieldIds.id,
type: FieldMetadataType.UUID,
name: 'id',
label: 'Id',
targetColumnMap: {
value: 'id',
},
icon: 'Icon123',
description: 'Id',
isNullable: false,
isActive: true,
isCustom: false,
isSystem: true,
workspaceId: objectMetadataInput.workspaceId,
defaultValue: { type: 'uuid' },
},
{
standardId: customObjectStandardFieldIds.name,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
targetColumnMap: {
value: 'name',
},
icon: 'IconAbc',
description: 'Name',
isNullable: false,
isActive: true,
isCustom: false,
workspaceId: objectMetadataInput.workspaceId,
defaultValue: { value: 'Untitled' },
},
{
standardId: baseObjectStandardFieldIds.createdAt,
type: FieldMetadataType.DATE_TIME,
name: 'createdAt',
label: 'Creation date',
targetColumnMap: {
value: 'createdAt',
},
icon: 'IconCalendar',
description: 'Creation date',
isNullable: false,
isActive: true,
isCustom: false,
workspaceId: objectMetadataInput.workspaceId,
defaultValue: { type: 'now' },
},
{
standardId: baseObjectStandardFieldIds.updatedAt,
type: FieldMetadataType.DATE_TIME,
name: 'updatedAt',
label: 'Update date',
targetColumnMap: {
value: 'updatedAt',
},
icon: 'IconCalendar',
description: 'Update date',
isNullable: false,
isActive: true,
isCustom: false,
isSystem: true,
workspaceId: objectMetadataInput.workspaceId,
defaultValue: { type: 'now' },
},
{
standardId: customObjectStandardFieldIds.position,
type: FieldMetadataType.POSITION,
name: 'position',
label: 'Position',
targetColumnMap: {
value: 'position',
},
icon: 'IconHierarchy2',
description: 'Position',
isNullable: true,
isActive: true,
isCustom: false,
isSystem: true,
workspaceId: objectMetadataInput.workspaceId,
defaultValue: null,
},
],
});
const { activityTargetObjectMetadata } =
await this.createActivityTargetRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
);
const { favoriteObjectMetadata } = await this.createFavoriteRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
);
const { attachmentObjectMetadata } = await this.createAttachmentRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdObjectMetadata.nameSingular}`),
createdObjectMetadata.workspaceId,
[
{
name: computeObjectTargetTable(createdObjectMetadata),
action: 'create',
} satisfies WorkspaceMigrationTableAction,
// Add activity target relation
{
name: computeObjectTargetTable(activityTargetObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
columnType: 'uuid',
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
},
{
name: computeObjectTargetTable(activityTargetObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
referencedTableName: computeObjectTargetTable(
createdObjectMetadata,
),
referencedTableColumnName: 'id',
onDelete: RelationOnDeleteAction.CASCADE,
},
],
},
// Add attachment relation
{
name: computeObjectTargetTable(attachmentObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
columnType: 'uuid',
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
},
{
name: computeObjectTargetTable(attachmentObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
referencedTableName: computeObjectTargetTable(
createdObjectMetadata,
),
referencedTableColumnName: 'id',
onDelete: RelationOnDeleteAction.CASCADE,
},
],
},
// Add favorite relation
{
name: computeObjectTargetTable(favoriteObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
columnType: 'uuid',
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
},
{
name: computeObjectTargetTable(favoriteObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
referencedTableName: computeObjectTargetTable(
createdObjectMetadata,
),
referencedTableColumnName: 'id',
onDelete: RelationOnDeleteAction.CASCADE,
},
],
},
{
name: computeObjectTargetTable(createdObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: 'position',
columnType: 'float',
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
} satisfies WorkspaceMigrationTableAction,
// This is temporary until we implement mainIdentifier
{
name: computeObjectTargetTable(createdObjectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: 'name',
columnType: 'text',
defaultValue: "'Untitled'",
} satisfies WorkspaceMigrationColumnCreate,
],
} satisfies WorkspaceMigrationTableAction,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
createdObjectMetadata.workspaceId,
);
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
createdObjectMetadata.workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
const view = await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."view"
("objectMetadataId", "type", "name")
VALUES ('${createdObjectMetadata.id}', 'table', 'All ${createdObjectMetadata.namePlural}') RETURNING *`,
);
createdObjectMetadata.fields.map(async (field, index) => {
if (field.name === 'id') {
return;
}
await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."viewField"
("fieldMetadataId", "position", "isVisible", "size", "viewId")
VALUES ('${field.id}', '${index - 1}', true, 180, '${
view[0].id
}') RETURNING *`,
);
});
return createdObjectMetadata;
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'fields.fromRelationMetadata',
'fields.toRelationMetadata',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findOneOrFailWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity> {
return this.objectMetadataRepository.findOneOrFail({
relations: [
'fields',
'fields.fromRelationMetadata',
'fields.toRelationMetadata',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.fromRelationMetadata',
'fields.toRelationMetadata',
'fields.fromRelationMetadata.toObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
});
}
public async findMany(options?: FindManyOptions<ObjectMetadataEntity>) {
return this.objectMetadataRepository.find({
relations: [
'fields',
'fields.fromRelationMetadata',
'fields.toRelationMetadata',
'fields.fromRelationMetadata.toObjectMetadata',
],
...options,
where: {
...options?.where,
},
});
}
public async deleteObjectsMetadata(workspaceId: string) {
await this.objectMetadataRepository.delete({ workspaceId });
}
private async createActivityTargetRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
) {
const activityTargetObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'activityTarget',
workspaceId: workspaceId,
});
const activityTargetRelationFieldMetadata =
await this.fieldMetadataRepository.save([
// FROM
{
standardId: customObjectStandardFieldIds.activityTargets,
objectMetadataId: createdObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.RELATION,
name: 'activityTargets',
label: 'Activities',
targetColumnMap: {},
description: `Activities tied to the ${createdObjectMetadata.labelSingular}`,
icon: 'IconCheckbox',
isNullable: true,
},
// TO
{
standardId: activityTargetStandardFieldIds.custom,
objectMetadataId: activityTargetObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.RELATION,
name: createdObjectMetadata.nameSingular,
label: createdObjectMetadata.labelSingular,
targetColumnMap: {},
description: `ActivityTarget ${createdObjectMetadata.labelSingular}`,
icon: 'IconBuildingSkyscraper',
isNullable: true,
},
// Foreign key
{
standardId: createDeterministicUuid(
activityTargetStandardFieldIds.custom,
),
objectMetadataId: activityTargetObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
targetColumnMap: {
value: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
},
description: `ActivityTarget ${createdObjectMetadata.labelSingular} id foreign key`,
icon: undefined,
isNullable: true,
isSystem: true,
defaultValue: undefined,
},
]);
const activityTargetRelationFieldMetadataMap =
activityTargetRelationFieldMetadata.reduce(
(acc, fieldMetadata: FieldMetadataEntity) => {
if (fieldMetadata.type === FieldMetadataType.RELATION) {
acc[fieldMetadata.objectMetadataId] = fieldMetadata;
}
return acc;
},
{},
);
await this.relationMetadataRepository.save([
{
workspaceId: workspaceId,
relationType: RelationMetadataType.ONE_TO_MANY,
fromObjectMetadataId: createdObjectMetadata.id,
toObjectMetadataId: activityTargetObjectMetadata.id,
fromFieldMetadataId:
activityTargetRelationFieldMetadataMap[createdObjectMetadata.id].id,
toFieldMetadataId:
activityTargetRelationFieldMetadataMap[
activityTargetObjectMetadata.id
].id,
onDeleteAction: RelationOnDeleteAction.CASCADE,
},
]);
return { activityTargetObjectMetadata };
}
private async createAttachmentRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
) {
const attachmentObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'attachment',
workspaceId: workspaceId,
});
const attachmentRelationFieldMetadata =
await this.fieldMetadataRepository.save([
// FROM
{
standardId: customObjectStandardFieldIds.attachments,
objectMetadataId: createdObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.RELATION,
name: 'attachments',
label: 'Attachments',
targetColumnMap: {},
description: `Attachments tied to the ${createdObjectMetadata.labelSingular}`,
icon: 'IconFileImport',
isNullable: true,
},
// TO
{
standardId: attachmentStandardFieldIds.custom,
objectMetadataId: attachmentObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.RELATION,
name: createdObjectMetadata.nameSingular,
label: createdObjectMetadata.labelSingular,
targetColumnMap: {},
description: `Attachment ${createdObjectMetadata.labelSingular}`,
icon: 'IconBuildingSkyscraper',
isNullable: true,
},
// Foreign key
{
standardId: createDeterministicUuid(
attachmentStandardFieldIds.custom,
),
objectMetadataId: attachmentObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
targetColumnMap: {
value: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
},
description: `Attachment ${createdObjectMetadata.labelSingular} id foreign key`,
icon: undefined,
isNullable: true,
isSystem: true,
defaultValue: undefined,
},
]);
const attachmentRelationFieldMetadataMap =
attachmentRelationFieldMetadata.reduce(
(acc, fieldMetadata: FieldMetadataEntity) => {
if (fieldMetadata.type === FieldMetadataType.RELATION) {
acc[fieldMetadata.objectMetadataId] = fieldMetadata;
}
return acc;
},
{},
);
await this.relationMetadataRepository.save([
{
workspaceId: workspaceId,
relationType: RelationMetadataType.ONE_TO_MANY,
fromObjectMetadataId: createdObjectMetadata.id,
toObjectMetadataId: attachmentObjectMetadata.id,
fromFieldMetadataId:
attachmentRelationFieldMetadataMap[createdObjectMetadata.id].id,
toFieldMetadataId:
attachmentRelationFieldMetadataMap[attachmentObjectMetadata.id].id,
onDeleteAction: RelationOnDeleteAction.CASCADE,
},
]);
return { attachmentObjectMetadata };
}
private async createFavoriteRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
) {
const favoriteObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'favorite',
workspaceId: workspaceId,
});
const favoriteRelationFieldMetadata =
await this.fieldMetadataRepository.save([
// FROM
{
standardId: customObjectStandardFieldIds.favorites,
objectMetadataId: createdObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
isSystem: true,
type: FieldMetadataType.RELATION,
name: 'favorites',
label: 'Favorites',
targetColumnMap: {},
description: `Favorites tied to the ${createdObjectMetadata.labelSingular}`,
icon: 'IconHeart',
isNullable: true,
},
// TO
{
standardId: favoriteStandardFieldIds.custom,
objectMetadataId: favoriteObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.RELATION,
name: createdObjectMetadata.nameSingular,
label: createdObjectMetadata.labelSingular,
targetColumnMap: {},
description: `Favorite ${createdObjectMetadata.labelSingular}`,
icon: 'IconBuildingSkyscraper',
isNullable: true,
},
// Foreign key
{
standardId: createDeterministicUuid(favoriteStandardFieldIds.custom),
objectMetadataId: favoriteObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
targetColumnMap: {
value: `${computeCustomName(
createdObjectMetadata.nameSingular,
false,
)}Id`,
},
description: `Favorite ${createdObjectMetadata.labelSingular} id foreign key`,
icon: undefined,
isNullable: true,
isSystem: true,
defaultValue: undefined,
},
]);
const favoriteRelationFieldMetadataMap =
favoriteRelationFieldMetadata.reduce(
(acc, fieldMetadata: FieldMetadataEntity) => {
if (fieldMetadata.type === FieldMetadataType.RELATION) {
acc[fieldMetadata.objectMetadataId] = fieldMetadata;
}
return acc;
},
{},
);
await this.relationMetadataRepository.save([
{
workspaceId: workspaceId,
relationType: RelationMetadataType.ONE_TO_MANY,
fromObjectMetadataId: createdObjectMetadata.id,
toObjectMetadataId: favoriteObjectMetadata.id,
fromFieldMetadataId:
favoriteRelationFieldMetadataMap[createdObjectMetadata.id].id,
toFieldMetadataId:
favoriteRelationFieldMetadataMap[favoriteObjectMetadata.id].id,
onDeleteAction: RelationOnDeleteAction.CASCADE,
},
]);
return { favoriteObjectMetadata };
}
}

View File

@ -0,0 +1,80 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { BeforeCreateOneRelation } from 'src/engine-metadata/relation-metadata/hooks/before-create-one-relation.hook';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
@InputType()
@BeforeCreateOne(BeforeCreateOneRelation)
export class CreateRelationInput {
@IsEnum(RelationMetadataType)
@IsNotEmpty()
@Field(() => RelationMetadataType)
relationType: RelationMetadataType;
@IsUUID()
@IsNotEmpty()
@Field()
fromObjectMetadataId: string;
@IsUUID()
@IsNotEmpty()
@Field()
toObjectMetadataId: string;
@IsString()
@IsNotEmpty()
@Field()
fromName: string;
@IsString()
@IsNotEmpty()
@Field()
toName: string;
@IsString()
@IsNotEmpty()
@Field()
fromLabel: string;
@IsString()
@IsNotEmpty()
@Field()
toLabel: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
fromIcon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
toIcon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true, deprecationReason: 'Use fromDescription instead' })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
fromDescription?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
toDescription?: string;
@HideField()
workspaceId: string;
}

View File

@ -0,0 +1,71 @@
import {
ObjectType,
ID,
Field,
HideField,
registerEnumType,
} from '@nestjs/graphql';
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
import {
Authorize,
BeforeDeleteOne,
IDField,
QueryOptions,
Relation,
} from '@ptc-org/nestjs-query-graphql';
import { ObjectMetadataDTO } from 'src/engine-metadata/object-metadata/dtos/object-metadata.dto';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { BeforeDeleteOneRelation } from 'src/engine-metadata/relation-metadata/hooks/before-delete-one-field.hook';
registerEnumType(RelationMetadataType, {
name: 'RelationMetadataType',
description: 'Type of the relation',
});
@ObjectType('relation')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.user?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
disableFilter: true,
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneRelation)
@Relation('fromObjectMetadata', () => ObjectMetadataDTO)
@Relation('toObjectMetadata', () => ObjectMetadataDTO)
export class RelationMetadataDTO {
@IDField(() => ID)
id: string;
@Field(() => RelationMetadataType)
relationType: RelationMetadataType;
@Field()
fromObjectMetadataId: string;
@Field()
toObjectMetadataId: string;
@Field()
fromFieldMetadataId: string;
@Field()
toFieldMetadataId: string;
@HideField()
workspaceId: string;
@Field()
@CreateDateColumn()
createdAt: Date;
@Field()
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,28 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
BeforeCreateOneHook,
CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { CreateRelationInput } from 'src/engine-metadata/relation-metadata/dtos/create-relation.input';
@Injectable()
export class BeforeCreateOneRelation<T extends CreateRelationInput>
implements BeforeCreateOneHook<T, any>
{
async run(
instance: CreateOneInputType<T>,
context: any,
): Promise<CreateOneInputType<T>> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
instance.input.workspaceId = workspaceId;
return instance;
}
}

View File

@ -0,0 +1,55 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { RelationMetadataService } from 'src/engine-metadata/relation-metadata/relation-metadata.service';
@Injectable()
export class BeforeDeleteOneRelation implements BeforeDeleteOneHook<any> {
constructor(readonly relationMetadataService: RelationMetadataService) {}
async run(
instance: DeleteOneInputType,
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const relationMetadata =
await this.relationMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!relationMetadata) {
throw new BadRequestException('Relation does not exist');
}
if (
!relationMetadata.toFieldMetadata.isCustom ||
!relationMetadata.fromFieldMetadata.isCustom
) {
throw new BadRequestException("Standard Relations can't be deleted");
}
if (
relationMetadata.toFieldMetadata.isActive ||
relationMetadata.fromFieldMetadata.isActive
) {
throw new BadRequestException("Active relations can't be deleted");
}
return instance;
}
}

View File

@ -0,0 +1,98 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { RelationMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/relation-metadata.interface';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
export enum RelationMetadataType {
ONE_TO_ONE = 'ONE_TO_ONE',
ONE_TO_MANY = 'ONE_TO_MANY',
MANY_TO_MANY = 'MANY_TO_MANY',
}
export enum RelationOnDeleteAction {
CASCADE = 'CASCADE',
RESTRICT = 'RESTRICT',
SET_NULL = 'SET_NULL',
NO_ACTION = 'NO_ACTION',
}
@Entity('relationMetadata')
export class RelationMetadataEntity implements RelationMetadataInterface {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
relationType: RelationMetadataType;
@Column({
nullable: false,
default: RelationOnDeleteAction.SET_NULL,
type: 'enum',
enum: RelationOnDeleteAction,
})
onDeleteAction: RelationOnDeleteAction;
@Column({ nullable: false, type: 'uuid' })
fromObjectMetadataId: string;
@Column({ nullable: false, type: 'uuid' })
toObjectMetadataId: string;
@Column({ nullable: false, type: 'uuid' })
fromFieldMetadataId: string;
@Column({ nullable: false, type: 'uuid' })
toFieldMetadataId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(
() => ObjectMetadataEntity,
(object: ObjectMetadataEntity) => object.fromRelations,
{
onDelete: 'CASCADE',
},
)
fromObjectMetadata: ObjectMetadataEntity;
@ManyToOne(
() => ObjectMetadataEntity,
(object: ObjectMetadataEntity) => object.toRelations,
{
onDelete: 'CASCADE',
},
)
toObjectMetadata: ObjectMetadataEntity;
@OneToOne(
() => FieldMetadataEntity,
(field: FieldMetadataEntity) => field.fromRelationMetadata,
)
@JoinColumn()
fromFieldMetadata: FieldMetadataEntity;
@OneToOne(
() => FieldMetadataEntity,
(field: FieldMetadataEntity) => field.toRelationMetadata,
)
@JoinColumn()
toFieldMetadata: FieldMetadataEntity;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import {
NestjsQueryGraphQLModule,
PagingStrategies,
} from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { FieldMetadataModule } from 'src/engine-metadata/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { RelationMetadataService } from './relation-metadata.service';
import { RelationMetadataEntity } from './relation-metadata.entity';
import { CreateRelationInput } from './dtos/create-relation.input';
import { RelationMetadataDTO } from './dtos/relation-metadata.dto';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[RelationMetadataEntity],
'metadata',
),
ObjectMetadataModule,
FieldMetadataModule,
WorkspaceMigrationRunnerModule,
WorkspaceMigrationModule,
],
services: [RelationMetadataService],
resolvers: [
{
EntityClass: RelationMetadataEntity,
DTOClass: RelationMetadataDTO,
ServiceClass: RelationMetadataService,
CreateDTOClass: CreateRelationInput,
pagingStrategy: PagingStrategies.CURSOR,
create: { many: { disabled: true } },
update: { disabled: true },
delete: { many: { disabled: true } },
guards: [JwtAuthGuard],
},
],
}),
],
providers: [RelationMetadataService],
exports: [RelationMetadataService],
})
export class RelationMetadataModule {}

View File

@ -0,0 +1,335 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FindOneOptions, In, Repository } from 'typeorm';
import camelCase from 'lodash.camelcase';
import { v4 as uuidV4 } from 'uuid';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { FieldMetadataService } from 'src/engine-metadata/field-metadata/field-metadata.service';
import { CreateRelationInput } from 'src/engine-metadata/relation-metadata/dtos/create-relation.input';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { WorkspaceMigrationService } from 'src/engine-metadata/workspace-migration/workspace-migration.service';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { WorkspaceMigrationColumnActionType } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { createCustomColumnName } from 'src/engine-metadata/utils/create-custom-column-name.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { createRelationForeignKeyColumnName } from 'src/engine-metadata/relation-metadata/utils/create-relation-foreign-key-column-name.util';
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
import {
RelationMetadataEntity,
RelationMetadataType,
RelationOnDeleteAction,
} from './relation-metadata.entity';
@Injectable()
export class RelationMetadataService extends TypeOrmQueryService<RelationMetadataEntity> {
constructor(
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(relationMetadataRepository);
}
override async createOne(
relationMetadataInput: CreateRelationInput,
): Promise<RelationMetadataEntity> {
const objectMetadataMap = await this.getObjectMetadataMap(
relationMetadataInput,
);
await this.validateCreateRelationMetadataInput(
relationMetadataInput,
objectMetadataMap,
);
// NOTE: this logic is called to create relation through metadata graphql endpoint (so only for custom field relations)
const isCustom = true;
const baseColumnName = `${camelCase(relationMetadataInput.toName)}Id`;
const foreignKeyColumnName = createRelationForeignKeyColumnName(
relationMetadataInput.toName,
isCustom,
);
const fromId = uuidV4();
const toId = uuidV4();
await this.fieldMetadataService.createMany([
this.createFieldMetadataForRelationMetadata(
relationMetadataInput,
'from',
isCustom,
fromId,
),
this.createFieldMetadataForRelationMetadata(
relationMetadataInput,
'to',
isCustom,
toId,
),
this.createForeignKeyFieldMetadata(
relationMetadataInput,
baseColumnName,
foreignKeyColumnName,
),
]);
const createdRelationMetadata = await super.createOne({
...relationMetadataInput,
fromFieldMetadataId: fromId,
toFieldMetadataId: toId,
});
await this.createWorkspaceCustomMigration(
relationMetadataInput,
objectMetadataMap,
foreignKeyColumnName,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
relationMetadataInput.workspaceId,
);
return createdRelationMetadata;
}
private async validateCreateRelationMetadataInput(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
) {
if (
relationMetadataInput.relationType === RelationMetadataType.MANY_TO_MANY
) {
throw new BadRequestException(
'Many to many relations are not supported yet',
);
}
if (
objectMetadataMap[relationMetadataInput.fromObjectMetadataId] ===
undefined ||
objectMetadataMap[relationMetadataInput.toObjectMetadataId] === undefined
) {
throw new NotFoundException(
'Can\t find an existing object matching with fromObjectMetadataId or toObjectMetadataId',
);
}
await this.checkIfFieldMetadataRelationNameExists(
relationMetadataInput,
objectMetadataMap,
'from',
);
await this.checkIfFieldMetadataRelationNameExists(
relationMetadataInput,
objectMetadataMap,
'to',
);
}
private async checkIfFieldMetadataRelationNameExists(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
relationDirection: 'from' | 'to',
) {
const fieldAlreadyExists =
await this.fieldMetadataService.findOneWithinWorkspace(
relationMetadataInput.workspaceId,
{
where: {
name: relationMetadataInput[`${relationDirection}Name`],
objectMetadataId:
relationMetadataInput[`${relationDirection}ObjectMetadataId`],
},
},
);
if (fieldAlreadyExists) {
throw new ConflictException(
`Field on ${
objectMetadataMap[
relationMetadataInput[`${relationDirection}ObjectMetadataId`]
].nameSingular
} already exists`,
);
}
}
private async createWorkspaceCustomMigration(
relationMetadataInput: CreateRelationInput,
objectMetadataMap: { [key: string]: ObjectMetadataEntity },
foreignKeyColumnName: string,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${relationMetadataInput.fromName}`),
relationMetadataInput.workspaceId,
[
// Create the column
{
name: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.toObjectMetadataId],
),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: foreignKeyColumnName,
columnType: 'uuid',
isNullable: true,
},
],
},
// Create the foreignKey
{
name: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.toObjectMetadataId],
),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: foreignKeyColumnName,
referencedTableName: computeObjectTargetTable(
objectMetadataMap[relationMetadataInput.fromObjectMetadataId],
),
referencedTableColumnName: 'id',
isUnique:
relationMetadataInput.relationType ===
RelationMetadataType.ONE_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
},
],
},
],
);
}
private createFieldMetadataForRelationMetadata(
relationMetadataInput: CreateRelationInput,
relationDirection: 'from' | 'to',
isCustom: boolean,
id?: string,
) {
return {
...(id && { id: id }),
name: relationMetadataInput[`${relationDirection}Name`],
label: relationMetadataInput[`${relationDirection}Label`],
description: relationMetadataInput[`${relationDirection}Description`],
icon: relationMetadataInput[`${relationDirection}Icon`],
isCustom: true,
targetColumnMap:
relationDirection === 'to'
? isCustom
? createCustomColumnName(relationMetadataInput.toName)
: relationMetadataInput.toName
: {},
isActive: true,
isNullable: true,
type: FieldMetadataType.RELATION,
objectMetadataId:
relationMetadataInput[`${relationDirection}ObjectMetadataId`],
workspaceId: relationMetadataInput.workspaceId,
};
}
private createForeignKeyFieldMetadata(
relationMetadataInput: CreateRelationInput,
baseColumnName: string,
foreignKeyColumnName: string,
) {
return {
name: baseColumnName,
label: `${relationMetadataInput.toLabel} Foreign Key`,
description: relationMetadataInput.toDescription
? `${relationMetadataInput.toDescription} Foreign Key`
: undefined,
icon: undefined,
isCustom: true,
targetColumnMap: { value: foreignKeyColumnName },
isActive: true,
isNullable: true,
isSystem: true,
type: FieldMetadataType.UUID,
objectMetadataId: relationMetadataInput.toObjectMetadataId,
workspaceId: relationMetadataInput.workspaceId,
};
}
private async getObjectMetadataMap(
relationMetadataInput: CreateRelationInput,
): Promise<{ [key: string]: ObjectMetadataEntity }> {
const objectMetadataEntries =
await this.objectMetadataService.findManyWithinWorkspace(
relationMetadataInput.workspaceId,
{
where: {
id: In([
relationMetadataInput.fromObjectMetadataId,
relationMetadataInput.toObjectMetadataId,
]),
},
},
);
return objectMetadataEntries.reduce(
(acc, curr) => {
acc[curr.id] = curr;
return acc;
},
{} as { [key: string]: ObjectMetadataEntity },
);
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<RelationMetadataEntity>,
) {
return this.relationMetadataRepository.findOne({
...options,
where: {
...options.where,
workspaceId,
},
relations: ['fromFieldMetadata', 'toFieldMetadata'],
});
}
override async deleteOne(id: string): Promise<RelationMetadataEntity> {
// TODO: This logic is duplicated with the BeforeDeleteOneRelation hook
const relationMetadata = await this.relationMetadataRepository.findOne({
where: { id },
relations: ['fromFieldMetadata', 'toFieldMetadata'],
});
if (!relationMetadata) {
throw new NotFoundException('Relation does not exist');
}
const deletedRelationMetadata = super.deleteOne(id);
// TODO: Move to a cdc scheduler
this.fieldMetadataService.deleteMany({
id: {
in: [
relationMetadata.fromFieldMetadataId,
relationMetadata.toFieldMetadataId,
],
},
});
return deletedRelationMetadata;
}
}

View File

@ -0,0 +1,14 @@
export type RelationToDelete = {
id: string;
fromFieldMetadataId: string;
toFieldMetadataId: string;
fromFieldMetadataName: string;
toFieldMetadataName: string;
fromObjectMetadataId: string;
toObjectMetadataId: string;
fromObjectName: string;
toObjectName: string;
toFieldMetadataIsCustom: boolean;
toObjectMetadataIsCustom: boolean;
direction: string;
};

View File

@ -0,0 +1,15 @@
import { createCustomColumnName } from 'src/engine-metadata/utils/create-custom-column-name.util';
import { camelCase } from 'src/utils/camel-case';
export const createRelationForeignKeyColumnName = (
name: string,
isCustom: boolean,
) => {
const baseColumnName = `${camelCase(name)}Id`;
const foreignKeyColumnName = isCustom
? createCustomColumnName(baseColumnName)
: baseColumnName;
return foreignKeyColumnName;
};

View File

@ -0,0 +1,5 @@
import { camelCase } from 'src/utils/camel-case';
export const createRelationForeignKeyFieldMetadataName = (name: string) => {
return `${camelCase(name)}Id`;
};

View File

@ -0,0 +1,3 @@
export const createCustomColumnName = (name: string) => {
return `_${name}`;
};

View File

@ -0,0 +1,25 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('workspaceCacheVersion')
export class WorkspaceCacheVersionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, nullable: false, type: 'uuid' })
workspaceId: string;
@Column()
version: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceCacheVersionEntity } from 'src/engine-metadata/workspace-cache-version/workspace-cache-version.entity';
import { WorkspaceCacheVersionService } from 'src/engine-metadata/workspace-cache-version/workspace-cache-version.service';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceCacheVersionEntity], 'metadata'),
],
exports: [WorkspaceCacheVersionService],
providers: [WorkspaceCacheVersionService],
})
export class WorkspaceCacheVersionModule {}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheVersionEntity } from 'src/engine-metadata/workspace-cache-version/workspace-cache-version.entity';
@Injectable()
export class WorkspaceCacheVersionService {
constructor(
@InjectRepository(WorkspaceCacheVersionEntity, 'metadata')
private readonly workspaceCacheVersionRepository: Repository<WorkspaceCacheVersionEntity>,
) {}
async incrementVersion(workspaceId: string): Promise<string> {
const workspaceCacheVersion = (await this.getVersion(workspaceId)) ?? '0';
const newVersion = `${+workspaceCacheVersion + 1}`;
await this.workspaceCacheVersionRepository.upsert(
{
workspaceId,
version: `${+workspaceCacheVersion + 1}`,
},
['workspaceId'],
);
return newVersion;
}
async getVersion(workspaceId: string): Promise<string | null> {
const workspaceCacheVersion =
await this.workspaceCacheVersionRepository.findOne({
where: { workspaceId },
});
return workspaceCacheVersion?.version ?? null;
}
}

View File

@ -0,0 +1,105 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceColumnActionOptions } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnAlter,
WorkspaceMigrationColumnCreate,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
import { fieldMetadataTypeToColumnType } from 'src/engine-metadata/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { ColumnActionAbstractFactory } from 'src/engine-metadata/workspace-migration/factories/column-action-abstract.factory';
export type BasicFieldMetadataType =
| FieldMetadataType.UUID
| FieldMetadataType.TEXT
| FieldMetadataType.PHONE
| FieldMetadataType.EMAIL
| FieldMetadataType.NUMERIC
| FieldMetadataType.NUMBER
| FieldMetadataType.PROBABILITY
| FieldMetadataType.BOOLEAN
| FieldMetadataType.POSITION
| FieldMetadataType.DATE_TIME
| FieldMetadataType.POSITION;
@Injectable()
export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicFieldMetadataType> {
protected readonly logger = new Logger(BasicColumnActionFactory.name);
protected handleCreateAction(
fieldMetadata: FieldMetadataInterface<BasicFieldMetadataType>,
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate {
const defaultValue =
this.getDefaultValue(fieldMetadata.defaultValue) ?? options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
return {
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: fieldMetadata.targetColumnMap.value,
columnType: fieldMetadataTypeToColumnType(fieldMetadata.type),
isNullable: fieldMetadata.isNullable,
defaultValue: serializedDefaultValue,
};
}
protected handleAlterAction(
currentFieldMetadata: FieldMetadataInterface<BasicFieldMetadataType>,
alteredFieldMetadata: FieldMetadataInterface<BasicFieldMetadataType>,
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter {
const defaultValue =
this.getDefaultValue(alteredFieldMetadata.defaultValue) ??
options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
const currentColumnName = currentFieldMetadata.targetColumnMap.value;
const alteredColumnName = alteredFieldMetadata.targetColumnMap.value;
if (!currentColumnName || !alteredColumnName) {
this.logger.error(
`Column name not found for current or altered field metadata, can be due to a missing or an invalid target column map. Current column name: ${currentColumnName}, Altered column name: ${alteredColumnName}.`,
);
throw new Error(
`Column name not found for current or altered field metadata`,
);
}
return {
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName: currentColumnName,
columnType: fieldMetadataTypeToColumnType(currentFieldMetadata.type),
isNullable: currentFieldMetadata.isNullable,
defaultValue: serializeDefaultValue(
this.getDefaultValue(currentFieldMetadata.defaultValue),
),
},
alteredColumnDefinition: {
columnName: alteredColumnName,
columnType: fieldMetadataTypeToColumnType(alteredFieldMetadata.type),
isNullable: alteredFieldMetadata.isNullable,
defaultValue: serializedDefaultValue,
},
};
}
private getDefaultValue(
defaultValue:
| FieldMetadataDefaultValue<BasicFieldMetadataType>
| undefined
| null,
) {
if (!defaultValue) return null;
if ('type' in defaultValue) {
return defaultValue;
} else {
return defaultValue?.value;
}
}
}

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Logger } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceColumnActionOptions } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { WorkspaceColumnActionFactory } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-factory.interface';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnCreate,
WorkspaceMigrationColumnAlter,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export class ColumnActionAbstractFactory<
T extends FieldMetadataType | 'default',
> implements WorkspaceColumnActionFactory<T>
{
protected readonly logger = new Logger(ColumnActionAbstractFactory.name);
create(
action:
| WorkspaceMigrationColumnActionType.CREATE
| WorkspaceMigrationColumnActionType.ALTER,
currentFieldMetadata: FieldMetadataInterface<T> | undefined,
alteredFieldMetadata: FieldMetadataInterface<T>,
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAction {
switch (action) {
case WorkspaceMigrationColumnActionType.CREATE:
return this.handleCreateAction(alteredFieldMetadata, options);
case WorkspaceMigrationColumnActionType.ALTER: {
if (!currentFieldMetadata) {
throw new Error('current field metadata is required for alter');
}
return this.handleAlterAction(
currentFieldMetadata,
alteredFieldMetadata,
options,
);
}
default: {
this.logger.error(`Invalid action: ${action}`);
throw new Error('[AbstractFactory]: invalid action');
}
}
}
protected handleCreateAction(
_fieldMetadata: FieldMetadataInterface<T>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate {
throw new Error('handleCreateAction method not implemented.');
}
protected handleAlterAction(
_currentFieldMetadata: FieldMetadataInterface<T>,
_alteredFieldMetadata: FieldMetadataInterface<T>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter {
throw new Error('handleAlterAction method not implemented.');
}
}

View File

@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceColumnActionOptions } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnAlter,
WorkspaceMigrationColumnCreate,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
import { fieldMetadataTypeToColumnType } from 'src/engine-metadata/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { ColumnActionAbstractFactory } from 'src/engine-metadata/workspace-migration/factories/column-action-abstract.factory';
export type EnumFieldMetadataType =
| FieldMetadataType.RATING
| FieldMetadataType.SELECT
| FieldMetadataType.MULTI_SELECT;
@Injectable()
export class EnumColumnActionFactory extends ColumnActionAbstractFactory<EnumFieldMetadataType> {
protected readonly logger = new Logger(EnumColumnActionFactory.name);
protected handleCreateAction(
fieldMetadata: FieldMetadataInterface<EnumFieldMetadataType>,
options: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate {
const defaultValue =
fieldMetadata.defaultValue?.value ?? options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
const enumOptions = fieldMetadata.options
? [...fieldMetadata.options.map((option) => option.value)]
: undefined;
return {
action: WorkspaceMigrationColumnActionType.CREATE,
columnName: fieldMetadata.targetColumnMap.value,
columnType: fieldMetadataTypeToColumnType(fieldMetadata.type),
enum: enumOptions,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: fieldMetadata.isNullable,
defaultValue: serializedDefaultValue,
};
}
protected handleAlterAction(
currentFieldMetadata: FieldMetadataInterface<EnumFieldMetadataType>,
alteredFieldMetadata: FieldMetadataInterface<EnumFieldMetadataType>,
options: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter {
const defaultValue =
alteredFieldMetadata.defaultValue?.value ?? options?.defaultValue;
const serializedDefaultValue = serializeDefaultValue(defaultValue);
const enumOptions = alteredFieldMetadata.options
? [
...alteredFieldMetadata.options.map((option) => {
const currentOption = currentFieldMetadata.options?.find(
(currentOption) => currentOption.id === option.id,
);
// The id is the same, but the value is different, so we need to alter the enum
if (currentOption && currentOption.value !== option.value) {
return {
from: currentOption.value,
to: option.value,
};
}
return option.value;
}),
]
: undefined;
const currentColumnName = currentFieldMetadata.targetColumnMap.value;
const alteredColumnName = alteredFieldMetadata.targetColumnMap.value;
if (!currentColumnName || !alteredColumnName) {
this.logger.error(
`Column name not found for current or altered field metadata, can be due to a missing or an invalid target column map. Current column name: ${currentColumnName}, Altered column name: ${alteredColumnName}.`,
);
throw new Error(
`Column name not found for current or altered field metadata`,
);
}
return {
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName: currentColumnName,
columnType: fieldMetadataTypeToColumnType(currentFieldMetadata.type),
enum: currentFieldMetadata.options
? [...currentFieldMetadata.options.map((option) => option.value)]
: undefined,
isArray: currentFieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: currentFieldMetadata.isNullable,
defaultValue: serializeDefaultValue(
currentFieldMetadata.defaultValue?.value,
),
},
alteredColumnDefinition: {
columnName: alteredColumnName,
columnType: fieldMetadataTypeToColumnType(alteredFieldMetadata.type),
enum: enumOptions,
isArray: alteredFieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: alteredFieldMetadata.isNullable,
defaultValue: serializedDefaultValue,
},
};
}
}

View File

@ -0,0 +1,7 @@
import { BasicColumnActionFactory } from 'src/engine-metadata/workspace-migration/factories/basic-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine-metadata/workspace-migration/factories/enum-column-action.factory';
export const workspaceColumnActionFactories = [
BasicColumnActionFactory,
EnumColumnActionFactory,
];

View File

@ -0,0 +1,21 @@
import { WorkspaceColumnActionOptions } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnAction,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
export interface WorkspaceColumnActionFactory<
T extends FieldMetadataType | 'default',
> {
create(
action:
| WorkspaceMigrationColumnActionType.CREATE
| WorkspaceMigrationColumnActionType.ALTER,
currentFieldMetadata: FieldMetadataInterface<T> | undefined,
alteredFieldMetadata: FieldMetadataInterface<T>,
options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAction;
}

View File

@ -0,0 +1,3 @@
export interface WorkspaceColumnActionOptions {
defaultValue?: string;
}

View File

@ -0,0 +1,35 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
fieldMetadataType: Type,
): string => {
/**
* Composite types are not implemented here, as they are flattened by their composite definitions.
* See src/metadata/field-metadata/composite-types for more information.
*/
switch (fieldMetadataType) {
case FieldMetadataType.UUID:
return 'uuid';
case FieldMetadataType.TEXT:
return 'text';
case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL:
return 'varchar';
case FieldMetadataType.NUMERIC:
return 'numeric';
case FieldMetadataType.NUMBER:
case FieldMetadataType.PROBABILITY:
case FieldMetadataType.POSITION:
return 'float';
case FieldMetadataType.BOOLEAN:
return 'boolean';
case FieldMetadataType.DATE_TIME:
return 'timestamp';
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
return 'enum';
default:
throw new Error(`Cannot convert ${fieldMetadataType} to column type.`);
}
};

View File

@ -0,0 +1,3 @@
export function generateMigrationName(name?: string): string {
return `${new Date().getTime()}${name ? `-${name}` : ''}`;
}

View File

@ -0,0 +1,97 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
import { RelationOnDeleteAction } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export enum WorkspaceMigrationColumnActionType {
CREATE = 'CREATE',
ALTER = 'ALTER',
CREATE_FOREIGN_KEY = 'CREATE_FOREIGN_KEY',
DROP_FOREIGN_KEY = 'DROP_FOREIGN_KEY',
DROP = 'DROP',
}
export type WorkspaceMigrationEnum = string | { from: string; to: string };
export interface WorkspaceMigrationColumnDefinition {
columnName: string;
columnType: string;
enum?: WorkspaceMigrationEnum[];
isArray?: boolean;
isNullable?: boolean;
defaultValue?: any;
}
export interface WorkspaceMigrationColumnCreate
extends WorkspaceMigrationColumnDefinition {
action: WorkspaceMigrationColumnActionType.CREATE;
}
export type WorkspaceMigrationColumnAlter = {
action: WorkspaceMigrationColumnActionType.ALTER;
currentColumnDefinition: WorkspaceMigrationColumnDefinition;
alteredColumnDefinition: WorkspaceMigrationColumnDefinition;
};
export type WorkspaceMigrationColumnCreateRelation = {
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY;
columnName: string;
referencedTableName: string;
referencedTableColumnName: string;
isUnique?: boolean;
onDelete?: RelationOnDeleteAction;
};
export type WorkspaceMigrationColumnDropRelation = {
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY;
columnName: string;
};
export type WorkspaceMigrationColumnDrop = {
action: WorkspaceMigrationColumnActionType.DROP;
columnName: string;
};
export type WorkspaceMigrationColumnAction = {
action: WorkspaceMigrationColumnActionType;
} & (
| WorkspaceMigrationColumnCreate
| WorkspaceMigrationColumnAlter
| WorkspaceMigrationColumnCreateRelation
| WorkspaceMigrationColumnDropRelation
| WorkspaceMigrationColumnDrop
);
export type WorkspaceMigrationTableAction = {
name: string;
action: 'create' | 'alter' | 'drop';
columns?: WorkspaceMigrationColumnAction[];
};
@Entity('workspaceMigration')
export class WorkspaceMigrationEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'jsonb' })
migrations: WorkspaceMigrationTableAction[];
@Column({ nullable: false })
name: string;
@Column({ default: false })
isCustom: boolean;
@Column({ nullable: true })
appliedAt?: Date;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,186 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceColumnActionFactory } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-factory.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceColumnActionOptions } from 'src/engine-metadata/workspace-migration/interfaces/workspace-column-action-options.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { BasicColumnActionFactory } from 'src/engine-metadata/workspace-migration/factories/basic-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine-metadata/workspace-migration/factories/enum-column-action.factory';
import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { compositeDefinitions } from 'src/engine-metadata/field-metadata/composite-types';
@Injectable()
export class WorkspaceMigrationFactory {
private readonly logger = new Logger(WorkspaceMigrationFactory.name);
private factoriesMap: Map<
FieldMetadataType,
{
factory: WorkspaceColumnActionFactory<any>;
options?: WorkspaceColumnActionOptions;
}
>;
constructor(
private readonly basicColumnActionFactory: BasicColumnActionFactory,
private readonly enumColumnActionFactory: EnumColumnActionFactory,
) {
this.factoriesMap = new Map<
FieldMetadataType,
{
factory: WorkspaceColumnActionFactory<any>;
options?: WorkspaceColumnActionOptions;
}
>([
[FieldMetadataType.UUID, { factory: this.basicColumnActionFactory }],
[
FieldMetadataType.TEXT,
{
factory: this.basicColumnActionFactory,
options: {
defaultValue: '',
},
},
],
[
FieldMetadataType.PHONE,
{
factory: this.basicColumnActionFactory,
options: {
defaultValue: '',
},
},
],
[
FieldMetadataType.EMAIL,
{
factory: this.basicColumnActionFactory,
options: {
defaultValue: '',
},
},
],
[FieldMetadataType.NUMERIC, { factory: this.basicColumnActionFactory }],
[FieldMetadataType.NUMBER, { factory: this.basicColumnActionFactory }],
[FieldMetadataType.POSITION, { factory: this.basicColumnActionFactory }],
[
FieldMetadataType.PROBABILITY,
{ factory: this.basicColumnActionFactory },
],
[FieldMetadataType.BOOLEAN, { factory: this.basicColumnActionFactory }],
[FieldMetadataType.DATE_TIME, { factory: this.basicColumnActionFactory }],
[FieldMetadataType.RATING, { factory: this.enumColumnActionFactory }],
[FieldMetadataType.SELECT, { factory: this.enumColumnActionFactory }],
[
FieldMetadataType.MULTI_SELECT,
{ factory: this.enumColumnActionFactory },
],
]);
}
createColumnActions(
action: WorkspaceMigrationColumnActionType.CREATE,
fieldMetadata: FieldMetadataInterface,
): WorkspaceMigrationColumnAction[];
createColumnActions(
action: WorkspaceMigrationColumnActionType.ALTER,
currentFieldMetadata: FieldMetadataInterface,
alteredFieldMetadata: FieldMetadataInterface,
): WorkspaceMigrationColumnAction[];
createColumnActions(
action:
| WorkspaceMigrationColumnActionType.CREATE
| WorkspaceMigrationColumnActionType.ALTER,
fieldMetadataOrCurrentFieldMetadata: FieldMetadataInterface,
undefinedOrAlteredFieldMetadata?: FieldMetadataInterface,
): WorkspaceMigrationColumnAction[] {
const currentFieldMetadata =
action === WorkspaceMigrationColumnActionType.ALTER
? fieldMetadataOrCurrentFieldMetadata
: undefined;
const alteredFieldMetadata =
action === WorkspaceMigrationColumnActionType.CREATE
? fieldMetadataOrCurrentFieldMetadata
: undefinedOrAlteredFieldMetadata;
if (!alteredFieldMetadata) {
this.logger.error(
`No field metadata provided for action ${action}`,
undefinedOrAlteredFieldMetadata,
);
throw new Error(`No field metadata provided for action ${action}`);
}
// If it's a composite field type, we need to create a column action for each of the fields
if (isCompositeFieldMetadataType(alteredFieldMetadata.type)) {
const fieldMetadataSplitterFunction = compositeDefinitions.get(
alteredFieldMetadata.type,
);
if (!fieldMetadataSplitterFunction) {
this.logger.error(
`No composite definition found for type ${alteredFieldMetadata.type}`,
{
alteredFieldMetadata,
},
);
throw new Error(
`No composite definition found for type ${alteredFieldMetadata.type}`,
);
}
const fieldMetadataCollection =
fieldMetadataSplitterFunction(alteredFieldMetadata);
return fieldMetadataCollection.map((fieldMetadata) =>
this.createColumnAction(action, fieldMetadata, fieldMetadata),
);
}
// Otherwise, we create a single column action
const columnAction = this.createColumnAction(
action,
currentFieldMetadata,
alteredFieldMetadata,
);
return [columnAction];
}
private createColumnAction(
action:
| WorkspaceMigrationColumnActionType.CREATE
| WorkspaceMigrationColumnActionType.ALTER,
currentFieldMetadata: FieldMetadataInterface | undefined,
alteredFieldMetadata: FieldMetadataInterface,
): WorkspaceMigrationColumnAction {
const { factory, options } =
this.factoriesMap.get(alteredFieldMetadata.type) ?? {};
if (!factory) {
this.logger.error(
`No factory found for type ${alteredFieldMetadata.type}`,
{
alteredFieldMetadata,
},
);
throw new Error(`No factory found for type ${alteredFieldMetadata.type}`);
}
return factory.create(
action,
currentFieldMetadata,
alteredFieldMetadata,
options,
);
}
}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { workspaceColumnActionFactories } from 'src/engine-metadata/workspace-migration/factories/factories';
import { WorkspaceMigrationFactory } from 'src/engine-metadata/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from './workspace-migration.service';
import { WorkspaceMigrationEntity } from './workspace-migration.entity';
@Module({
imports: [TypeOrmModule.forFeature([WorkspaceMigrationEntity], 'metadata')],
providers: [
...workspaceColumnActionFactories,
WorkspaceMigrationFactory,
WorkspaceMigrationService,
],
exports: [WorkspaceMigrationFactory, WorkspaceMigrationService],
})
export class WorkspaceMigrationModule {}

View File

@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import {
WorkspaceMigrationEntity,
WorkspaceMigrationTableAction,
} from './workspace-migration.entity';
@Injectable()
export class WorkspaceMigrationService {
constructor(
@InjectRepository(WorkspaceMigrationEntity, 'metadata')
private readonly workspaceMigrationRepository: Repository<WorkspaceMigrationEntity>,
) {}
/**
* Get all pending migrations for a given workspaceId
*
* @param workspaceId: string
* @returns Promise<WorkspaceMigration[]>
*/
public async getPendingMigrations(
workspaceId: string,
): Promise<WorkspaceMigrationEntity[]> {
return await this.workspaceMigrationRepository.find({
order: { createdAt: 'ASC', name: 'ASC' },
where: {
appliedAt: IsNull(),
workspaceId,
},
});
}
/**
* Set appliedAt as current date for a given migration.
* Should be called once the migration has been applied
*
* @param workspaceId: string
* @param migration: WorkspaceMigration
*/
public async setAppliedAtForMigration(
workspaceId: string,
migration: WorkspaceMigrationEntity,
) {
await this.workspaceMigrationRepository.save({
id: migration.id,
appliedAt: new Date(),
});
}
/**
* Create a new pending migration for a given workspaceId and expected changes
*
* @param workspaceId
* @param migrations
*/
public async createCustomMigration(
name: string,
workspaceId: string,
migrations: WorkspaceMigrationTableAction[],
) {
await this.workspaceMigrationRepository.save({
name,
migrations,
workspaceId,
isCustom: true,
});
}
public async delete(workspaceId: string) {
await this.workspaceMigrationRepository.delete({ workspaceId });
}
}