feat: add object/field create/update resolvers (#1963)
* feat: add object/field create/update resolvers * fix tests
This commit is contained in:
@ -0,0 +1,51 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateFieldInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
displayName: string;
|
||||
|
||||
// Todo: use a type enum and share with typeorm entity
|
||||
@IsEnum([
|
||||
'text',
|
||||
'phone',
|
||||
'email',
|
||||
'number',
|
||||
'boolean',
|
||||
'date',
|
||||
'url',
|
||||
'money',
|
||||
])
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@IsUUID()
|
||||
@Field()
|
||||
objectId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
icon?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
placeholder?: string;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateFieldInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
displayName: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
icon?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
placeholder?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
isActive?: boolean;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { SortDirection } from '@ptc-org/nestjs-query-core';
|
||||
import {
|
||||
AutoResolverOpts,
|
||||
PagingStrategies,
|
||||
ReadResolverOpts,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
|
||||
import { FieldMetadata } from './field-metadata.entity';
|
||||
|
||||
import { FieldMetadataService } from './services/field-metadata.service';
|
||||
import { CreateFieldInput } from './dtos/create-field.input';
|
||||
import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
|
||||
export const fieldMetadataAutoResolverOpts: AutoResolverOpts<
|
||||
any,
|
||||
any,
|
||||
unknown,
|
||||
unknown,
|
||||
ReadResolverOpts<any>,
|
||||
PagingStrategies
|
||||
>[] = [
|
||||
{
|
||||
EntityClass: FieldMetadata,
|
||||
DTOClass: FieldMetadata,
|
||||
CreateDTOClass: CreateFieldInput,
|
||||
UpdateDTOClass: UpdateFieldInput,
|
||||
ServiceClass: FieldMetadataService,
|
||||
enableTotalCount: true,
|
||||
pagingStrategy: PagingStrategies.CURSOR,
|
||||
read: {
|
||||
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
|
||||
},
|
||||
create: {
|
||||
many: { disabled: true },
|
||||
},
|
||||
update: {
|
||||
many: { disabled: true },
|
||||
},
|
||||
delete: { disabled: true },
|
||||
guards: [JwtAuthGuard],
|
||||
},
|
||||
];
|
||||
@ -11,29 +11,32 @@ import {
|
||||
} from 'typeorm';
|
||||
import {
|
||||
Authorize,
|
||||
BeforeCreateOne,
|
||||
IDField,
|
||||
QueryOptions,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
import { BeforeCreateOneField } from './hooks/before-create-one-field.hook';
|
||||
|
||||
export type FieldMetadataTargetColumnMap = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
@Entity('field_metadata')
|
||||
@ObjectType('field')
|
||||
@BeforeCreateOne(BeforeCreateOneField)
|
||||
@Authorize({
|
||||
authorize: (context: any) => ({
|
||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||
}),
|
||||
})
|
||||
@QueryOptions({
|
||||
defaultResultSize: 10,
|
||||
maxResultsSize: 100,
|
||||
disableFilter: true,
|
||||
disableSort: true,
|
||||
})
|
||||
@Authorize({
|
||||
authorize: (context: any) => ({
|
||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||
}),
|
||||
})
|
||||
@Entity('field_metadata')
|
||||
export class FieldMetadata {
|
||||
@IDField(() => ID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@ -90,9 +93,11 @@ export class FieldMetadata {
|
||||
@JoinColumn({ name: 'object_id' })
|
||||
object: ObjectMetadata;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@ -1,34 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
NestjsQueryGraphQLModule,
|
||||
PagingStrategies,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { MigrationRunnerModule } from 'src/metadata/migration-runner/migration-runner.module';
|
||||
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
||||
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
|
||||
|
||||
import { FieldMetadataService } from './field-metadata.service';
|
||||
import { FieldMetadata } from './field-metadata.entity';
|
||||
import { fieldMetadataAutoResolverOpts } from './field-metadata.auto-resolver-opts';
|
||||
|
||||
import { FieldMetadataService } from './services/field-metadata.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([FieldMetadata], 'metadata'),
|
||||
TenantMigrationModule,
|
||||
MigrationRunnerModule,
|
||||
ObjectMetadataModule,
|
||||
],
|
||||
resolvers: [
|
||||
{
|
||||
EntityClass: FieldMetadata,
|
||||
DTOClass: FieldMetadata,
|
||||
enableTotalCount: true,
|
||||
pagingStrategy: PagingStrategies.CURSOR,
|
||||
create: { disabled: true },
|
||||
update: { disabled: true },
|
||||
delete: { disabled: true },
|
||||
guards: [JwtAuthGuard],
|
||||
},
|
||||
],
|
||||
services: [FieldMetadataService],
|
||||
resolvers: fieldMetadataAutoResolverOpts,
|
||||
}),
|
||||
],
|
||||
providers: [FieldMetadataService],
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FieldMetadata } from './field-metadata.entity';
|
||||
import {
|
||||
generateColumnName,
|
||||
generateTargetColumnMap,
|
||||
} from './field-metadata.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(FieldMetadata, 'metadata')
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadata>,
|
||||
) {}
|
||||
|
||||
public async createFieldMetadata(
|
||||
displayName: string,
|
||||
type: string,
|
||||
objectId: string,
|
||||
workspaceId: string,
|
||||
): Promise<FieldMetadata> {
|
||||
return await this.fieldMetadataRepository.save({
|
||||
displayName: displayName,
|
||||
type,
|
||||
objectId,
|
||||
isCustom: true,
|
||||
targetColumnName: generateColumnName(displayName), // deprecated
|
||||
workspaceId,
|
||||
targetColumnMap: generateTargetColumnMap(type),
|
||||
});
|
||||
}
|
||||
|
||||
public async getFieldMetadataByDisplayNameAndObjectId(
|
||||
name: string,
|
||||
objectId: string,
|
||||
): Promise<FieldMetadata | null> {
|
||||
return await this.fieldMetadataRepository.findOne({
|
||||
where: { displayName: name, objectId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { uuidToBase36 } from 'src/metadata/data-source/data-source.util';
|
||||
|
||||
import { FieldMetadataTargetColumnMap } from './field-metadata.entity';
|
||||
|
||||
/**
|
||||
* Generate a column name from a field name removing unsupported characters.
|
||||
*
|
||||
* @param name string
|
||||
* @returns string
|
||||
*/
|
||||
export function generateColumnName(name: string): string {
|
||||
return name.toLowerCase().replace(/ /g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string,
|
||||
): FieldMetadataTargetColumnMap {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'phone':
|
||||
case 'email':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'date':
|
||||
return {
|
||||
value: uuidToBase36(v4()),
|
||||
};
|
||||
case 'url':
|
||||
return {
|
||||
text: uuidToBase36(v4()),
|
||||
link: uuidToBase36(v4()),
|
||||
};
|
||||
case 'money':
|
||||
return {
|
||||
amount: uuidToBase36(v4()),
|
||||
currency: uuidToBase36(v4()),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown type ${type}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
BeforeCreateOneHook,
|
||||
CreateOneInputType,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BeforeCreateOneField<T extends FieldMetadata>
|
||||
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;
|
||||
instance.input.isActive = false;
|
||||
instance.input.isCustom = true;
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
|
||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
||||
|
||||
import { FieldMetadataService } from './field-metadata.service';
|
||||
import { FieldMetadata } from './field-metadata.entity';
|
||||
|
||||
describe('FieldMetadataService', () => {
|
||||
let service: FieldMetadataService;
|
||||
@ -15,6 +19,18 @@ describe('FieldMetadataService', () => {
|
||||
provide: getRepositoryToken(FieldMetadata, 'metadata'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TenantMigrationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: MigrationRunnerService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
convertFieldMetadataToColumnChanges,
|
||||
convertMetadataTypeToColumnType,
|
||||
generateColumnName,
|
||||
generateTargetColumnMap,
|
||||
} from 'src/metadata/field-metadata/utils/field-metadata.util';
|
||||
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
|
||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
||||
import {
|
||||
TenantMigrationColumnChange,
|
||||
TenantMigrationTableChange,
|
||||
} from 'src/metadata/tenant-migration/tenant-migration.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadata> {
|
||||
constructor(
|
||||
@InjectRepository(FieldMetadata, 'metadata')
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadata>,
|
||||
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly tenantMigrationService: TenantMigrationService,
|
||||
private readonly migrationRunnerService: MigrationRunnerService,
|
||||
) {
|
||||
super(fieldMetadataRepository);
|
||||
}
|
||||
|
||||
override async createOne(record: FieldMetadata): Promise<FieldMetadata> {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(
|
||||
record.objectId,
|
||||
record.workspaceId,
|
||||
);
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new NotFoundException('Object does not exist');
|
||||
}
|
||||
|
||||
const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({
|
||||
where: {
|
||||
displayName: record.displayName,
|
||||
objectId: record.objectId,
|
||||
workspaceId: record.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (fieldAlreadyExists) {
|
||||
throw new ConflictException('Field already exists');
|
||||
}
|
||||
|
||||
const createdFieldMetadata = await super.createOne({
|
||||
...record,
|
||||
targetColumnName: generateColumnName(record.displayName), // deprecated
|
||||
targetColumnMap: generateTargetColumnMap(record.type),
|
||||
});
|
||||
|
||||
await this.tenantMigrationService.createMigration(record.workspaceId, [
|
||||
{
|
||||
name: objectMetadata.targetTableName,
|
||||
change: 'alter',
|
||||
columns: [
|
||||
...convertFieldMetadataToColumnChanges(createdFieldMetadata),
|
||||
// Deprecated
|
||||
{
|
||||
name: createdFieldMetadata.targetColumnName,
|
||||
type: convertMetadataTypeToColumnType(record.type),
|
||||
change: 'create',
|
||||
} satisfies TenantMigrationColumnChange,
|
||||
],
|
||||
} satisfies TenantMigrationTableChange,
|
||||
]);
|
||||
|
||||
await this.migrationRunnerService.executeMigrationFromPendingMigrations(
|
||||
record.workspaceId,
|
||||
);
|
||||
|
||||
return createdFieldMetadata;
|
||||
}
|
||||
|
||||
public async getFieldMetadataByDisplayNameAndObjectId(
|
||||
name: string,
|
||||
objectId: string,
|
||||
): Promise<FieldMetadata | null> {
|
||||
return await this.fieldMetadataRepository.findOne({
|
||||
where: { displayName: name, objectId },
|
||||
});
|
||||
}
|
||||
}
|
||||
150
server/src/metadata/field-metadata/utils/field-metadata.util.ts
Normal file
150
server/src/metadata/field-metadata/utils/field-metadata.util.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { uuidToBase36 } from 'src/metadata/data-source/data-source.util';
|
||||
import {
|
||||
FieldMetadata,
|
||||
FieldMetadataTargetColumnMap,
|
||||
} from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { TenantMigrationColumnChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
|
||||
|
||||
/**
|
||||
* Generate a column name from a field name removing unsupported characters.
|
||||
*
|
||||
* @param name string
|
||||
* @returns string
|
||||
*/
|
||||
export function generateColumnName(name: string): string {
|
||||
return name.toLowerCase().replace(/ /g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string,
|
||||
): FieldMetadataTargetColumnMap {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'phone':
|
||||
case 'email':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'date':
|
||||
return {
|
||||
value: uuidToBase36(v4()),
|
||||
};
|
||||
case 'url':
|
||||
return {
|
||||
text: uuidToBase36(v4()),
|
||||
link: uuidToBase36(v4()),
|
||||
};
|
||||
case 'money':
|
||||
return {
|
||||
amount: uuidToBase36(v4()),
|
||||
currency: uuidToBase36(v4()),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown type ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFieldMetadataToColumnChanges(
|
||||
fieldMetadata: FieldMetadata,
|
||||
): TenantMigrationColumnChange[] {
|
||||
switch (fieldMetadata.type) {
|
||||
case 'text':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.value,
|
||||
change: 'create',
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
case 'phone':
|
||||
case 'email':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.value,
|
||||
change: 'create',
|
||||
type: 'varchar',
|
||||
},
|
||||
];
|
||||
case 'number':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.value,
|
||||
change: 'create',
|
||||
type: 'integer',
|
||||
},
|
||||
];
|
||||
case 'boolean':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.value,
|
||||
change: 'create',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
case 'date':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.value,
|
||||
change: 'create',
|
||||
type: 'timestamp',
|
||||
},
|
||||
];
|
||||
case 'url':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.text,
|
||||
change: 'create',
|
||||
type: 'varchar',
|
||||
},
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.link,
|
||||
change: 'create',
|
||||
type: 'varchar',
|
||||
},
|
||||
];
|
||||
case 'money':
|
||||
return [
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.amount,
|
||||
change: 'create',
|
||||
type: 'integer',
|
||||
},
|
||||
{
|
||||
name: fieldMetadata.targetColumnMap.currency,
|
||||
change: 'create',
|
||||
type: 'varchar',
|
||||
},
|
||||
];
|
||||
default:
|
||||
throw new Error(`Unknown type ${fieldMetadata.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated with target_column_name deprecation
|
||||
export function convertMetadataTypeToColumnType(type: string) {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'url':
|
||||
case 'phone':
|
||||
case 'email':
|
||||
return 'text';
|
||||
case 'number':
|
||||
return 'int';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'date':
|
||||
return 'timestamp';
|
||||
case 'money':
|
||||
return 'integer';
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user