Setup relations for remote objects (#5149)

New strategy:
- add settings field on FieldMetadata. Contains a boolean isIdField and
for numbers, a precision
- if idField, the graphql scalar returned will be a GraphQL id. This
will allow the app to work even for ids that are not uuid
- remove globals dateScalar and numberScalar modes. These were not used
- set limit as Integer
- check manually in query runner mutations that we send a valid id

Todo left:
- remove WorkspaceBuildSchemaOptions since this is not used anymore.
Will do in another PR

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Thomas Trompette
2024-04-26 14:37:34 +02:00
committed by GitHub
parent dc576d0818
commit 224c8d361b
71 changed files with 616 additions and 223 deletions

View File

@ -26,6 +26,7 @@ import {
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationMetadataDTO } from 'src/engine/metadata-modules/relation-metadata/dtos/relation-metadata.dto';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -120,6 +121,10 @@ export class FieldMetadataDTO<
@Field(() => GraphQLJSON, { nullable: true })
options?: FieldMetadataOptions<T>;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
settings?: FieldMetadataSettings<T>;
@HideField()
workspaceId: string;

View File

@ -14,6 +14,7 @@ import {
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
@ -87,6 +88,9 @@ export class FieldMetadataEntity<
@Column('jsonb', { nullable: true })
options: FieldMetadataOptions<T>;
@Column('jsonb', { nullable: true })
settings?: FieldMetadataSettings<T>;
@Column({ default: false })
isCustom: boolean;

View File

@ -17,7 +17,6 @@ import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-m
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { FieldMetadataService } from './field-metadata.service';
import { FieldMetadataEntity } from './field-metadata.entity';
@ -29,10 +28,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[FieldMetadataEntity, RelationMetadataEntity],
'metadata',
),
NestjsQueryTypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'),
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
ObjectMetadataModule,

View File

@ -58,8 +58,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly metadataDataSource: DataSource,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
private readonly objectMetadataService: ObjectMetadataService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationService: WorkspaceMigrationService,

View File

@ -0,0 +1,24 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
type FieldMetadataDefaultSettings = {
isForeignKey?: boolean;
};
type FieldMetadataNumberSettings = {
precision: number;
};
type FieldMetadataSettingsMapping = {
[FieldMetadataType.NUMBER]: FieldMetadataNumberSettings;
};
type SettingsByFieldMetadata<T extends FieldMetadataType | 'default'> =
T extends keyof FieldMetadataSettingsMapping
? FieldMetadataSettingsMapping[T] & FieldMetadataDefaultSettings
: T extends 'default'
? FieldMetadataDefaultSettings
: never;
export type FieldMetadataSettings<
T extends FieldMetadataType | 'default' = 'default',
> = SettingsByFieldMetadata<T>;

View File

@ -1,5 +1,6 @@
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
@ -13,6 +14,7 @@ export interface FieldMetadataInterface<
label: string;
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
settings?: FieldMetadataSettings<T>;
objectMetadataId: string;
workspaceId?: string;
description?: string;

View File

@ -8,9 +8,13 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
@ -70,5 +74,11 @@ export class CreateObjectInput {
@IsOptional()
@Field({ nullable: true })
remoteTablePrimaryKeyColumnType?: string;
primaryKeyColumnType?: string;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
primaryKeyFieldMetadataSettings?: FieldMetadataSettings<
FieldMetadataType | 'default'
>;
}

View File

@ -16,6 +16,8 @@ import {
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import {
@ -54,12 +56,10 @@ import {
import { createWorkspaceMigrationsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/create-workspace-migrations-for-custom-object.util';
import { createWorkspaceMigrationsForRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/create-workspace-migrations-for-remote-object.util';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { validateObjectMetadataInput } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/remote-postgres-table/utils/remote-postgres-table.util';
import { ObjectMetadataEntity } from './object-metadata.entity';
@ -469,35 +469,38 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceDataSource: DataSource | undefined,
isRemoteObject = false,
) {
const isRelationEnabledForRemoteObjects =
await this.isRelationEnabledForRemoteObjects(
objectMetadataInput.workspaceId,
);
if (isRemoteObject && !isRelationEnabledForRemoteObjects) {
return;
}
const { timelineActivityObjectMetadata } =
await this.createTimelineActivityRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
mapUdtNameToFieldType(
objectMetadataInput.primaryKeyColumnType ?? 'uuid',
),
objectMetadataInput.primaryKeyFieldMetadataSettings,
);
const { activityTargetObjectMetadata } =
await this.createActivityTargetRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
mapUdtNameToFieldType(
objectMetadataInput.primaryKeyColumnType ?? 'uuid',
),
objectMetadataInput.primaryKeyFieldMetadataSettings,
);
const { favoriteObjectMetadata } = await this.createFavoriteRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
mapUdtNameToFieldType(objectMetadataInput.primaryKeyColumnType ?? 'uuid'),
objectMetadataInput.primaryKeyFieldMetadataSettings,
);
const { attachmentObjectMetadata } = await this.createAttachmentRelation(
objectMetadataInput.workspaceId,
createdObjectMetadata,
mapUdtNameToFieldType(objectMetadataInput.primaryKeyColumnType ?? 'uuid'),
objectMetadataInput.primaryKeyFieldMetadataSettings,
);
return this.workspaceMigrationService.createCustomMigration(
@ -511,7 +514,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
timelineActivityObjectMetadata,
favoriteObjectMetadata,
lastDataSourceMetadata.schema,
objectMetadataInput.remoteTablePrimaryKeyColumnType ?? 'uuid',
objectMetadataInput.primaryKeyColumnType ?? 'uuid',
workspaceDataSource,
)
: createWorkspaceMigrationsForCustomObject(
@ -527,6 +530,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private async createActivityTargetRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
objectPrimaryKeyType: FieldMetadataType,
objectPrimaryKeyFieldSettings:
| FieldMetadataSettings<FieldMetadataType | 'default'>
| undefined,
) {
const activityTargetObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
@ -577,7 +584,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
type: objectPrimaryKeyType,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
description: `ActivityTarget ${createdObjectMetadata.labelSingular} id foreign key`,
@ -585,6 +592,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isNullable: true,
isSystem: true,
defaultValue: undefined,
settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true },
},
]);
@ -622,6 +630,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private async createAttachmentRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
objectPrimaryKeyType: FieldMetadataType,
objectPrimaryKeyFieldSettings:
| FieldMetadataSettings<FieldMetadataType | 'default'>
| undefined,
) {
const attachmentObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
@ -672,7 +684,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
type: objectPrimaryKeyType,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
description: `Attachment ${createdObjectMetadata.labelSingular} id foreign key`,
@ -680,6 +692,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isNullable: true,
isSystem: true,
defaultValue: undefined,
settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true },
},
]);
@ -715,6 +728,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private async createTimelineActivityRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
objectPrimaryKeyType: FieldMetadataType,
objectPrimaryKeyFieldSettings:
| FieldMetadataSettings<FieldMetadataType | 'default'>
| undefined,
) {
const timelineActivityObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
@ -765,7 +782,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
type: objectPrimaryKeyType,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
description: `Timeline Activity ${createdObjectMetadata.labelSingular} id foreign key`,
@ -773,6 +790,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isNullable: true,
isSystem: true,
defaultValue: undefined,
settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true },
},
]);
@ -810,6 +828,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private async createFavoriteRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
objectPrimaryKeyType: FieldMetadataType,
objectPrimaryKeyFieldSettings:
| FieldMetadataSettings<FieldMetadataType | 'default'>
| undefined,
) {
const favoriteObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
@ -861,7 +883,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: FieldMetadataType.UUID,
type: objectPrimaryKeyType,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
description: `Favorite ${createdObjectMetadata.labelSingular} id foreign key`,
@ -869,6 +891,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isNullable: true,
isSystem: true,
defaultValue: undefined,
settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true },
},
]);
@ -900,14 +923,4 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
return { favoriteObjectMetadata };
}
private async isRelationEnabledForRemoteObjects(workspaceId: string) {
const featureFlag = await this.featureFlagRepository.findOneBy({
workspaceId,
key: FeatureFlagKeys.IsRelationForRemoteObjectsEnabled,
value: true,
});
return featureFlag && featureFlag.value;
}
}

