Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,54 @@
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
export const currencyFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
return [
{
id: 'amountMicros',
type: FieldMetadataType.NUMERIC,
objectMetadataId: FieldMetadataType.CURRENCY.toString(),
name: 'amountMicros',
label: 'AmountMicros',
targetColumnMap: {
value: fieldMetadata
? `${fieldMetadata.name}AmountMicros`
: 'amountMicros',
},
isNullable: true,
} satisfies FieldMetadataInterface,
{
id: 'currencyCode',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.CURRENCY.toString(),
name: 'currencyCode',
label: 'Currency Code',
targetColumnMap: {
value: fieldMetadata
? `${fieldMetadata.name}CurrencyCode`
: 'currencyCode',
},
isNullable: true,
} satisfies FieldMetadataInterface,
];
};
export const currencyObjectDefinition = {
id: FieldMetadataType.CURRENCY.toString(),
nameSingular: 'currency',
namePlural: 'currency',
labelSingular: 'Currency',
labelPlural: 'Currency',
targetTableName: '',
fields: currencyFields(),
fromRelations: [],
toRelations: [],
} satisfies ObjectMetadataInterface;
export type CurrencyMetadata = {
amountMicros: number;
currencyCode: string;
}

View File

@ -0,0 +1,50 @@
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
export const fullNameFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
return [
{
id: 'firstName',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.FULL_NAME.toString(),
name: 'firstName',
label: 'First Name',
targetColumnMap: {
value: fieldMetadata ? `${fieldMetadata.name}FirstName` : 'firstName',
},
isNullable: true,
} satisfies FieldMetadataInterface,
{
id: 'lastName',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.FULL_NAME.toString(),
name: 'lastName',
label: 'Last Name',
targetColumnMap: {
value: fieldMetadata ? `${fieldMetadata.name}LastName` : 'lastName',
},
isNullable: true,
} satisfies FieldMetadataInterface,
];
};
export const fullNameObjectDefinition = {
id: FieldMetadataType.FULL_NAME.toString(),
nameSingular: 'fullName',
namePlural: 'fullName',
labelSingular: 'FullName',
labelPlural: 'FullName',
targetTableName: '',
fields: fullNameFields(),
fromRelations: [],
toRelations: [],
} satisfies ObjectMetadataInterface;
export type FullNameMetadata = {
firstName: string;
lastName: string;
}

View File

@ -0,0 +1,50 @@
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
export const linkFields = (
fieldMetadata?: FieldMetadataInterface,
): FieldMetadataInterface[] => {
return [
{
id: 'label',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.LINK.toString(),
name: 'label',
label: 'Label',
targetColumnMap: {
value: fieldMetadata ? `${fieldMetadata.name}Label` : 'label',
},
isNullable: true,
} satisfies FieldMetadataInterface,
{
id: 'url',
type: FieldMetadataType.TEXT,
objectMetadataId: FieldMetadataType.LINK.toString(),
name: 'url',
label: 'Url',
targetColumnMap: {
value: fieldMetadata ? `${fieldMetadata.name}Url` : 'url',
},
isNullable: true,
} satisfies FieldMetadataInterface,
];
};
export const linkObjectDefinition = {
id: FieldMetadataType.LINK.toString(),
nameSingular: 'link',
namePlural: 'link',
labelSingular: 'Link',
labelPlural: 'Link',
targetTableName: '',
fields: linkFields(),
fromRelations: [],
toRelations: [],
} satisfies ObjectMetadataInterface;
export type LinkMetadata = {
label: string;
url: string;
}

View File

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

View File

@ -0,0 +1,85 @@
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
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)
@IsNumber()
amountMicros: number | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
currencyCode: string | null;
}
export class FieldMetadataDefaultValueFullName {
@ValidateIf((_object, value) => value !== null)
@IsString()
firstName: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
lastName: string | null;
}
export class FieldMetadataDynamicDefaultValueUuid {
@Matches('uuid')
@IsNotEmpty()
@IsString()
type: 'uuid';
}
export class FieldMetadataDynamicDefaultValueNow {
@Matches('now')
@IsNotEmpty()
@IsString()
type: 'now';
}

View File

