Files
twenty/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts
Thomas Trompette 756de8a31b Add connection failed status (#4939)
1/ When the user inputs wrong connection informations, we do not inform
him. He will only see that no tables are available.
We will display a connection failed status if an error is raised testing
the connection

2/ If the connection fails, it should still be possible to delete the
server. Today, since we try first to delete the tables, the connection
failure throws an error that will prevent server deletion. Using the
foreign tables instead of calling the distant DB.

3/ Redirect to connection show page instead of connection list after
creation

4/ Today, foreign tables are fetched without the server name. This is a
mistake because we need to know which foreign table is linked with which
server. Updating the associated query.

<img width="632" alt="Capture d’écran 2024-04-12 à 10 52 49"
src="https://github.com/twentyhq/twenty/assets/22936103/9e8406b8-75d0-494c-ac1f-5e9fa7100f5c">

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
2024-04-15 14:09:01 +02:00

358 lines
12 KiB
TypeScript

import { NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
RemoteServerType,
RemoteServerEntity,
} from 'src/engine/metadata-modules/remote-server/remote-server.entity';
import {
RemoteTableDTO,
RemoteTableStatus,
} from 'src/engine/metadata-modules/remote-server/remote-table/dtos/remote-table.dto';
import {
isPostgreSQLIntegrationEnabled,
mapUdtNameToFieldType,
} 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';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { RemotePostgresTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-postgres-table/remote-postgres-table.service';
import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service';
import { camelCase } from 'src/utils/camel-case';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
import { getRemoteTableLocalName } from 'src/engine/metadata-modules/remote-server/remote-table/utils/get-remote-table-local-name.util';
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 { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnDefinition,
WorkspaceMigrationForeignTable,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { RemoteTableColumn } from 'src/engine/metadata-modules/remote-server/remote-table/types/remote-table-column';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { RemoteTable } from 'src/engine/metadata-modules/remote-server/remote-table/types/remote-table';
export class RemoteTableService {
constructor(
@InjectRepository(RemoteServerEntity, 'metadata')
private readonly remoteServerRepository: Repository<
RemoteServerEntity<RemoteServerType>
>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
private readonly workspaceCacheVersionService: WorkspaceCacheVersionService,
private readonly dataSourceService: DataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly remotePostgresTableService: RemotePostgresTableService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
public async findAvailableRemoteTablesByServerId(
id: string,
workspaceId: string,
) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id,
workspaceId,
},
});
if (!remoteServer) {
throw new NotFoundException('Remote server does not exist');
}
const currentForeignTableNames =
await this.fetchForeignTableNamesWithinWorkspace(
workspaceId,
remoteServer.foreignDataWrapperId,
);
const tableInRemoteSchema =
await this.fetchTablesFromRemoteSchema(remoteServer);
return tableInRemoteSchema.map((remoteTable) => ({
name: remoteTable.tableName,
schema: remoteTable.tableSchema,
status: currentForeignTableNames.includes(
getRemoteTableLocalName(remoteTable.tableName),
)
? RemoteTableStatus.SYNCED
: RemoteTableStatus.NOT_SYNCED,
}));
}
public async syncRemoteTable(input: RemoteTableInput, workspaceId: string) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id: input.remoteServerId,
workspaceId,
},
});
if (!remoteServer) {
throw new NotFoundException('Remote server does not exist');
}
const remoteTable = await this.createForeignTableAndMetadata(
input,
remoteServer,
workspaceId,
);
return remoteTable;
}
public async unsyncRemoteTable(input: RemoteTableInput, workspaceId: string) {
const remoteServer = await this.remoteServerRepository.findOne({
where: {
id: input.remoteServerId,
workspaceId,
},
});
if (!remoteServer) {
throw new NotFoundException('Remote server does not exist');
}
const remoteTableLocalName = getRemoteTableLocalName(input.name);
await this.removeForeignTableAndMetadata(
remoteTableLocalName,
workspaceId,
remoteServer,
);
return {
name: input.name,
schema: input.schema,
status: RemoteTableStatus.NOT_SYNCED,
};
}
public async fetchForeignTableNamesWithinWorkspace(
workspaceId: string,
foreignDataWrapperId: string,
): Promise<string[]> {
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
workspaceId,
);
return (
await workspaceDataSource.query(
`SELECT foreign_table_name, foreign_server_name FROM information_schema.foreign_tables WHERE foreign_server_name = '${foreignDataWrapperId}'`,
)
).map((foreignTable) => foreignTable.foreign_table_name);
}
public async removeForeignTableAndMetadata(
remoteTableLocalName: string,
workspaceId: string,
remoteServer: RemoteServerEntity<RemoteServerType>,
) {
const currentForeignTableNames =
await this.fetchForeignTableNamesWithinWorkspace(
workspaceId,
remoteServer.foreignDataWrapperId,
);
if (!currentForeignTableNames.includes(remoteTableLocalName)) {
throw new Error('Remote table does not exist');
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: remoteTableLocalName },
});
if (objectMetadata) {
await this.objectMetadataService.deleteOneObject(
{ id: objectMetadata.id },
workspaceId,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`drop-foreign-table-${remoteTableLocalName}`),
workspaceId,
[
{
name: remoteTableLocalName,
action: WorkspaceMigrationTableActionType.DROP_FOREIGN_TABLE,
},
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceCacheVersionService.incrementVersion(workspaceId);
}
private async createForeignTableAndMetadata(
input: RemoteTableInput,
remoteServer: RemoteServerEntity<RemoteServerType>,
workspaceId: string,
): Promise<RemoteTableDTO> {
if (!input.schema) {
throw new Error('Schema is required for syncing remote table');
}
const currentForeignTableNames =
await this.fetchForeignTableNamesWithinWorkspace(
workspaceId,
remoteServer.foreignDataWrapperId,
);
if (
currentForeignTableNames.includes(getRemoteTableLocalName(input.name))
) {
throw new Error('Remote table already exists');
}
const remoteTableColumns = await this.fetchTableColumnsSchema(
remoteServer,
input.name,
input.schema,
);
const remoteTableLocalName = getRemoteTableLocalName(input.name);
const remoteTableLabel = camelToTitleCase(remoteTableLocalName);
// We only support remote tables with an id column for now.
const remoteTableIdColumn = remoteTableColumns.filter(
(column) => column.columnName === 'id',
)?.[0];
if (!remoteTableIdColumn) {
throw new Error('Remote table must have an id column');
}
const dataSourceMetatada =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-foreign-table-${remoteTableLocalName}`),
workspaceId,
[
{
name: remoteTableLocalName,
action: WorkspaceMigrationTableActionType.CREATE_FOREIGN_TABLE,
foreignTable: {
columns: remoteTableColumns.map(
(column) =>
({
columnName: column.columnName,
columnType: column.dataType,
}) satisfies WorkspaceMigrationColumnDefinition,
),
referencedTableName: input.name,
referencedTableSchema: input.schema,
foreignDataWrapperId: remoteServer.foreignDataWrapperId,
} satisfies WorkspaceMigrationForeignTable,
},
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
const objectMetadata = await this.objectMetadataService.createOne({
nameSingular: remoteTableLocalName,
namePlural: `${remoteTableLocalName}s`,
labelSingular: remoteTableLabel,
labelPlural: `${remoteTableLabel}s`,
description: 'Remote table',
dataSourceId: dataSourceMetatada.id,
workspaceId: workspaceId,
icon: 'IconPlug',
isRemote: true,
remoteTablePrimaryKeyColumnType: remoteTableIdColumn.udtName,
} satisfies CreateObjectInput);
for (const column of remoteTableColumns) {
const field = await this.fieldMetadataService.createOne({
name: column.columnName,
label: camelToTitleCase(camelCase(column.columnName)),
description: 'Field of remote',
// TODO: function should work for other types than Postgres
type: mapUdtNameToFieldType(column.udtName),
workspaceId: workspaceId,
objectMetadataId: objectMetadata.id,
isRemoteCreation: true,
isNullable: true,
icon: 'IconPlug',
} satisfies CreateFieldInput);
if (column.columnName === 'id') {
await this.objectMetadataService.updateOne(objectMetadata.id, {
labelIdentifierFieldMetadataId: field.id,
});
}
}
await this.workspaceCacheVersionService.incrementVersion(workspaceId);
return {
name: input.name,
schema: input.schema,
status: RemoteTableStatus.SYNCED,
};
}
private async fetchTableColumnsSchema(
remoteServer: RemoteServerEntity<RemoteServerType>,
tableName: string,
tableSchema: string,
): Promise<RemoteTableColumn[]> {
switch (remoteServer.foreignDataWrapperType) {
case RemoteServerType.POSTGRES_FDW:
await isPostgreSQLIntegrationEnabled(
this.featureFlagRepository,
remoteServer.workspaceId,
);
return this.remotePostgresTableService.fetchPostgresTableColumnsSchema(
remoteServer,
tableName,
tableSchema,
);
default:
throw new Error('Unsupported foreign data wrapper type');
}
}
private async fetchTablesFromRemoteSchema(
remoteServer: RemoteServerEntity<RemoteServerType>,
): Promise<RemoteTable[]> {
switch (remoteServer.foreignDataWrapperType) {
case RemoteServerType.POSTGRES_FDW:
await isPostgreSQLIntegrationEnabled(
this.featureFlagRepository,
remoteServer.workspaceId,
);
return this.remotePostgresTableService.fetchTablesFromRemotePostgresSchema(
remoteServer,
);
default:
throw new Error('Unsupported foreign data wrapper type');
}
}
}