Add identifier fields to ObjectMetadata (#2616)

* Add indentifier fields to ObjectMetadata

* Add indentifier fields to ObjectMetadata

* Add indentifier fields to ObjectMetadata

* temporarily block name/label edition
This commit is contained in:
Weiko
2023-11-21 18:41:48 +01:00
committed by GitHub
parent 726e375616
commit c74bde28b8
17 changed files with 408 additions and 94 deletions

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIdentifierFieldToObjectMetadata1700565712112
implements MigrationInterface
{
name = 'AddIdentifierFieldToObjectMetadata1700565712112';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" ADD "labelIdentifierFieldMetadataId" character varying`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" ADD "imageIdentifierFieldMetadataId" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "imageIdentifierFieldMetadataId"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "labelIdentifierFieldMetadataId"`,
);
}
}

View File

@ -8,6 +8,7 @@ import {
import {
Authorize,
BeforeDeleteOne,
FilterableField,
IDField,
QueryOptions,
@ -16,6 +17,7 @@ import {
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';
registerEnumType(FieldMetadataType, {
name: 'FieldMetadataType',
@ -33,6 +35,7 @@ registerEnumType(FieldMetadataType, {
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneField)
@Relation('toRelationMetadata', () => RelationMetadataDTO, {
nullable: true,
})

View File

@ -1,8 +1,12 @@
import { Field, InputType } from '@nestjs/graphql';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { BeforeUpdateOneField } from 'src/metadata/field-metadata/hooks/before-update-one-field.hook';
@InputType()
@BeforeUpdateOne(BeforeUpdateOneField)
export class UpdateFieldInput {
@IsString()
@IsOptional()

View File

@ -1,5 +1,4 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@ -8,12 +7,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { DeleteOneOptions } from '@ptc-org/nestjs-query-core';
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 { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { CreateFieldInput } from 'src/metadata/field-metadata/dtos/create-field.input';
import { WorkspaceMigrationTableAction } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
@ -34,30 +31,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
super(fieldMetadataRepository);
}
override async deleteOne(
id: string,
opts?: DeleteOneOptions<FieldMetadataDTO> | undefined,
): Promise<FieldMetadataEntity> {
const fieldMetadata = await this.fieldMetadataRepository.findOne({
where: { id },
});
if (!fieldMetadata) {
throw new NotFoundException('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");
}
// TODO: delete associated relation-metadata and field-metadata from the relation
return super.deleteOne(id, opts);
}
override async createOne(
record: CreateFieldInput,
): Promise<FieldMetadataEntity> {
@ -108,6 +81,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return createdFieldMetadata;
}
public async findOneWithinWorkspace(
fieldMetadataId: string,
workspaceId: string,
) {
return this.fieldMetadataRepository.findOne({
where: { id: fieldMetadataId, workspaceId },
});
}
public async deleteFieldsMetadata(workspaceId: string) {
await this.fieldMetadataRepository.delete({ workspaceId });
}

View File

@ -0,0 +1,55 @@
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(
instance.id.toString(),
workspaceId,
);
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,57 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeUpdateOneHook,
UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.input';
import { FieldMetadataService } from 'src/metadata/field-metadata/field-metadata.service';
@Injectable()
export class BeforeUpdateOneField<T extends UpdateFieldInput>
implements BeforeUpdateOneHook<T, any>
{
constructor(readonly fieldMetadataService: FieldMetadataService) {}
// 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 fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(
instance.id.toString(),
workspaceId,
);
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
}
if (!fieldMetadata.isCustom) {
throw new BadRequestException("Standard Fields can't be updated");
}
this.checkIfFieldIsEditable(instance.update);
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(update: UpdateFieldInput) {
if (update.name || update.label) {
throw new BadRequestException("Field's name and label can't be updated");
}
}
}

View File

@ -2,6 +2,7 @@ import { ObjectType, ID, Field, HideField } from '@nestjs/graphql';
import {
Authorize,
BeforeDeleteOne,
CursorConnection,
FilterableField,
IDField,
@ -9,6 +10,7 @@ import {
} from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto';
import { BeforeDeleteOneObject } from 'src/metadata/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('object')
@Authorize({
@ -21,6 +23,7 @@ import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadat
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneObject)
@CursorConnection('fields', () => FieldMetadataDTO)
export class ObjectMetadataDTO {
@IDField(() => ID)

View File

@ -1,8 +1,12 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
import { BeforeUpdateOneObject } from 'src/metadata/object-metadata/hooks/before-update-one-object.hook';
@InputType()
@BeforeUpdateOne(BeforeUpdateOneObject)
export class UpdateObjectInput {
@IsString()
@IsOptional()
@ -38,4 +42,14 @@ export class UpdateObjectInput {
@IsOptional()
@Field({ nullable: true })
isActive?: boolean;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
}

View File

@ -5,15 +5,12 @@ import {
CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { CreateObjectInput } from 'src/metadata/object-metadata/dtos/create-object.input';
@Injectable()
export class BeforeCreateOneObject<T extends CreateObjectInput>
implements BeforeCreateOneHook<T, any>
{
constructor(readonly dataSourceService: DataSourceService) {}
async run(
instance: CreateOneInputType<T>,
context: any,
@ -24,12 +21,6 @@ export class BeforeCreateOneObject<T extends CreateObjectInput>
throw new UnauthorizedException();
}
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
instance.input.dataSourceId = lastDataSourceMetadata.id;
instance.input.workspaceId = workspaceId;
return instance;
}

View File

@ -0,0 +1,48 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { ObjectMetadataService } from 'src/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(
instance.id.toString(),
workspaceId,
);
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
throw new BadRequestException("Standard Objects can't be deleted");
}
if (objectMetadata.isActive) {
throw new BadRequestException("Active objects can't be deleted");
}
return instance;
}
}

View File

@ -0,0 +1,102 @@
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/metadata/field-metadata/field-metadata.entity';
import { UpdateObjectInput } from 'src/metadata/object-metadata/dtos/update-object.input';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
@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(
instance.id.toString(),
workspaceId,
);
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
throw new BadRequestException("Standard Objects can't be updated");
}
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);
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(update: UpdateObjectInput) {
if (
update.nameSingular ||
update.namePlural ||
update.labelSingular ||
update.labelPlural
) {
throw new BadRequestException("Object's name and label can't be updated");
}
}
}

View File

@ -58,6 +58,12 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@Column({ default: false })
isSystem: boolean;
@Column({ nullable: true })
labelIdentifierFieldMetadataId?: string;
@Column({ nullable: true })
imageIdentifierFieldMetadataId?: string;
@Column({ nullable: false })
workspaceId: string;

View File

@ -12,6 +12,7 @@ import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migratio
import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataService } from './object-metadata.service';
import { ObjectMetadataEntity } from './object-metadata.entity';
@ -25,7 +26,10 @@ import { ObjectMetadataDTO } from './dtos/object-metadata.dto';
NestjsQueryGraphQLModule.forFeature({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
NestjsQueryTypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
DataSourceModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,

View File

@ -1,8 +1,4 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Equal, In, Repository } from 'typeorm';
@ -37,31 +33,17 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
super(objectMetadataRepository);
}
override async deleteOne(id: string): Promise<ObjectMetadataEntity> {
const objectMetadata = await this.objectMetadataRepository.findOne({
where: { id },
});
if (!objectMetadata) {
throw new NotFoundException('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 super.deleteOne(id);
}
override async createOne(
record: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
record.workspaceId,
);
const createdObjectMetadata = await super.createOne({
...record,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: `_${record.nameSingular}`,
isActive: true,
isCustom: true,
@ -208,23 +190,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
}
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
return this.objectMetadataRepository.find({
where: { dataSourceId },
relations: [
'fields',
'fields.fromRelationMetadata',
'fields.fromRelationMetadata.fromObjectMetadata',
'fields.fromRelationMetadata.toObjectMetadata',
'fields.fromRelationMetadata.toObjectMetadata.fields',
'fields.toRelationMetadata',
'fields.toRelationMetadata.fromObjectMetadata',
'fields.toRelationMetadata.fromObjectMetadata.fields',
'fields.toRelationMetadata.toObjectMetadata',
],
});
}
public async findOneWithinWorkspace(
objectMetadataId: string,
workspaceId: string,

View File

@ -9,6 +9,7 @@ import {
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
import {
Authorize,
BeforeDeleteOne,
IDField,
QueryOptions,
Relation,
@ -16,6 +17,7 @@ import {
import { ObjectMetadataDTO } from 'src/metadata/object-metadata/dtos/object-metadata.dto';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import { BeforeDeleteOneRelation } from 'src/metadata/relation-metadata/hooks/before-delete-one-field.hook';
registerEnumType(RelationMetadataType, {
name: 'RelationMetadataType',
@ -34,6 +36,7 @@ registerEnumType(RelationMetadataType, {
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneRelation)
@Relation('fromObjectMetadata', () => ObjectMetadataDTO)
@Relation('toObjectMetadata', () => ObjectMetadataDTO)
export class RelationMetadataDTO {

View File

@ -0,0 +1,54 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { RelationMetadataService } from 'src/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(
instance.id.toString(),
workspaceId,
);
if (!relationMetadata) {
throw new BadRequestException('Relation does not exist');
}
if (
!relationMetadata.toFieldMetadata.isCustom ||
!relationMetadata.fromFieldMetadata.isCustom
) {
throw new BadRequestException("Standard Relations can't be deleted");
}
if (
relationMetadata.toFieldMetadata.isActive ||
relationMetadata.fromFieldMetadata.isActive
) {
throw new BadRequestException("Active relations can't be deleted");
}
return instance;
}
}

View File

@ -36,6 +36,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
}
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'],
@ -45,22 +46,9 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
throw new NotFoundException('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");
}
const deletedRelationMetadata = super.deleteOne(id);
// TODO: Move to a cdc scheduler
this.fieldMetadataService.deleteMany({
id: {
in: [
@ -213,4 +201,14 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
return createdRelationMetadata;
}
public async findOneWithinWorkspace(
relationMetadataId: string,
workspaceId: string,
) {
return this.relationMetadataRepository.findOne({
where: { id: relationMetadataId, workspaceId },
relations: ['fromFieldMetadata', 'toFieldMetadata'],
});
}
}