@ -0,0 +1,134 @@
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/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationMetadataDTO } from 'src/metadata/relation-metadata/dtos/relation-metadata.dto';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { BeforeDeleteOneField } from 'src/metadata/field-metadata/hooks/before-delete-one-field.hook';
import { IsFieldMetadataDefaultValue } from 'src/metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/metadata/field-metadata/validators/is-field-metadata-options.validator';
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()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isCustom?: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isActive?: boolean;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isSystem?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isNullable?: boolean;
@Validate(IsFieldMetadataDefaultValue)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
defaultValue?: FieldMetadataDefaultValue<T>;
@Validate(IsFieldMetadataOptions)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions<T>;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}

View File

@ -0,0 +1,24 @@
import { IsString, IsNumber, IsOptional, IsNotEmpty } from 'class-validator';
export class FieldMetadataDefaultOptions {
@IsOptional()
@IsString()
id?: string;
@IsNumber()
position: number;
@IsNotEmpty()
@IsString()
label: string;
@IsNotEmpty()
@IsString()
value: string;
}
export class FieldMetadataComplexOptions extends FieldMetadataDefaultOptions {
@IsNotEmpty()
@IsString()
color: string;
}

View File

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

View File

@ -0,0 +1,118 @@
import {
Entity,
Unique,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
OneToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/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',
}
@Entity('fieldMetadata')
@Unique('IndexOnNameObjectMetadataIdAndWorkspaceIdUnique', [
'name',
'objectMetadataId',
'workspaceId',
])
export class FieldMetadataEntity<
T extends FieldMetadataType | 'default' = 'default',
> implements FieldMetadataInterface<T>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@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 })
workspaceId: string;
@OneToOne(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.fromFieldMetadata,
)
fromRelationMetadata: RelationMetadataEntity;
@OneToOne(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.toFieldMetadata,
)
toRelationMetadata: RelationMetadataEntity;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,75 @@
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/workspace/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { IsFieldMetadataDefaultValue } from 'src/metadata/field-metadata/validators/is-field-metadata-default-value.validator';
import { FieldMetadataResolver } from 'src/metadata/field-metadata/field-metadata.resolver';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { IsFieldMetadataOptions } from 'src/metadata/field-metadata/validators/is-field-metadata-options.validator';
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], 'metadata'),
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
ObjectMetadataModule,
DataSourceModule,
TypeORMModule,
],
services: [IsFieldMetadataDefaultValue, FieldMetadataService],
resolvers: [
{
EntityClass: FieldMetadataEntity,
DTOClass: FieldMetadataDTO,
CreateDTOClass: CreateFieldInput,
UpdateDTOClass: UpdateFieldInput,
ServiceClass: FieldMetadataService,
enableTotalCount: true,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
update: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
},
delete: { many: { disabled: true } },
guards: [JwtAuthGuard],
},
],
}),
],
providers: [
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataResolver,
],
exports: [FieldMetadataService],
})
export class FieldMetadataModule {}

View File

@ -0,0 +1,38 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { CreateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/create-field.input';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { UpdateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/update-field.input';
import { FieldMetadataService } from 'src/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,
});
}
}

View File

