Fix ORM event mixing records in batch updates (#15985)

While investigating the issue a customer was facing, I discovered that
before records and after records could be in different order, making the
orm event and timeline activity engine mix records
This commit is contained in:
Charles Bochet
2025-11-22 13:16:04 +01:00
committed by GitHub
parent b711c11431
commit 30e504628c
18 changed files with 329 additions and 90 deletions
@@ -20,10 +20,11 @@ import {
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
@@ -35,15 +36,15 @@ export class WorkspaceDeleteQueryBuilder<
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private authContext: AuthContext;
private featureFlagMap: FeatureFlagMap;
constructor(
queryBuilder: DeleteQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
featureFlagMap?: FeatureFlagMap,
authContext: AuthContext,
featureFlagMap: FeatureFlagMap,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
@@ -62,6 +63,7 @@ export class WorkspaceDeleteQueryBuilder<
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.featureFlagMap,
) as this;
}
@@ -81,26 +83,20 @@ export class WorkspaceDeleteQueryBuilder<
this.internalContext,
);
const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
true,
this.authContext,
this.featureFlagMap,
);
const eventSelectQueryBuilder = computeEventSelectQueryBuilder<T>({
queryBuilder: this,
authContext: this.authContext,
internalContext: this.internalContext,
featureFlagMap: this.featureFlagMap,
expressionMap: this.expressionMap,
objectRecordsPermissions: this.objectRecordsPermissions,
});
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres;
eventSelectQueryBuilder.expressionMap.aliases =
this.expressionMap.aliases;
eventSelectQueryBuilder.setParameters(this.getParameters());
const before = await eventSelectQueryBuilder.getOne();
this.expressionMap.wheres = applyTableAliasOnWhereCondition({
@@ -38,8 +38,8 @@ export class WorkspaceInsertQueryBuilder<
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private authContext: AuthContext;
private featureFlagMap: FeatureFlagMap;
private relationNestedQueries: RelationNestedQueries;
private relationNestedConfig:
| [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex]
@@ -50,8 +50,8 @@ export class WorkspaceInsertQueryBuilder<
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
featureFlagMap?: FeatureFlagMap,
authContext: AuthContext,
featureFlagMap: FeatureFlagMap,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
@@ -123,6 +123,7 @@ export class WorkspaceInsertQueryBuilder<
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.featureFlagMap,
);
const updatedValues =
@@ -33,15 +33,15 @@ export class WorkspaceSelectQueryBuilder<
objectRecordsPermissions: ObjectsPermissions;
shouldBypassPermissionChecks: boolean;
internalContext: WorkspaceInternalContext;
authContext?: AuthContext;
featureFlagMap?: FeatureFlagMap;
authContext: AuthContext;
featureFlagMap: FeatureFlagMap;
constructor(
queryBuilder: SelectQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
featureFlagMap?: FeatureFlagMap,
authContext: AuthContext,
featureFlagMap: FeatureFlagMap,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
@@ -20,9 +20,10 @@ import {
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
@@ -34,16 +35,16 @@ export class WorkspaceSoftDeleteQueryBuilder<
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private authContext: AuthContext;
private featureFlagMap: FeatureFlagMap;
constructor(
queryBuilder: SoftDeleteQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
featureFlagMap?: FeatureFlagMap,
authContext: AuthContext,
featureFlagMap: FeatureFlagMap,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
@@ -62,6 +63,7 @@ export class WorkspaceSoftDeleteQueryBuilder<
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.featureFlagMap,
) as this;
}
@@ -81,26 +83,21 @@ export class WorkspaceSoftDeleteQueryBuilder<
this.internalContext,
);
const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
true,
this.authContext,
this.featureFlagMap,
);
const beforeEventSelectQueryBuilder = computeEventSelectQueryBuilder<T>({
queryBuilder: this,
authContext: this.authContext,
internalContext: this.internalContext,
featureFlagMap: this.featureFlagMap,
expressionMap: this.expressionMap,
objectRecordsPermissions: this.objectRecordsPermissions,
});
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres;
eventSelectQueryBuilder.expressionMap.aliases =
this.expressionMap.aliases;
eventSelectQueryBuilder.setParameters(this.getParameters());
const before = await eventSelectQueryBuilder.getMany();
const before = await beforeEventSelectQueryBuilder.getMany();
this.expressionMap.wheres = applyTableAliasOnWhereCondition({
condition: this.expressionMap.wheres,
@@ -1,9 +1,11 @@
import { msg } from '@lingui/core/macro';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { type ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
UpdateQueryBuilder,
type EntityTarget,
type ObjectLiteral,
UpdateQueryBuilder,
type UpdateResult,
} from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
@@ -28,6 +30,7 @@ import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/reposito
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
@@ -40,8 +43,8 @@ export class WorkspaceUpdateQueryBuilder<
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private authContext: AuthContext;
private featureFlagMap: FeatureFlagMap;
private relationNestedQueries: RelationNestedQueries;
private relationNestedConfig:
| [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex]
@@ -56,8 +59,8 @@ export class WorkspaceUpdateQueryBuilder<
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
featureFlagMap?: FeatureFlagMap,
authContext: AuthContext,
featureFlagMap: FeatureFlagMap,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
@@ -103,27 +106,32 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
);
const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
true,
this.authContext,
this.featureFlagMap,
);
const eventSelectQueryBuilder = computeEventSelectQueryBuilder<T>({
queryBuilder: this,
authContext: this.authContext,
internalContext: this.internalContext,
featureFlagMap: this.featureFlagMap,
expressionMap: this.expressionMap,
objectRecordsPermissions: this.objectRecordsPermissions,
});
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres;
eventSelectQueryBuilder.expressionMap.aliases =
this.expressionMap.aliases;
eventSelectQueryBuilder.setParameters(this.getParameters());
const before = await eventSelectQueryBuilder.getMany();
if (before.length > QUERY_MAX_RECORDS) {
throw new TwentyORMException(
`Cannot update more than ${QUERY_MAX_RECORDS} records at once`,
TwentyORMExceptionCode.TOO_MANY_RECORDS_TO_UPDATE,
{
userFriendlyMessage: msg`You can only update up to ${QUERY_MAX_RECORDS} records at once.`,
},
);
}
this.expressionMap.wheres = applyTableAliasOnWhereCondition({
condition: this.expressionMap.wheres,
tableName,
@@ -136,6 +144,7 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.featureFlagMap,
);
if (isDefined(this.relationNestedConfig)) {
@@ -238,21 +247,18 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
);
const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
true,
this.authContext,
this.featureFlagMap,
);
const eventSelectQueryBuilder = computeEventSelectQueryBuilder<T>({
queryBuilder: this,
authContext: this.authContext,
internalContext: this.internalContext,
featureFlagMap: this.featureFlagMap,
expressionMap: this.expressionMap,
objectRecordsPermissions: this.objectRecordsPermissions,
});
eventSelectQueryBuilder.whereInIds(
this.manyInputs.map((input) => input.criteria),
);
eventSelectQueryBuilder.expressionMap.aliases =
this.expressionMap.aliases;
eventSelectQueryBuilder.setParameters(this.getParameters());
const beforeRecords = await eventSelectQueryBuilder.getMany();
@@ -270,6 +276,7 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.featureFlagMap,
);
this.relationNestedConfig =
@@ -81,7 +81,7 @@ export class WorkspaceRepository<
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
this.authContext ?? {},
this.featureFlagMap,
);
}