View File

@ -1,9 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
export const assertMutationNotOnRemoteObject = (
objectMetadataItem: ObjectMetadataInterface,
) => {
if (objectMetadataItem.isRemote) {
throw new Error('Remote objects are read-only');
throw new BadRequestException('Remote objects are read-only');
}
};

View File

@ -54,7 +54,7 @@ export const createWorkspaceMigrationsForRemoteObject = async (
eventObjectMetadata: ObjectMetadataEntity,
favoriteObjectMetadata: ObjectMetadataEntity,
schema: string,
remoteTablePrimaryKeyColumnType: string,
primaryKeyColumnType: string,
workspaceDataSource: DataSource | undefined,
): Promise<WorkspaceMigrationTableAction[]> => {
const createdObjectName = createdObjectMetadata.nameSingular;
@ -69,7 +69,7 @@ export const createWorkspaceMigrationsForRemoteObject = async (
columnName: computeColumnName(createdObjectMetadata.nameSingular, {
isForeignKey: true,
}),
columnType: remoteTablePrimaryKeyColumnType,
columnType: primaryKeyColumnType,
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
@ -99,7 +99,7 @@ export const createWorkspaceMigrationsForRemoteObject = async (
columnName: computeColumnName(createdObjectMetadata.nameSingular, {
isForeignKey: true,
}),
columnType: remoteTablePrimaryKeyColumnType,
columnType: primaryKeyColumnType,
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
@ -129,7 +129,7 @@ export const createWorkspaceMigrationsForRemoteObject = async (
columnName: computeColumnName(createdObjectMetadata.nameSingular, {
isForeignKey: true,
}),
columnType: remoteTablePrimaryKeyColumnType,
columnType: primaryKeyColumnType,
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],
@ -159,7 +159,7 @@ export const createWorkspaceMigrationsForRemoteObject = async (
columnName: computeColumnName(createdObjectMetadata.nameSingular, {
isForeignKey: true,
}),
columnType: remoteTablePrimaryKeyColumnType,
columnType: primaryKeyColumnType,
isNullable: true,
} satisfies WorkspaceMigrationColumnCreate,
],

View File

@ -1,5 +1,7 @@
import { Repository } from 'typeorm/repository/Repository';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { decryptText } from 'src/engine/core-modules/auth/auth.util';
import {
FeatureFlagEntity,
@ -42,11 +44,32 @@ export const mapUdtNameToFieldType = (udtName: string): FieldMetadataType => {
case 'timestamp':
case 'timestamptz':
return FieldMetadataType.DATE_TIME;
case 'integer':
case 'int2':
case 'int4':
case 'int8':
return FieldMetadataType.NUMBER;
default:
return FieldMetadataType.TEXT;
}
};
export const mapUdtNameToSettings = (
udtName: string,
): FieldMetadataSettings<FieldMetadataType> | undefined => {
switch (udtName) {
case 'integer':
case 'int2':
case 'int4':
case 'int8':
return {
precision: 0,
} satisfies FieldMetadataSettings<FieldMetadataType.NUMBER>;
default:
return undefined;
}
};
export const isPostgreSQLIntegrationEnabled = async (
featureFlagRepository: Repository<FeatureFlagEntity>,
workspaceId: string,

View File

@ -12,6 +12,7 @@ import { RemoteTableStatus } from 'src/engine/metadata-modules/remote-server/rem
import {
isPostgreSQLIntegrationEnabled,
mapUdtNameToFieldType,
mapUdtNameToSettings,
} from 'src/engine/metadata-modules/remote-server/remote-table/remote-postgres-table/utils/remote-postgres-table.util';
import { RemoteTableInput } from 'src/engine/metadata-modules/remote-server/remote-table/dtos/remote-table-input';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@ -429,7 +430,11 @@ export class RemoteTableService {
workspaceId: workspaceId,
icon: 'IconPlug',
isRemote: true,
remoteTablePrimaryKeyColumnType: remoteTableIdColumn.udtName,
primaryKeyColumnType: remoteTableIdColumn.udtName,
// TODO: function should work for other types than Postgres
primaryKeyFieldMetadataSettings: mapUdtNameToSettings(
remoteTableIdColumn.udtName,
),
} satisfies CreateObjectInput);
for (const column of remoteTableColumns) {
@ -444,6 +449,8 @@ export class RemoteTableService {
isRemoteCreation: true,
isNullable: true,
icon: 'IconPlug',
// TODO: function should work for other types than Postgres
settings: mapUdtNameToSettings(column.udtName),
} satisfies CreateFieldInput);
if (column.columnName === 'id') {