@ -0,0 +1,255 @@
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/workspace/workspace-migration-runner/workspace-migration-runner.service';
import { WorkspaceMigrationService } from 'src/metadata/workspace-migration/workspace-migration.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { CreateFieldInput } from 'src/metadata/field-metadata/dtos/create-field.input';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
} from 'src/metadata/workspace-migration/workspace-migration.entity';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.input';
import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory';
import { FieldMetadataEntity } from './field-metadata.entity';
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
constructor(
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
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(
record: CreateFieldInput,
): Promise<FieldMetadataEntity> {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
record.workspaceId,
{
where: {
id: record.objectMetadataId,
},
},
);
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({
where: {
name: record.name,
objectMetadataId: record.objectMetadataId,
workspaceId: record.workspaceId,
},
});
if (fieldAlreadyExists) {
throw new ConflictException('Field already exists');
}
const createdFieldMetadata = await super.createOne({
...record,
targetColumnMap: generateTargetColumnMap(record.type, true, record.name),
options: record.options
? record.options.map((option) => ({
...option,
id: uuidV4(),
}))
: undefined,
isActive: true,
isCustom: true,
});
await this.workspaceMigrationService.createCustomMigration(
record.workspaceId,
[
{
name: objectMetadata.targetTableName,
action: 'alter',
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
} satisfies WorkspaceMigrationTableAction,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
record.workspaceId,
);
// TODO: Move viewField creation to a cdc scheduler
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
record.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,
record: UpdateFieldInput,
): Promise<FieldMetadataEntity> {
const existingFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
id,
workspaceId: record.workspaceId,
},
});
if (!existingFieldMetadata) {
throw new NotFoundException('Field does not exist');
}
if (existingFieldMetadata.isCustom === false) {
// We can only update the isActive field for standard fields
record = {
id: record.id,
isActive: record.isActive,
workspaceId: record.workspaceId,
};
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
record.workspaceId,
{
where: {
id: existingFieldMetadata?.objectMetadataId,
},
},
);
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
// Check if the id of the options has been provided
if (record.options) {
for (const option of record.options) {
if (!option.id) {
throw new BadRequestException('Option id is required');
}
}
}
const updatedFieldMetadata = await super.updateOne(id, record);
if (record.options || record.defaultValue) {
await this.workspaceMigrationService.createCustomMigration(
existingFieldMetadata.workspaceId,
[
{
name: objectMetadata.targetTableName,
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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
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[];
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/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: '',
};
default:
return null;
}
}

View File

@ -0,0 +1,60 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { createCustomColumnName } from 'src/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:
return {
value: columnName,
};
case FieldMetadataType.LINK:
return {
label: `${columnName}Label`,
url: `${columnName}Url`,
};
case FieldMetadataType.CURRENCY:
return {
amountMicros: `${columnName}AmountMicros`,
currencyCode: `${columnName}CurrencyCode`,
};
case FieldMetadataType.FULL_NAME:
return {
firstName: `${columnName}FirstName`,
lastName: `${columnName}LastName`,
};
case FieldMetadataType.RELATION:
return {};
default:
throw new BadRequestException(`Unknown type ${type}`);
}
}

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataDefaultSerializableValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
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
) {
switch (defaultValue.type) {
case 'uuid':
return 'public.uuid_generate_v4()';
case 'now':
return 'now()';
default:
throw new BadRequestException('Invalid dynamic default value type');
}
}
// Static default values
if (typeof defaultValue === 'string') {
return `'${defaultValue}'`;
}
if (typeof defaultValue === 'number') {
return defaultValue;
}
if (typeof defaultValue === 'boolean') {
return defaultValue;
}
if (defaultValue instanceof Date) {
return `'${defaultValue.toISOString()}'`;
}
if (Array.isArray(defaultValue)) {
return defaultValue;
}
if (typeof defaultValue === 'object') {
return `'${JSON.stringify(defaultValue)}'`;
}
throw new BadRequestException('Invalid default value');
};

View File

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

View File

@ -0,0 +1,50 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
FieldMetadataComplexOptions,
FieldMetadataDefaultOptions,
} from 'src/metadata/field-metadata/dtos/options.input';
export const optionsValidatorsMap = {
[FieldMetadataType.RATING]: [FieldMetadataDefaultOptions],
[FieldMetadataType.SELECT]: [FieldMetadataComplexOptions],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOptions],
};
export const validateOptionsForType = (
type: FieldMetadataType,
options: FieldMetadataOptions,
): boolean => {
if (options === null) return true;
if (!Array.isArray(options)) {
return false;
}
const validators = optionsValidatorsMap[type];
if (!validators) return false;
const isValid = options.every((option) => {
return validators.some((validator) => {
const optionsInstance = plainToInstance<
any,
FieldMetadataDefaultOptions | FieldMetadataComplexOptions
>(validator, option);
return (
validateSync(optionsInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
}).length === 0
);
});
});
return isValid;
};

View File

@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/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
// @ts-expect-error Todo: Fix typing error
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataService.findOneOrFail(id);
} catch {
return false;
}
type = fieldMetadata.type;
}
return validateDefaultValueForType(type, value);
}
defaultMessage(): string {
return 'FieldMetadataDefaultValue is not valid';
}
}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { ValidationArguments, ValidatorConstraint } from 'class-validator';
import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { validateOptionsForType } from 'src/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
// @ts-expect-error Todo: Fix typing error
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';
}
}