Files
twenty/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts
T
Charles Bochet 3c4168759a Refactor metadata caching (#7011)
This PR introduces the following changes:
- add the metadataVersion to all our metadata cache keys to ease
troubleshooting:
<img width="1146" alt="image"
src="https://github.com/user-attachments/assets/8427805b-e07f-465e-9e69-1403652c8b12">
- introduce a cache recompute lock to avoid overloading the database to
recompute the cache many time
2024-09-12 16:06:19 +02:00

619 lines
19 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { plural } from 'pluralize';
import { Repository } from 'typeorm';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import {
RemoteServerEntity,
RemoteServerType,
} from 'src/engine/metadata-modules/remote-server/remote-server.entity';
import { DistantTableService } from 'src/engine/metadata-modules/remote-server/remote-table/distant-table/distant-table.service';
import { sortDistantTables } from 'src/engine/metadata-modules/remote-server/remote-table/distant-table/utils/sort-distant-tables.util';
import { RemoteTableInput } from 'src/engine/metadata-modules/remote-server/remote-table/dtos/remote-table-input';
import {
DistantTableUpdate,
RemoteTableStatus,
} from 'src/engine/metadata-modules/remote-server/remote-table/dtos/remote-table.dto';
import { ForeignTableService } from 'src/engine/metadata-modules/remote-server/remote-table/foreign-table/foreign-table.service';
import { RemoteTableSchemaUpdateService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-schema-update/remote-table-schema-update.service';
import { RemoteTableEntity } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.entity';
import {
RemoteTableException,
RemoteTableExceptionCode,
} from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.exception';
import { fetchTableColumns } from 'src/engine/metadata-modules/remote-server/remote-table/utils/fetch-table-columns.util';
import { getRemoteTableLocalName } from 'src/engine/metadata-modules/remote-server/remote-table/utils/get-remote-table-local-name.util';
import {
mapUdtNameToFieldSettings,
mapUdtNameToFieldType,
} from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { PostgresTableSchemaColumn } from 'src/engine/metadata-modules/remote-server/types/postgres-table-schema-column';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { camelCase } from 'src/utils/camel-case';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
export class RemoteTableService {
private readonly logger = new Logger(RemoteTableService.name);
constructor(
@InjectRepository(RemoteTableEntity, 'metadata')
private readonly remoteTableRepository: Repository<RemoteTableEntity>,
@InjectRepository(RemoteServerEntity, 'metadata')
private readonly remoteServerRepository: Repository<
RemoteServerEntity<RemoteServerType>
>,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly dataSourceService: DataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly distantTableService: DistantTableService,
private readonly foreignTableService: ForeignTableService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly remoteTableSchemaUpdateService: RemoteTableSchemaUpdateService,
) {}
public async findDistantTablesWithStatus(
id: string,
workspaceId: string,
shouldFetchPendingSchemaUpdates?: boolean,
) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id,
workspaceId,
},
});
if (!remoteServer) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.INVALID_REMOTE_TABLE_INPUT,
);
}
const currentRemoteTables = await this.findRemoteTablesByServerId({
remoteServerId: id,
workspaceId,
});
const currentRemoteTableDistantNames = currentRemoteTables.map(
(remoteTable) => remoteTable.distantTableName,
);
const distantTables = await this.distantTableService.fetchDistantTables(
remoteServer,
workspaceId,
);
const distantTablesWithStatus = Object.keys(distantTables).map(
(tableName) => ({
name: tableName,
schema: remoteServer.schema,
status: currentRemoteTableDistantNames.includes(tableName)
? RemoteTableStatus.SYNCED
: RemoteTableStatus.NOT_SYNCED,
}),
);
if (!shouldFetchPendingSchemaUpdates) {
return distantTablesWithStatus.sort(sortDistantTables);
}
const schemaPendingUpdates =
await this.remoteTableSchemaUpdateService.getSchemaUpdatesBetweenForeignAndDistantTables(
{
workspaceId,
remoteTables: currentRemoteTables,
distantTables,
},
);
const distantTablesWithPendingUpdates =
this.getDistantTablesWithPendingUpdates(
schemaPendingUpdates,
distantTablesWithStatus,
remoteServer.schema,
);
return distantTablesWithPendingUpdates.sort(sortDistantTables);
}
public async findRemoteTablesByServerId({
remoteServerId,
workspaceId,
}: {
remoteServerId: string;
workspaceId: string;
}) {
return this.remoteTableRepository.find({
where: {
remoteServerId,
workspaceId,
},
});
}
public async syncRemoteTable(input: RemoteTableInput, workspaceId: string) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id: input.remoteServerId,
workspaceId,
},
});
if (!remoteServer) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.INVALID_REMOTE_TABLE_INPUT,
);
}
const currentRemoteTableWithSameDistantName =
await this.remoteTableRepository.findOne({
where: {
distantTableName: input.name,
remoteServerId: remoteServer.id,
workspaceId,
},
});
if (currentRemoteTableWithSameDistantName) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.REMOTE_TABLE_ALREADY_EXISTS,
);
}
const dataSourceMetatada =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
workspaceId,
);
const { baseName: localTableBaseName, suffix: localTableSuffix } =
await getRemoteTableLocalName(
input.name,
dataSourceMetatada.schema,
workspaceDataSource,
);
const localTableName = localTableSuffix
? `${localTableBaseName}${localTableSuffix}`
: localTableBaseName;
const remoteTableEntity = this.remoteTableRepository.create({
distantTableName: input.name,
localTableName,
workspaceId,
remoteServerId: remoteServer.id,
});
const distantTableColumns =
await this.distantTableService.getDistantTableColumns(
remoteServer,
workspaceId,
input.name,
);
if (!distantTableColumns) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.REMOTE_TABLE_NOT_FOUND,
);
}
// We only support remote tables with an id column for now.
const distantTableIdColumn = distantTableColumns.find(
(column) => column.columnName === 'id',
);
if (!distantTableIdColumn) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.INVALID_REMOTE_TABLE_INPUT,
);
}
await this.foreignTableService.createForeignTable(
workspaceId,
localTableName,
remoteServer,
input.name,
distantTableColumns,
);
await this.createRemoteTableMetadata(
workspaceId,
localTableBaseName,
localTableSuffix,
distantTableColumns,
distantTableIdColumn,
dataSourceMetatada.id,
);
await this.remoteTableRepository.save(remoteTableEntity);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
return {
id: remoteTableEntity.id,
name: input.name,
schema: remoteServer.schema,
status: RemoteTableStatus.SYNCED,
};
}
public async unsyncRemoteTable(input: RemoteTableInput, workspaceId: string) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id: input.remoteServerId,
workspaceId,
},
});
if (!remoteServer) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.INVALID_REMOTE_TABLE_INPUT,
);
}
const remoteTable = await this.remoteTableRepository.findOne({
where: {
distantTableName: input.name,
remoteServerId: remoteServer.id,
workspaceId,
},
});
if (!remoteTable) {
throw new RemoteTableException(
'Remote table does not exist',
RemoteTableExceptionCode.REMOTE_TABLE_NOT_FOUND,
);
}
await this.unsyncOne(workspaceId, remoteTable, remoteServer);
return {
name: input.name,
schema: remoteServer.schema,
status: RemoteTableStatus.NOT_SYNCED,
};
}
public async unsyncAll(
workspaceId: string,
remoteServer: RemoteServerEntity<RemoteServerType>,
) {
const remoteTables = await this.remoteTableRepository.find({
where: {
remoteServerId: remoteServer.id,
workspaceId,
},
});
for (const remoteTable of remoteTables) {
await this.unsyncOne(workspaceId, remoteTable, remoteServer);
}
}
public async syncRemoteTableSchemaChanges(
input: RemoteTableInput,
workspaceId: string,
) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id: input.remoteServerId,
workspaceId,
},
});
if (!remoteServer) {
throw new RemoteTableException(
'Remote server does not exist',
RemoteTableExceptionCode.INVALID_REMOTE_TABLE_INPUT,
);
}
const remoteTable = await this.remoteTableRepository.findOne({
where: {
distantTableName: input.name,
remoteServerId: remoteServer.id,
workspaceId,
},
});
if (!remoteTable) {
throw new RemoteTableException(
'Remote table does not exist',
RemoteTableExceptionCode.REMOTE_TABLE_NOT_FOUND,
);
}
const distantTableColumns =
await this.distantTableService.getDistantTableColumns(
remoteServer,
workspaceId,
remoteTable.distantTableName,
);
if (!distantTableColumns) {
await this.unsyncOne(workspaceId, remoteTable, remoteServer);
return {
name: remoteTable.localTableName,
status: RemoteTableStatus.NOT_SYNCED,
schemaPendingUpdates: [],
};
}
const foreignTableColumns = await fetchTableColumns(
this.workspaceDataSourceService,
workspaceId,
remoteTable.localTableName,
);
const columnsUpdates =
this.remoteTableSchemaUpdateService.computeForeignTableColumnsUpdates(
foreignTableColumns,
distantTableColumns,
);
if (isEmpty(columnsUpdates)) {
this.logger.log(
`No update to perform on table "${remoteTable.localTableName}" for workspace ${workspaceId}`,
);
return {
name: remoteTable.localTableName,
status: RemoteTableStatus.SYNCED,
schemaPendingUpdates: [],
};
}
const updatedTable = await this.updateForeignTableAndFieldsMetadata(
remoteTable.localTableName,
workspaceId,
columnsUpdates,
);
return updatedTable;
}
private async unsyncOne(
workspaceId: string,
remoteTable: RemoteTableEntity,
remoteServer: RemoteServerEntity<RemoteServerType>,
) {
const currentForeignTableNames =
await this.foreignTableService.fetchForeignTableNamesWithinWorkspace(
workspaceId,
remoteServer.foreignDataWrapperId,
);
if (!currentForeignTableNames.includes(remoteTable.localTableName)) {
throw new RemoteTableException(
'Foreign table does not exist',
RemoteTableExceptionCode.NO_FOREIGN_TABLES_FOUND,
);
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: remoteTable.localTableName },
});
if (objectMetadata) {
await this.objectMetadataService.deleteOneObject(
{ id: objectMetadata.id },
workspaceId,
);
}
await this.foreignTableService.deleteForeignTable(
remoteTable.localTableName,
workspaceId,
);
await this.remoteTableRepository.delete(remoteTable.id);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
}
private async createRemoteTableMetadata(
workspaceId: string,
localTableBaseName: string,
localTableSuffix: number | undefined,
distantTableColumns: PostgresTableSchemaColumn[],
distantTableIdColumn: PostgresTableSchemaColumn,
dataSourceMetadataId: string,
) {
const localTableNameSingular = localTableSuffix
? `${localTableBaseName}${localTableSuffix}`
: localTableBaseName;
const localTableNamePlural = localTableSuffix
? `${plural(localTableBaseName)}${localTableSuffix}`
: plural(localTableBaseName);
const objectMetadata = await this.objectMetadataService.createOne({
nameSingular: camelCase(localTableNameSingular),
namePlural: camelCase(localTableNamePlural),
labelSingular: camelToTitleCase(camelCase(localTableBaseName)),
labelPlural: camelToTitleCase(plural(camelCase(localTableBaseName))),
description: 'Remote table',
dataSourceId: dataSourceMetadataId,
workspaceId: workspaceId,
icon: 'IconPlug',
isRemote: true,
primaryKeyColumnType: distantTableIdColumn.udtName,
primaryKeyFieldMetadataSettings: mapUdtNameToFieldSettings(
distantTableIdColumn.udtName,
),
} satisfies CreateObjectInput);
for (const column of distantTableColumns) {
const columnName = camelCase(column.columnName);
// TODO: return error to the user when a column cannot be managed
try {
const field = await this.createFieldMetadataForForeignTableColumn(
workspaceId,
columnName,
column.udtName,
objectMetadata.id,
);
if (columnName === 'id') {
await this.objectMetadataService.updateOne(objectMetadata.id, {
labelIdentifierFieldMetadataId: field.id,
});
}
} catch (error) {
this.logger.error(
`Could not create field ${columnName} for remote table ${localTableNameSingular}: ${error}`,
);
}
}
}
private getDistantTablesWithPendingUpdates(
schemaPendingUpdates: { [tablename: string]: DistantTableUpdate[] },
distantTablesWithStatus: {
name: string;
schema: string;
status: RemoteTableStatus;
}[],
remoteServerSchema: string,
) {
const distantTablesWithUpdates = distantTablesWithStatus.map((table) => ({
...table,
schemaPendingUpdates: schemaPendingUpdates[table.name] || [],
}));
const deletedTables = Object.entries(schemaPendingUpdates)
.filter(([_tableName, updates]) =>
updates.includes(DistantTableUpdate.TABLE_DELETED),
)
.map(([tableName, updates]) => ({
name: tableName,
schema: remoteServerSchema,
status: RemoteTableStatus.SYNCED,
schemaPendingUpdates: updates,
}));
return [...distantTablesWithUpdates, ...deletedTables];
}
private async updateForeignTableAndFieldsMetadata(
foreignTableName: string,
workspaceId: string,
columnsUpdates: WorkspaceMigrationColumnAction[],
) {
const updatedForeignTable =
await this.foreignTableService.updateForeignTable(
foreignTableName,
workspaceId,
columnsUpdates,
);
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: foreignTableName },
});
if (!objectMetadata) {
throw new RemoteTableException(
`Cannot find associated object for table ${foreignTableName}`,
RemoteTableExceptionCode.NO_OBJECT_METADATA_FOUND,
);
}
for (const columnUpdate of columnsUpdates) {
this.updateFieldMetadataFromColumnUpdate(
columnUpdate,
workspaceId,
objectMetadata.id,
);
}
return updatedForeignTable;
}
private async updateFieldMetadataFromColumnUpdate(
columnUpdate: WorkspaceMigrationColumnAction,
workspaceId: string,
objectMetadataId: string,
) {
if (columnUpdate.action === WorkspaceMigrationColumnActionType.CREATE) {
await this.createFieldMetadataForForeignTableColumn(
workspaceId,
columnUpdate.columnName,
columnUpdate.columnType,
objectMetadataId,
);
}
if (columnUpdate.action === WorkspaceMigrationColumnActionType.DROP) {
const columnName = columnUpdate.columnName;
const fieldMetadataToDelete =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
objectMetadataId: objectMetadataId,
name: columnName,
},
});
if (!fieldMetadataToDelete) {
throw new RemoteTableException(
`Cannot find associated field metadata for column ${columnName}`,
RemoteTableExceptionCode.NO_FIELD_METADATA_FOUND,
);
}
await this.fieldMetadataService.deleteOne(fieldMetadataToDelete.id);
}
}
private async createFieldMetadataForForeignTableColumn(
workspaceId: string,
columnName: string,
columnType: string,
objectMetadataId: string,
): Promise<FieldMetadataEntity> {
return this.fieldMetadataService.createOne({
name: columnName,
label: camelToTitleCase(columnName),
description: 'Field of remote',
type: mapUdtNameToFieldType(columnType),
workspaceId: workspaceId,
objectMetadataId: objectMetadataId,
isRemoteCreation: true,
isNullable: true,
icon: 'IconPlug',
settings: mapUdtNameToFieldSettings(columnType),
} satisfies CreateFieldInput);
}
}