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:
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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],
|
||||
]);
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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()}'`);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
@ -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');
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
|
||||
export const createRelationForeignKeyFieldMetadataName = (name: string) => {
|
||||
return `${camelCase(name)}Id`;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export const createCustomColumnName = (name: string) => {
|
||||
return `_${name}`;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export interface WorkspaceColumnActionOptions {
|
||||
defaultValue?: string;
|
||||
}
|
||||
@ -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.`);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export function generateMigrationName(name?: string): string {
|
||||
return `${new Date().getTime()}${name ? `-${name}` : ''}`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user