Add unique indexes and indexes for composite types (#7162)

Add support for indexes on composite fields and unicity constraint on
indexes

This pull request includes several changes across multiple files to
improve error handling, enforce unique constraints, and update database
migrations. The most important changes include updating error messages
for snack bars, adding a new command to enforce unique constraints, and
updating database migrations to include new fields and constraints.

### Error Handling Improvements:
*
[`packages/twenty-front/src/modules/error-handler/components/PromiseRejectionEffect.tsx`](diffhunk://#diff-e7dc05ced8e4730430f5c7fcd0c75b3aa723da438c26e0bef8130b614427dd9aL23-R23):
Updated error messages in `enqueueSnackBar` to use `error.message`
directly.
*
[`packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts`](diffhunk://#diff-74c126d6bc7a5ed6b63be994d298df6669058034bfbc367b11045f9f31a3abe6L44-R46):
Simplified error messages in `enqueueSnackBar`.
*
[`packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts`](diffhunk://#diff-af23a1d99639a66c251f87473e63e2b7bceaa4ee4f70fedfa0fcffe5c7d79181L56-R58):
Simplified error messages in `enqueueSnackBar`.
*
[`packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts`](diffhunk://#diff-da04296cbe280202a1eaf6b1244a30490d4f400411bee139651172c59719088eL22-R24):
Simplified error messages in `enqueueSnackBar`.

### New Command for Unique Constraints:
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-enforce-unique-constraints.command.ts`](diffhunk://#diff-8337096c8c80dd2619a5ba691ae5145101f8ae0368a75192a050047e8c6ab7cbR1-R159):
Added a new command to enforce unique constraints on company domain
names and person emails.
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.command.ts`](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14):
Integrated the new `EnforceUniqueConstraintsCommand` into the upgrade
process.
[[1]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14)
[[2]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR31)
[[3]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR64-R68)
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.module.ts`](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7):
Registered the new `EnforceUniqueConstraintsCommand` in the module.
[[1]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7)
[[2]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R24)

### Database Migrations:
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368824-migrationDebt.ts`](diffhunk://#diff-c450aeae7bc0ef4416a0ade2dc613ca3f688629f35d2a32f90a09c3f494febdcR1-R53):
Added a migration to update the `relationMetadata_ondeleteaction_enum`
and set default values.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368825-addIsUniqueToIndexMetadata.ts`](diffhunk://#diff-8f1e14bd7f6835ec2c3bb39bcc51e3c318a3008d576a981e682f4c985e746fbfR1-R19):
Added a migration to include the `isUnique` field in `indexMetadata`.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726762935841-addCompostiveColumnToIndexFieldMetadata.ts`](diffhunk://#diff-7c96b7276c7722d41ff31de23b2de4d6e09adfdc74815356ba63bc96a2669440R1-R19):
Added a migration to include the `compositeColumn` field in
`indexFieldMetadata`.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726766871572-addWhereToIndexMetadata.ts`](diffhunk://#diff-26651295a975eb50e672dce0e4e274e861f66feb1b68105eee5a04df32796190R1-R14):
Added a migration to include the `indexWhereClause` field in
`indexMetadata`.

### GraphQL Exception Handling:
*
[`packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts`](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4):
Enhanced exception handling for `QueryFailedError` to provide more
specific error messages for unique constraint violations.
[[1]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4)
[[2]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R23-R59)
*
[`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts`](diffhunk://#diff-233d58ab2333586dd45e46e33d4f07e04a4b8adde4a11a48e25d86985e5a7943L58-R58):
Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to
include context.
*
[`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts`](diffhunk://#diff-68b803f0762c407f5d2d1f5f8d389655a60654a2dd2394a81318655dcd44dc43L58-R58):
Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to
include context.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2024-10-13 10:21:03 +02:00
committed by GitHub
parent d1d4af0c63
commit b792d2a4d3
137 changed files with 22351 additions and 17974 deletions

View File

@ -0,0 +1,303 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, Option } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
interface EnforceUniqueConstraintsCommandOptions
extends ActiveWorkspacesCommandOptions {
person?: boolean;
company?: boolean;
viewField?: boolean;
viewSort?: boolean;
}
@Command({
name: 'upgrade-0.32:enforce-unique-constraints',
description:
'Enforce unique constraints on company domainName, person emailsPrimaryEmail, ViewField, and ViewSort',
})
export class EnforceUniqueConstraintsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
@Option({
flags: '--person',
description: 'Enforce unique constraints on person emailsPrimaryEmail',
})
parsePerson() {
return true;
}
@Option({
flags: '--company',
description: 'Enforce unique constraints on company domainName',
})
parseCompany() {
return true;
}
@Option({
flags: '--view-field',
description: 'Enforce unique constraints on ViewField',
})
parseViewField() {
return true;
}
@Option({
flags: '--view-sort',
description: 'Enforce unique constraints on ViewSort',
})
parseViewSort() {
return true;
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: EnforceUniqueConstraintsCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to enforce unique constraints');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.enforceUniqueConstraintsForWorkspace(
workspaceId,
options.dryRun ?? false,
options,
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async enforceUniqueConstraintsForWorkspace(
workspaceId: string,
dryRun: boolean,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
if (options.company) {
await this.enforceUniqueCompanyDomainName(workspaceId, dryRun);
}
if (options.person) {
await this.enforceUniquePersonEmail(workspaceId, dryRun);
}
if (options.viewField) {
await this.enforceUniqueViewField(workspaceId, dryRun);
}
if (options.viewSort) {
await this.enforceUniqueViewSort(workspaceId, dryRun);
}
}
private async enforceUniqueCompanyDomainName(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const companyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'company',
);
const duplicates = await companyRepository
.createQueryBuilder('company')
.select('company.domainNamePrimaryLinkUrl')
.addSelect('COUNT(*)', 'count')
.where('company.deletedAt IS NULL')
.where('company.domainNamePrimaryLinkUrl IS NOT NULL')
.where("company.domainNamePrimaryLinkUrl != ''")
.groupBy('company.domainNamePrimaryLinkUrl')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { company_domainNamePrimaryLinkUrl } = duplicate;
const companies = await companyRepository.find({
where: {
domainName: {
primaryLinkUrl: company_domainNamePrimaryLinkUrl,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < companies.length; i++) {
const newdomainNamePrimaryLinkUrl = `${company_domainNamePrimaryLinkUrl}${i}`;
if (!dryRun) {
await companyRepository.update(companies[i].id, {
domainNamePrimaryLinkUrl: newdomainNamePrimaryLinkUrl,
});
}
this.logger.log(
chalk.yellow(
`Updated company ${companies[i].id} domainName from ${company_domainNamePrimaryLinkUrl} to ${newdomainNamePrimaryLinkUrl}`,
),
);
}
}
}
private async enforceUniquePersonEmail(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'person',
);
const duplicates = await personRepository
.createQueryBuilder('person')
.select('person.emailsPrimaryEmail')
.addSelect('COUNT(*)', 'count')
.where('person.deletedAt IS NULL')
.where('person.emailsPrimaryEmail IS NOT NULL')
.where("person.emailsPrimaryEmail != ''")
.groupBy('person.emailsPrimaryEmail')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { person_emailsPrimaryEmail } = duplicate;
const persons = await personRepository.find({
where: {
emails: {
primaryEmail: person_emailsPrimaryEmail,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < persons.length; i++) {
const newEmail = person_emailsPrimaryEmail?.includes('@')
? `${person_emailsPrimaryEmail.split('@')[0]}+${i}@${person_emailsPrimaryEmail.split('@')[1]}`
: `${person_emailsPrimaryEmail}+${i}`;
if (!dryRun) {
await personRepository.update(persons[i].id, {
emailsPrimaryEmail: newEmail,
});
}
this.logger.log(
chalk.yellow(
`Updated person ${persons[i].id} emailsPrimaryEmail from ${person_emailsPrimaryEmail} to ${newEmail}`,
),
);
}
}
}
private async enforceUniqueViewField(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewField',
);
const duplicates = await viewFieldRepository
.createQueryBuilder('viewField')
.select(['viewField.fieldMetadataId', 'viewField.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewField.deletedAt IS NULL')
.groupBy('viewField.fieldMetadataId, viewField.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { fieldMetadataId, viewId } = duplicate;
const viewFields = await viewFieldRepository.find({
where: { fieldMetadataId, viewId, deletedAt: IsNull() },
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewFields.length; i++) {
if (!dryRun) {
await viewFieldRepository.softDelete(viewFields[i].id);
}
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewField ${viewFields[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
private async enforceUniqueViewSort(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewSortRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewSort',
);
const duplicates = await viewSortRepository
.createQueryBuilder('viewSort')
.select(['viewSort.fieldMetadataId', 'viewSort.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewSort.deletedAt IS NULL')
.groupBy('viewSort.fieldMetadataId, viewSort.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { fieldMetadataId, viewId } = duplicate;
const viewSorts = await viewSortRepository.find({
where: { fieldMetadataId, viewId, deletedAt: IsNull() },
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewSorts.length; i++) {
if (!dryRun) {
await viewSortRepository.softDelete(viewSorts[i].id);
}
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewSort ${viewSorts[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
}

View File

@ -0,0 +1,50 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
import { EnforceUniqueConstraintsCommand } from './0-32-enforce-unique-constraints.command';
interface UpdateTo0_32CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.32',
description: 'Upgrade to 0.32',
})
export class UpgradeTo0_32Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly enforceUniqueConstraintsCommand: EnforceUniqueConstraintsCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_32CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.enforceUniqueConstraintsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EnforceUniqueConstraintsCommand } from 'src/database/commands/upgrade-version/0-32/0-32-enforce-unique-constraints.command';
import { UpgradeTo0_32Command } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
WorkspaceSyncMetadataCommandsModule,
],
providers: [UpgradeTo0_32Command, EnforceUniqueConstraintsCommand],
})
export class UpgradeTo0_32CommandModule {}