fixing index on relations (#12602)

## Why

After the changes on relations, index on relations were skipped by the
syncmetadata service, so no more migrations were generated for relation
fields.

We wanted to fix this.


## Test

This PR adds unit tests for the `createIndexMigration` utility in the
workspace migration builder. The tests cover:

- Creating index migrations for simple fields (e.g., text fields)
- Creating index migrations for relation fields (ensuring correct column
naming, e.g., `authorId` for the `author` objectmetadataname)


## Excluded
The delete index on relation does not need the column names so i don't
think i needed to work on this method. I might be wrong.


## Checklist

- [x] Added/updated unit tests for index migration creation
- [x] Verified correct handling of simple and relation fields
- [x] Ensured all tests pass

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-06-17 18:22:08 +02:00
committed by GitHub
parent 1d703bbf2b
commit c72ecde094
6 changed files with 268 additions and 172 deletions

View File

@ -3,7 +3,7 @@ import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metad
import { computeTableName } from './compute-table-name.util';
export const computeObjectTargetTable = (
objectMetadata: ObjectMetadataInterface,
objectMetadata: Pick<ObjectMetadataInterface, 'nameSingular' | 'isCustom'>,
) => {
return computeTableName(objectMetadata.nameSingular, objectMetadata.isCustom);
};

View File

@ -8,7 +8,7 @@ export function isFieldMetadataInterfaceOfType<
Field extends FieldMetadataInterface<FieldMetadataType>,
Type extends FieldMetadataType,
>(
fieldMetadata: Field,
fieldMetadata: Pick<Field, 'type'>,
type: Type,
): fieldMetadata is Field & FieldMetadataInterface<Type> {
return fieldMetadata.type === type;
@ -18,7 +18,7 @@ export function isFieldMetadataEntityOfType<
Field extends FieldMetadataEntity<FieldMetadataType>,
Type extends FieldMetadataType,
>(
fieldMetadata: Field,
fieldMetadata: Pick<Field, 'type'>,
type: Type,
): fieldMetadata is Field & FieldMetadataEntity<Type> {
return fieldMetadata.type === type;

View File

@ -0,0 +1,77 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { createIndexMigration } from 'src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils';
describe('WorkspaceMigrationIndexFactory', () => {
it('should create index migrations for simple fields', async () => {
const objectMetadata = {
id: 'obj1',
workspaceId: 'ws1',
nameSingular: 'Test',
fields: [{ id: 'f1', name: 'simpleField', type: FieldMetadataType.TEXT }],
isCustom: false,
};
const indexMetadata = {
name: 'idx_simple',
isUnique: true,
indexType: 'BTREE',
indexWhereClause: null,
indexFieldMetadatas: [{ fieldMetadataId: 'f1', order: 0 }],
} as IndexMetadataEntity;
const map = new Map([[objectMetadata, [indexMetadata]]]);
const result = (await createIndexMigration(map)) as any;
expect(result).toHaveLength(1);
const firstMigration = result[0].migrations[0];
expect(firstMigration.action).toBe('alter_indexes');
expect(firstMigration.indexes[0].name).toBe('idx_simple');
expect(firstMigration.indexes[0].columns).toEqual(['simpleField']);
expect(firstMigration.indexes[0].type).toBe('BTREE');
expect(firstMigration.indexes[0].isUnique).toBe(true);
expect(firstMigration.indexes[0].where).toBe(
'"simpleField" != \'\' AND "deletedAt" IS NULL',
);
});
it('should create index migrations for relation fields', async () => {
const fieldMetadata: Pick<
FieldMetadataEntity<FieldMetadataType.RELATION>,
'id' | 'name' | 'type' | 'settings' | 'isCustom'
> = {
id: 'f2',
name: 'author',
type: FieldMetadataType.RELATION,
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'authorId',
},
isCustom: false,
};
const objectMetadata = {
id: 'obj2',
workspaceId: 'ws1',
nameSingular: 'Attachment',
fields: [fieldMetadata],
isCustom: false,
};
const indexMetadata = {
name: 'idx_rel',
isUnique: false,
indexType: 'BTREE',
indexWhereClause: null,
indexFieldMetadatas: [{ fieldMetadataId: 'f2', order: 0 }],
} as IndexMetadataEntity;
const map = new Map([[objectMetadata, [indexMetadata]]]);
const result = (await createIndexMigration(map)) as any;
const firstMigration = result[0].migrations[0];
expect(firstMigration.indexes[0].columns).toEqual(['authorId']);
});
});

View File

@ -0,0 +1,161 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationEntity,
WorkspaceMigrationIndexActionType,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const createIndexMigration = async (
indexMetadataByObjectMetadataMap: Map<
Pick<
ObjectMetadataEntity,
'id' | 'workspaceId' | 'nameSingular' | 'isCustom'
> & {
fields: Pick<FieldMetadataEntity, 'id' | 'name' | 'type' | 'settings'>[];
},
IndexMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> => {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const [
objectMetadata,
indexMetadataCollection,
] of indexMetadataByObjectMetadataMap) {
const targetTable = computeObjectTargetTable(objectMetadata);
const fieldsById = Object.fromEntries(
objectMetadata.fields.map((field) => [field.id, field]),
);
const indexes = indexMetadataCollection.map((indexMetadata) => {
const columns = indexMetadata.indexFieldMetadatas
.sort((a, b) => a.order - b.order)
.map((indexFieldMetadata) => {
const fieldMetadata = fieldsById[indexFieldMetadata.fieldMetadataId];
if (!fieldMetadata) {
throw new Error(
`Field metadata with id ${indexFieldMetadata.fieldMetadataId} not found in object metadata with id ${objectMetadata.id}`,
);
}
if (
isFieldMetadataEntityOfType(
fieldMetadata,
FieldMetadataType.RELATION,
)
) {
if (!fieldMetadata.settings) {
throw new Error(
`Join column name is not supported for relation fields`,
);
}
return fieldMetadata.settings.joinColumnName;
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return fieldMetadata.name;
}
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type,
) as CompositeType;
const columns = compositeType.properties
.filter((property) => property.isIncludedInUniqueConstraint)
.map((property) =>
computeCompositeColumnName(fieldMetadata, property),
);
return columns;
})
.flat()
.filter(isDefined);
const defaultWhereClause = indexMetadata.isUnique
? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != '' AND "deletedAt" IS NULL`
: null;
return {
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.CREATE,
isUnique: indexMetadata.isUnique,
columns,
type: indexMetadata.indexType,
where: indexMetadata.indexWhereClause ?? defaultWhereClause,
};
});
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(
`create-${objectMetadata.nameSingular}-indexes`,
),
isCustom: false,
migrations: [
{
name: targetTable,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes,
},
],
});
}
return workspaceMigrations;
};
export const deleteIndexMigration = async (
indexMetadataByObjectMetadataMap: Map<
ObjectMetadataEntity,
IndexMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> => {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const [
objectMetadata,
indexMetadataCollection,
] of indexMetadataByObjectMetadataMap) {
const targetTable = computeObjectTargetTable(objectMetadata);
const indexes = indexMetadataCollection.map((indexMetadata) => ({
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.DROP,
columns: [],
isUnique: indexMetadata.isUnique,
}));
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(
`delete-${objectMetadata.nameSingular}-indexes`,
),
isCustom: false,
migrations: [
{
name: targetTable,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes,
},
],
});
}
return workspaceMigrations;
};

View File

@ -1,20 +1,14 @@
import { Injectable } from '@nestjs/common';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import {
WorkspaceMigrationEntity,
WorkspaceMigrationIndexActionType,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
createIndexMigration,
deleteIndexMigration,
} from 'src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils';
@Injectable()
export class WorkspaceMigrationIndexFactory {
@ -55,131 +49,11 @@ export class WorkspaceMigrationIndexFactory {
switch (action) {
case WorkspaceMigrationBuilderAction.CREATE:
return this.createIndexMigration(indexMetadataByObjectMetadataMap);
return createIndexMigration(indexMetadataByObjectMetadataMap);
case WorkspaceMigrationBuilderAction.DELETE:
return this.deleteIndexMigration(indexMetadataByObjectMetadataMap);
return deleteIndexMigration(indexMetadataByObjectMetadataMap);
default:
return [];
}
}
private async createIndexMigration(
indexMetadataByObjectMetadataMap: Map<
ObjectMetadataEntity,
IndexMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const [
objectMetadata,
indexMetadataCollection,
] of indexMetadataByObjectMetadataMap) {
const targetTable = computeObjectTargetTable(objectMetadata);
const fieldsById = Object.fromEntries(
objectMetadata.fields.map((field) => [field.id, field]),
);
const indexes = indexMetadataCollection.map((indexMetadata) => {
const columns = indexMetadata.indexFieldMetadatas
.sort((a, b) => a.order - b.order)
.map((indexFieldMetadata) => {
const fieldMetadata =
fieldsById[indexFieldMetadata.fieldMetadataId];
if (!fieldMetadata) {
throw new Error(
`Field metadata with id ${indexFieldMetadata.fieldMetadataId} not found in object metadata with id ${objectMetadata.id}`,
);
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return fieldMetadata.name;
}
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type,
) as CompositeType;
return compositeType.properties
.filter((property) => property.isIncludedInUniqueConstraint)
.map((property) =>
computeCompositeColumnName(fieldMetadata, property),
);
})
.flat();
const defaultWhereClause = indexMetadata.isUnique
? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != '' AND "deletedAt" IS NULL`
: null;
return {
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.CREATE,
isUnique: indexMetadata.isUnique,
columns,
type: indexMetadata.indexType,
where: indexMetadata.indexWhereClause ?? defaultWhereClause,
};
});
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(
`create-${objectMetadata.nameSingular}-indexes`,
),
isCustom: false,
migrations: [
{
name: targetTable,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes,
},
],
});
}
return workspaceMigrations;
}
private async deleteIndexMigration(
indexMetadataByObjectMetadataMap: Map<
ObjectMetadataEntity,
IndexMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const [
objectMetadata,
indexMetadataCollection,
] of indexMetadataByObjectMetadataMap) {
const targetTable = computeObjectTargetTable(objectMetadata);
const indexes = indexMetadataCollection.map((indexMetadata) => ({
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.DROP,
columns: [],
isUnique: indexMetadata.isUnique,
}));
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(
`delete-${objectMetadata.nameSingular}-indexes`,
),
isCustom: false,
migrations: [
{
name: targetTable,
action: WorkspaceMigrationTableActionType.ALTER_INDEXES,
indexes,
},
],
});
}
return workspaceMigrations;
}
}

