feat: add object/field create/update resolvers (#1963)
* feat: add object/field create/update resolvers * fix tests
This commit is contained in:
@ -1,21 +0,0 @@
|
|||||||
import { ArgsType, Field } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class CreateCustomFieldInput {
|
|
||||||
@Field(() => String)
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
displayName: string;
|
|
||||||
|
|
||||||
@Field(() => String)
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
type: string;
|
|
||||||
|
|
||||||
@Field(() => String)
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
objectId: string;
|
|
||||||
}
|
|
||||||
@ -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';
|
} from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Authorize,
|
Authorize,
|
||||||
|
BeforeCreateOne,
|
||||||
IDField,
|
IDField,
|
||||||
QueryOptions,
|
QueryOptions,
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
} from '@ptc-org/nestjs-query-graphql';
|
||||||
|
|
||||||
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
|
import { BeforeCreateOneField } from './hooks/before-create-one-field.hook';
|
||||||
|
|
||||||
export type FieldMetadataTargetColumnMap = {
|
export type FieldMetadataTargetColumnMap = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
@Entity('field_metadata')
|
||||||
@ObjectType('field')
|
@ObjectType('field')
|
||||||
|
@BeforeCreateOne(BeforeCreateOneField)
|
||||||
|
@Authorize({
|
||||||
|
authorize: (context: any) => ({
|
||||||
|
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||||
|
}),
|
||||||
|
})
|
||||||
@QueryOptions({
|
@QueryOptions({
|
||||||
defaultResultSize: 10,
|
defaultResultSize: 10,
|
||||||
maxResultsSize: 100,
|
maxResultsSize: 100,
|
||||||
disableFilter: true,
|
disableFilter: true,
|
||||||
disableSort: true,
|
disableSort: true,
|
||||||
})
|
})
|
||||||
@Authorize({
|
|
||||||
authorize: (context: any) => ({
|
|
||||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@Entity('field_metadata')
|
|
||||||
export class FieldMetadata {
|
export class FieldMetadata {
|
||||||
@IDField(() => ID)
|
@IDField(() => ID)
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@ -90,9 +93,11 @@ export class FieldMetadata {
|
|||||||
@JoinColumn({ name: 'object_id' })
|
@JoinColumn({ name: 'object_id' })
|
||||||
object: ObjectMetadata;
|
object: ObjectMetadata;
|
||||||
|
|
||||||
|
@Field()
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Field()
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||||
NestjsQueryGraphQLModule,
|
|
||||||
PagingStrategies,
|
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
|
||||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
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 { FieldMetadata } from './field-metadata.entity';
|
||||||
|
import { fieldMetadataAutoResolverOpts } from './field-metadata.auto-resolver-opts';
|
||||||
|
|
||||||
|
import { FieldMetadataService } from './services/field-metadata.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryGraphQLModule.forFeature({
|
NestjsQueryGraphQLModule.forFeature({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryTypeOrmModule.forFeature([FieldMetadata], 'metadata'),
|
NestjsQueryTypeOrmModule.forFeature([FieldMetadata], 'metadata'),
|
||||||
|
TenantMigrationModule,
|
||||||
|
MigrationRunnerModule,
|
||||||
|
ObjectMetadataModule,
|
||||||
],
|
],
|
||||||
resolvers: [
|
services: [FieldMetadataService],
|
||||||
{
|
resolvers: fieldMetadataAutoResolverOpts,
|
||||||
EntityClass: FieldMetadata,
|
|
||||||
DTOClass: FieldMetadata,
|
|
||||||
enableTotalCount: true,
|
|
||||||
pagingStrategy: PagingStrategies.CURSOR,
|
|
||||||
create: { disabled: true },
|
|
||||||
update: { disabled: true },
|
|
||||||
delete: { disabled: true },
|
|
||||||
guards: [JwtAuthGuard],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [FieldMetadataService],
|
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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
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 { FieldMetadataService } from './field-metadata.service';
|
||||||
import { FieldMetadata } from './field-metadata.entity';
|
|
||||||
|
|
||||||
describe('FieldMetadataService', () => {
|
describe('FieldMetadataService', () => {
|
||||||
let service: FieldMetadataService;
|
let service: FieldMetadataService;
|
||||||
@ -15,6 +19,18 @@ describe('FieldMetadataService', () => {
|
|||||||
provide: getRepositoryToken(FieldMetadata, 'metadata'),
|
provide: getRepositoryToken(FieldMetadata, 'metadata'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ObjectMetadataService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TenantMigrationService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MigrationRunnerService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,56 @@
|
|||||||
|
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';
|
import { TenantMigrationColumnChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
|
||||||
|
|
||||||
import { FieldMetadata } from './field-metadata/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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function convertFieldMetadataToColumnChanges(
|
export function convertFieldMetadataToColumnChanges(
|
||||||
fieldMetadata: FieldMetadata,
|
fieldMetadata: FieldMetadata,
|
||||||
@ -77,3 +127,24 @@ export function convertFieldMetadataToColumnChanges(
|
|||||||
throw new Error(`Unknown type ${fieldMetadata.type}`);
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,12 +5,10 @@ import { GraphQLModule } from '@nestjs/graphql';
|
|||||||
import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs';
|
import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs';
|
||||||
import GraphQLJSON from 'graphql-type-json';
|
import GraphQLJSON from 'graphql-type-json';
|
||||||
|
|
||||||
import { MigrationGeneratorModule } from 'src/metadata/migration-generator/migration-generator.module';
|
import { MigrationRunnerModule } from 'src/metadata/migration-runner/migration-runner.module';
|
||||||
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
||||||
|
|
||||||
import { MetadataService } from './metadata.service';
|
|
||||||
import { typeORMMetadataModuleOptions } from './metadata.datasource';
|
import { typeORMMetadataModuleOptions } from './metadata.datasource';
|
||||||
import { MetadataResolver } from './metadata.resolver';
|
|
||||||
|
|
||||||
import { DataSourceModule } from './data-source/data-source.module';
|
import { DataSourceModule } from './data-source/data-source.module';
|
||||||
import { DataSourceMetadataModule } from './data-source-metadata/data-source-metadata.module';
|
import { DataSourceMetadataModule } from './data-source-metadata/data-source-metadata.module';
|
||||||
@ -41,10 +39,8 @@ const typeORMFactory = async (): Promise<TypeOrmModuleOptions> => ({
|
|||||||
DataSourceMetadataModule,
|
DataSourceMetadataModule,
|
||||||
FieldMetadataModule,
|
FieldMetadataModule,
|
||||||
ObjectMetadataModule,
|
ObjectMetadataModule,
|
||||||
MigrationGeneratorModule,
|
MigrationRunnerModule,
|
||||||
TenantMigrationModule,
|
TenantMigrationModule,
|
||||||
],
|
],
|
||||||
providers: [MetadataService, MetadataResolver],
|
|
||||||
exports: [MetadataService],
|
|
||||||
})
|
})
|
||||||
export class MetadataModule {}
|
export class MetadataModule {}
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { MetadataResolver } from './metadata.resolver';
|
|
||||||
import { MetadataService } from './metadata.service';
|
|
||||||
|
|
||||||
describe('MetadataResolver', () => {
|
|
||||||
let resolver: MetadataResolver;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
MetadataResolver,
|
|
||||||
{
|
|
||||||
provide: MetadataService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
resolver = module.get<MetadataResolver>(MetadataResolver);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(resolver).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { UseGuards } from '@nestjs/common';
|
|
||||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
import { Workspace } from '@prisma/client';
|
|
||||||
|
|
||||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
|
||||||
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
|
|
||||||
|
|
||||||
import { MetadataService } from './metadata.service';
|
|
||||||
|
|
||||||
import { CreateCustomFieldInput } from './args/create-custom-field.input';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Resolver()
|
|
||||||
export class MetadataResolver {
|
|
||||||
constructor(private readonly metadataService: MetadataService) {}
|
|
||||||
|
|
||||||
@Mutation(() => String)
|
|
||||||
async createCustomField(
|
|
||||||
@Args() createCustomFieldInput: CreateCustomFieldInput,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
): Promise<string> {
|
|
||||||
return this.metadataService.createCustomField(
|
|
||||||
createCustomFieldInput.displayName,
|
|
||||||
createCustomFieldInput.objectId,
|
|
||||||
createCustomFieldInput.type,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { MigrationGeneratorService } from 'src/metadata/migration-generator/migration-generator.service';
|
|
||||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
|
||||||
|
|
||||||
import { MetadataService } from './metadata.service';
|
|
||||||
|
|
||||||
import { DataSourceService } from './data-source/data-source.service';
|
|
||||||
import { ObjectMetadataService } from './object-metadata/object-metadata.service';
|
|
||||||
import { FieldMetadataService } from './field-metadata/field-metadata.service';
|
|
||||||
|
|
||||||
describe('MetadataService', () => {
|
|
||||||
let service: MetadataService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
MetadataService,
|
|
||||||
{
|
|
||||||
provide: DataSourceService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: MigrationGeneratorService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ObjectMetadataService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: TenantMigrationService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: FieldMetadataService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<MetadataService>(MetadataService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { MigrationGeneratorService } from 'src/metadata/migration-generator/migration-generator.service';
|
|
||||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
|
||||||
import {
|
|
||||||
TenantMigrationColumnChange,
|
|
||||||
TenantMigrationTableChange,
|
|
||||||
} from 'src/metadata/tenant-migration/tenant-migration.entity';
|
|
||||||
|
|
||||||
import { convertFieldMetadataToColumnChanges } from './metadata.util';
|
|
||||||
|
|
||||||
import { DataSourceService } from './data-source/data-source.service';
|
|
||||||
import { FieldMetadataService } from './field-metadata/field-metadata.service';
|
|
||||||
import { ObjectMetadataService } from './object-metadata/object-metadata.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MetadataService {
|
|
||||||
constructor(
|
|
||||||
private readonly dataSourceService: DataSourceService,
|
|
||||||
private readonly migrationGenerator: MigrationGeneratorService,
|
|
||||||
private readonly objectMetadataService: ObjectMetadataService,
|
|
||||||
private readonly tenantMigrationService: TenantMigrationService,
|
|
||||||
private readonly fieldMetadataService: FieldMetadataService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async createCustomField(
|
|
||||||
displayName: string,
|
|
||||||
objectId: string,
|
|
||||||
type: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const workspaceDataSource =
|
|
||||||
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
|
|
||||||
|
|
||||||
if (!workspaceDataSource) {
|
|
||||||
throw new Error('Workspace data source not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectMetadata =
|
|
||||||
await this.objectMetadataService.getObjectMetadataFromId(objectId);
|
|
||||||
|
|
||||||
if (!objectMetadata) {
|
|
||||||
throw new Error('Object not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldMetadataAlreadyExists =
|
|
||||||
await this.fieldMetadataService.getFieldMetadataByDisplayNameAndObjectId(
|
|
||||||
displayName,
|
|
||||||
objectId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fieldMetadataAlreadyExists) {
|
|
||||||
throw new Error('Field already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdFieldMetadata =
|
|
||||||
await this.fieldMetadataService.createFieldMetadata(
|
|
||||||
displayName,
|
|
||||||
type,
|
|
||||||
objectMetadata.id,
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.tenantMigrationService.createMigration(workspaceId, [
|
|
||||||
{
|
|
||||||
name: objectMetadata.targetTableName,
|
|
||||||
change: 'alter',
|
|
||||||
columns: [
|
|
||||||
...convertFieldMetadataToColumnChanges(createdFieldMetadata),
|
|
||||||
// Deprecated
|
|
||||||
{
|
|
||||||
name: createdFieldMetadata.targetColumnName,
|
|
||||||
type: this.convertMetadataTypeToColumnType(type),
|
|
||||||
change: 'create',
|
|
||||||
} satisfies TenantMigrationColumnChange,
|
|
||||||
],
|
|
||||||
} satisfies TenantMigrationTableChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await this.migrationGenerator.executeMigrationFromPendingMigrations(
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return createdFieldMetadata.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated with target_column_name
|
|
||||||
private 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,11 +3,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
|
||||||
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
||||||
|
|
||||||
import { MigrationGeneratorService } from './migration-generator.service';
|
import { MigrationRunnerService } from './migration-runner.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DataSourceModule, TenantMigrationModule],
|
imports: [DataSourceModule, TenantMigrationModule],
|
||||||
exports: [MigrationGeneratorService],
|
exports: [MigrationRunnerService],
|
||||||
providers: [MigrationGeneratorService],
|
providers: [MigrationRunnerService],
|
||||||
})
|
})
|
||||||
export class MigrationGeneratorModule {}
|
export class MigrationRunnerModule {}
|
||||||
@ -3,15 +3,15 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
||||||
|
|
||||||
import { MigrationGeneratorService } from './migration-generator.service';
|
import { MigrationRunnerService } from './migration-runner.service';
|
||||||
|
|
||||||
describe('MigrationGeneratorService', () => {
|
describe('MigrationRunnerService', () => {
|
||||||
let service: MigrationGeneratorService;
|
let service: MigrationRunnerService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
MigrationGeneratorService,
|
MigrationRunnerService,
|
||||||
{
|
{
|
||||||
provide: DataSourceService,
|
provide: DataSourceService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
@ -23,7 +23,7 @@ describe('MigrationGeneratorService', () => {
|
|||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<MigrationGeneratorService>(MigrationGeneratorService);
|
service = module.get<MigrationRunnerService>(MigrationRunnerService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MigrationGeneratorService {
|
export class MigrationRunnerService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSourceService: DataSourceService,
|
private readonly dataSourceService: DataSourceService,
|
||||||
private readonly tenantMigrationService: TenantMigrationService,
|
private readonly tenantMigrationService: TenantMigrationService,
|
||||||
@ -113,13 +113,23 @@ export class MigrationGeneratorService {
|
|||||||
name: 'id',
|
name: 'id',
|
||||||
type: 'uuid',
|
type: 'uuid',
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
default: 'uuid_generate_v4()',
|
default: 'public.uuid_generate_v4()',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'created_at',
|
name: 'created_at',
|
||||||
type: 'timestamp',
|
type: 'timestamp',
|
||||||
default: 'now()',
|
default: 'now()',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
default: 'now()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Field, InputType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class CreateObjectInput {
|
||||||
|
// Deprecated
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Field()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
displayNameSingular?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
displayNamePlural?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Field, InputType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class UpdateObjectInput {
|
||||||
|
// Deprecated
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
displayNameSingular?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
displayNamePlural?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@Field({ nullable: true })
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BeforeCreateOneHook,
|
||||||
|
CreateOneInputType,
|
||||||
|
} from '@ptc-org/nestjs-query-graphql';
|
||||||
|
|
||||||
|
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
|
||||||
|
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BeforeCreateOneObject<T extends ObjectMetadata>
|
||||||
|
implements BeforeCreateOneHook<T, any>
|
||||||
|
{
|
||||||
|
constructor(readonly dataSourceMetadataService: DataSourceMetadataService) {}
|
||||||
|
|
||||||
|
async run(
|
||||||
|
instance: CreateOneInputType<T>,
|
||||||
|
context: any,
|
||||||
|
): Promise<CreateOneInputType<T>> {
|
||||||
|
const workspaceId = context?.req?.user?.workspace?.id;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDataSourceMetadata =
|
||||||
|
await this.dataSourceMetadataService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.input.dataSourceId = lastDataSourceMetadata.id;
|
||||||
|
instance.input.targetTableName = instance.input.displayName;
|
||||||
|
instance.input.workspaceId = workspaceId;
|
||||||
|
instance.input.isActive = false;
|
||||||
|
instance.input.isCustom = true;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { ObjectMetadata } from './object-metadata.entity';
|
||||||
|
|
||||||
|
import { ObjectMetadataService } from './services/object-metadata.service';
|
||||||
|
import { CreateObjectInput } from './dtos/create-object.input';
|
||||||
|
import { UpdateObjectInput } from './dtos/update-object.input';
|
||||||
|
|
||||||
|
export const objectMetadataAutoResolverOpts: AutoResolverOpts<
|
||||||
|
any,
|
||||||
|
any,
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
ReadResolverOpts<any>,
|
||||||
|
PagingStrategies
|
||||||
|
>[] = [
|
||||||
|
{
|
||||||
|
EntityClass: ObjectMetadata,
|
||||||
|
DTOClass: ObjectMetadata,
|
||||||
|
CreateDTOClass: CreateObjectInput,
|
||||||
|
UpdateDTOClass: UpdateObjectInput,
|
||||||
|
ServiceClass: ObjectMetadataService,
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Authorize,
|
Authorize,
|
||||||
|
BeforeCreateOne,
|
||||||
CursorConnection,
|
CursorConnection,
|
||||||
IDField,
|
IDField,
|
||||||
QueryOptions,
|
QueryOptions,
|
||||||
@ -17,20 +18,23 @@ import {
|
|||||||
|
|
||||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
|
||||||
|
import { BeforeCreateOneObject } from './hooks/before-create-one-object.hook';
|
||||||
|
|
||||||
|
@Entity('object_metadata')
|
||||||
@ObjectType('object')
|
@ObjectType('object')
|
||||||
|
@BeforeCreateOne(BeforeCreateOneObject)
|
||||||
|
@Authorize({
|
||||||
|
authorize: (context: any) => ({
|
||||||
|
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||||
|
}),
|
||||||
|
})
|
||||||
@QueryOptions({
|
@QueryOptions({
|
||||||
defaultResultSize: 10,
|
defaultResultSize: 10,
|
||||||
maxResultsSize: 100,
|
maxResultsSize: 100,
|
||||||
disableFilter: true,
|
disableFilter: true,
|
||||||
disableSort: true,
|
disableSort: true,
|
||||||
})
|
})
|
||||||
@Authorize({
|
|
||||||
authorize: (context: any) => ({
|
|
||||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@CursorConnection('fields', () => FieldMetadata)
|
@CursorConnection('fields', () => FieldMetadata)
|
||||||
@Entity('object_metadata')
|
|
||||||
export class ObjectMetadata {
|
export class ObjectMetadata {
|
||||||
@IDField(() => ID)
|
@IDField(() => ID)
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@ -44,19 +48,19 @@ export class ObjectMetadata {
|
|||||||
@Column({ nullable: false, name: 'display_name' })
|
@Column({ nullable: false, name: 'display_name' })
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
||||||
@Field()
|
@Field({ nullable: true })
|
||||||
@Column({ nullable: true, name: 'display_name_singular' })
|
@Column({ nullable: true, name: 'display_name_singular' })
|
||||||
displayNameSingular: string;
|
displayNameSingular: string;
|
||||||
|
|
||||||
@Field()
|
@Field({ nullable: true })
|
||||||
@Column({ nullable: true, name: 'display_name_plural' })
|
@Column({ nullable: true, name: 'display_name_plural' })
|
||||||
displayNamePlural: string;
|
displayNamePlural: string;
|
||||||
|
|
||||||
@Field()
|
@Field({ nullable: true })
|
||||||
@Column({ nullable: true, name: 'description', type: 'text' })
|
@Column({ nullable: true, name: 'description', type: 'text' })
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
@Field()
|
@Field({ nullable: true })
|
||||||
@Column({ nullable: true, name: 'icon' })
|
@Column({ nullable: true, name: 'icon' })
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||||
NestjsQueryGraphQLModule,
|
|
||||||
PagingStrategies,
|
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
|
||||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
|
||||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
import { DataSourceMetadataModule } from 'src/metadata/data-source-metadata/data-source-metadata.module';
|
||||||
|
import { MigrationRunnerModule } from 'src/metadata/migration-runner/migration-runner.module';
|
||||||
|
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
|
||||||
|
|
||||||
import { ObjectMetadataService } from './object-metadata.service';
|
|
||||||
import { ObjectMetadata } from './object-metadata.entity';
|
import { ObjectMetadata } from './object-metadata.entity';
|
||||||
|
import { objectMetadataAutoResolverOpts } from './object-metadata.auto-resolver-opts';
|
||||||
|
|
||||||
|
import { ObjectMetadataService } from './services/object-metadata.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryGraphQLModule.forFeature({
|
NestjsQueryGraphQLModule.forFeature({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadata], 'metadata'),
|
NestjsQueryTypeOrmModule.forFeature([ObjectMetadata], 'metadata'),
|
||||||
|
DataSourceMetadataModule,
|
||||||
|
TenantMigrationModule,
|
||||||
|
MigrationRunnerModule,
|
||||||
],
|
],
|
||||||
resolvers: [
|
services: [ObjectMetadataService],
|
||||||
{
|
resolvers: objectMetadataAutoResolverOpts,
|
||||||
EntityClass: ObjectMetadata,
|
|
||||||
DTOClass: ObjectMetadata,
|
|
||||||
enableTotalCount: true,
|
|
||||||
pagingStrategy: PagingStrategies.CURSOR,
|
|
||||||
create: { disabled: true },
|
|
||||||
update: { disabled: true },
|
|
||||||
delete: { disabled: true },
|
|
||||||
guards: [JwtAuthGuard],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [ObjectMetadataService],
|
providers: [ObjectMetadataService],
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
|
|
||||||
import { ObjectMetadata } from './object-metadata.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ObjectMetadataService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(ObjectMetadata, 'metadata')
|
|
||||||
private readonly fieldMetadataRepository: Repository<ObjectMetadata>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
|
|
||||||
return this.fieldMetadataRepository.find({
|
|
||||||
where: { dataSourceId },
|
|
||||||
relations: ['fields'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getObjectMetadataFromId(objectMetadataId: string) {
|
|
||||||
return this.fieldMetadataRepository.findOne({
|
|
||||||
where: { id: objectMetadataId },
|
|
||||||
relations: ['fields'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,11 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ObjectMetadata } from 'src/metadata/object-metadata/object-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 './object-metadata.service';
|
import { ObjectMetadataService } from './object-metadata.service';
|
||||||
import { ObjectMetadata } from './object-metadata.entity';
|
|
||||||
|
|
||||||
describe('ObjectMetadataService', () => {
|
describe('ObjectMetadataService', () => {
|
||||||
let service: ObjectMetadataService;
|
let service: ObjectMetadataService;
|
||||||
@ -15,6 +18,14 @@ describe('ObjectMetadataService', () => {
|
|||||||
provide: getRepositoryToken(ObjectMetadata, 'metadata'),
|
provide: getRepositoryToken(ObjectMetadata, 'metadata'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: TenantMigrationService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MigrationRunnerService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
|
||||||
|
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
|
||||||
|
import { TenantMigrationTableChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
|
||||||
|
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
|
||||||
|
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadata> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ObjectMetadata, 'metadata')
|
||||||
|
private readonly objectMetadataRepository: Repository<ObjectMetadata>,
|
||||||
|
|
||||||
|
private readonly tenantMigrationService: TenantMigrationService,
|
||||||
|
private readonly migrationRunnerService: MigrationRunnerService,
|
||||||
|
) {
|
||||||
|
super(objectMetadataRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async createOne(record: ObjectMetadata): Promise<ObjectMetadata> {
|
||||||
|
const objectAlreadyExists = await this.objectMetadataRepository.findOne({
|
||||||
|
where: {
|
||||||
|
displayName: record.displayName, // deprecated, use singular and plural
|
||||||
|
workspaceId: record.workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (objectAlreadyExists) {
|
||||||
|
throw new ConflictException('Object already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdObjectMetadata = await super.createOne(record);
|
||||||
|
|
||||||
|
await this.tenantMigrationService.createMigration(
|
||||||
|
createdObjectMetadata.workspaceId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: createdObjectMetadata.targetTableName,
|
||||||
|
change: 'create',
|
||||||
|
} satisfies TenantMigrationTableChange,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.migrationRunnerService.executeMigrationFromPendingMigrations(
|
||||||
|
createdObjectMetadata.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return createdObjectMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
|
||||||
|
return this.objectMetadataRepository.find({
|
||||||
|
where: { dataSourceId },
|
||||||
|
relations: ['fields'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOneWithinWorkspace(
|
||||||
|
objectMetadataId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
|
return this.objectMetadataRepository.findOne({
|
||||||
|
where: { id: objectMetadataId, workspaceId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -156,6 +156,11 @@ export class SchemaBuilderService {
|
|||||||
const mutationFields: any = {};
|
const mutationFields: any = {};
|
||||||
|
|
||||||
for (const objectDefinition of objectMetadata) {
|
for (const objectDefinition of objectMetadata) {
|
||||||
|
if (objectDefinition.fields.length === 0) {
|
||||||
|
// A graphql type must define one or more fields
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const tableName = objectDefinition?.targetTableName ?? '';
|
const tableName = objectDefinition?.targetTableName ?? '';
|
||||||
const ObjectType = generateObjectType(
|
const ObjectType = generateObjectType(
|
||||||
objectDefinition.displayName,
|
objectDefinition.displayName,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
|
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
|
||||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
||||||
|
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { GraphQLSchema } from 'graphql';
|
import { GraphQLSchema } from 'graphql';
|
||||||
|
|
||||||
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
|
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
|
||||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
|
||||||
|
|
||||||
import { SchemaBuilderService } from './schema-builder/schema-builder.service';
|
import { SchemaBuilderService } from './schema-builder/schema-builder.service';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user