3c4168759a
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
619 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|