View File

@ -69,46 +69,30 @@ export class StandardIndexFactory {
);
});
return (
workspaceIndexMetadataArgsCollection
.map((workspaceIndexMetadataArgs) => {
const objectMetadata =
originalStandardObjectMetadataMap[workspaceEntity.nameSingular];
return workspaceIndexMetadataArgsCollection.map(
(workspaceIndexMetadataArgs) => {
const objectMetadata =
originalStandardObjectMetadataMap[workspaceEntity.nameSingular];
if (!objectMetadata) {
throw new Error(
`Object metadata not found for ${workspaceEntity.nameSingular}`,
);
}
const indexMetadata: PartialIndexMetadata = {
workspaceId: context.workspaceId,
objectMetadataId: objectMetadata.id,
name: workspaceIndexMetadataArgs.name,
columns: workspaceIndexMetadataArgs.columns,
isUnique: workspaceIndexMetadataArgs.isUnique,
isCustom: false,
indexWhereClause: workspaceIndexMetadataArgs.whereClause,
indexType: workspaceIndexMetadataArgs.type,
};
return indexMetadata;
})
// TODO: remove this filter when we have a way to handle index on relations
.filter((workspaceIndexMetadataArgs) => {
const objectMetadata =
originalStandardObjectMetadataMap[workspaceEntity.nameSingular];
const hasAllFields = workspaceIndexMetadataArgs.columns.every(
(expectedField) => {
return objectMetadata.fields.some(
(field) => field.name === expectedField,
);
},
if (!objectMetadata) {
throw new Error(
`Object metadata not found for ${workspaceEntity.nameSingular}`,
);
}
return hasAllFields;
})
const indexMetadata: PartialIndexMetadata = {
workspaceId: context.workspaceId,
objectMetadataId: objectMetadata.id,
name: workspaceIndexMetadataArgs.name,
columns: workspaceIndexMetadataArgs.columns,
isUnique: workspaceIndexMetadataArgs.isUnique,
isCustom: false,
indexWhereClause: workspaceIndexMetadataArgs.whereClause,
indexType: workspaceIndexMetadataArgs.type,
};
return indexMetadata;
},
);
}