Common Api - createOne/Many (#15083)

closes https://github.com/twentyhq/core-team-issues/issues/1578
This commit is contained in:
Etienne
2025-10-15 14:13:07 +02:00
committed by GitHub
parent f65783f900
commit 4ae299973b
31 changed files with 1573 additions and 45 deletions
@@ -0,0 +1,436 @@
import { Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util';
import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import {
CommonQueryNames,
CreateManyQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@Injectable()
export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService {
async run({
args,
authContext: toValidateAuthContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
}: {
args: CreateManyQueryArgs;
authContext: AuthContext;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<ObjectRecord[]> {
const authContext = toValidateAuthContext;
if (!isWorkspaceAuthContext(authContext)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
);
}
assertMutationNotOnRemoteObject(objectMetadataItemWithFieldMaps);
// TODO : Refacto-common - Remove this validation once https://github.com/twentyhq/core-team-issues/issues/1622 done
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);
}
});
const {
workspaceDataSource,
repository,
roleId,
shouldBypassPermissionChecks,
} = await this.prepareQueryRunnerContext({
authContext,
objectMetadataItemWithFieldMaps,
});
const processedArgs = await this.processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
});
const objectRecords = await this.insertOrUpsertRecords({
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
args: processedArgs,
});
const upsertedRecords = await this.fetchUpsertedRecords({
args: processedArgs,
objectRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
repository,
});
await this.processNestedRelationsIfNeeded({
args: processedArgs,
records: upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
});
return upsertedRecords;
}
async processQueryArgs({
authContext,
objectMetadataItemWithFieldMaps,
args,
}: {
authContext: WorkspaceAuthContext;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
args: CreateManyQueryArgs;
}): Promise<CreateManyQueryArgs> {
const hookedArgs =
(await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItemWithFieldMaps.nameSingular,
CommonQueryNames.createMany,
args,
//TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency
)) as CreateManyQueryArgs;
return {
...hookedArgs,
data: await this.queryRunnerArgsFactory.overrideDataByFieldMetadata({
partialRecordInputs: hookedArgs.data,
authContext,
objectMetadataItemWithFieldMaps,
}),
};
}
private async insertOrUpsertRecords({
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
args,
}: {
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
args: CreateManyQueryArgs;
}): Promise<InsertResult> {
if (!args.upsert) {
const selectedColumns = buildColumnsToReturn({
select: args.selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
return await repository.insert(args.data, undefined, selectedColumns);
}
return this.performUpsertOperation({
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
args,
});
}
private async performUpsertOperation({
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
args,
}: {
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
args: CreateManyQueryArgs;
}): Promise<InsertResult> {
const conflictingFields = getConflictingFields(
objectMetadataItemWithFieldMaps,
);
const existingRecords = await this.findExistingRecords({
repository,
objectMetadataItemWithFieldMaps,
args,
conflictingFields,
});
const { recordsToUpdate, recordsToInsert } = categorizeRecords(
args.data,
conflictingFields,
existingRecords,
);
const result: InsertResult = {
identifiers: [],
generatedMaps: [],
raw: [],
};
const columnsToReturn = buildColumnsToReturn({
select: args.selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
if (recordsToUpdate.length > 0) {
await this.processRecordsToUpdate({
partialRecordsToUpdate: recordsToUpdate,
repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
});
}
await this.processRecordsToInsert({
recordsToInsert,
repository,
result,
columnsToReturn,
});
return result;
}
private async findExistingRecords({
repository,
objectMetadataItemWithFieldMaps,
args,
conflictingFields,
}: {
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
args: CreateManyQueryArgs;
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[];
}): Promise<PartialObjectRecordWithId[]> {
const queryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const whereConditions = buildWhereConditions(args.data, conflictingFields);
whereConditions.forEach((condition) => {
queryBuilder.orWhere(condition);
});
const restrictedFields =
repository.objectRecordsPermissions?.[objectMetadataItemWithFieldMaps.id]
?.restrictedFields;
const selectOptions = getAllSelectableFields({
restrictedFields: restrictedFields ?? {},
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
},
});
return (await queryBuilder
.withDeleted()
.setFindOptions({
select: selectOptions,
})
.getMany()) as PartialObjectRecordWithId[];
}
private async processRecordsToUpdate({
partialRecordsToUpdate,
repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
}: {
partialRecordsToUpdate: PartialObjectRecordWithId[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
const partialRecordsToUpdateWithoutCreatedByUpdate =
partialRecordsToUpdate.map((record) =>
this.getRecordWithoutCreatedBy(record, objectMetadataItemWithFieldMaps),
);
const savedRecords = await repository.updateMany(
partialRecordsToUpdateWithoutCreatedByUpdate.map((record) => ({
criteria: record.id,
partialEntity: { ...record, deletedAt: null },
})),
undefined,
columnsToReturn,
);
result.identifiers.push(
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
);
result.generatedMaps.push(
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
);
}
private async processRecordsToInsert({
recordsToInsert,
repository,
result,
columnsToReturn,
}: {
recordsToInsert: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
if (recordsToInsert.length > 0) {
const insertResult = await repository.insert(
recordsToInsert,
undefined,
columnsToReturn,
);
result.identifiers.push(...insertResult.identifiers);
result.generatedMaps.push(...insertResult.generatedMaps);
result.raw.push(...insertResult.raw);
}
}
private async fetchUpsertedRecords({
args,
objectRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
repository,
}: {
args: CreateManyQueryArgs;
objectRecords: InsertResult;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
repository: WorkspaceRepository<ObjectLiteral>;
}): Promise<ObjectRecord[]> {
const queryBuilder = repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToSelect = buildColumnsToSelect({
select: args.selectedFieldsResult.select,
relations: args.selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const upsertedRecords = await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
})
.withDeleted()
.take(QUERY_MAX_RECORDS)
.getMany();
return upsertedRecords as ObjectRecord[];
}
private async processNestedRelationsIfNeeded({
args,
records,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
roleId,
authContext,
workspaceDataSource,
shouldBypassPermissionChecks,
}: {
args: CreateManyQueryArgs;
records: ObjectRecord[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
roleId?: string;
authContext: AuthContext;
workspaceDataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
}): Promise<void> {
if (!args.selectedFieldsResult.relations) {
return;
}
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: records,
//TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common
relations: args.selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource,
roleId,
shouldBypassPermissionChecks,
selectedFields: args.selectedFieldsResult.select,
});
}
private getRecordWithoutCreatedBy(
record: PartialObjectRecordWithId,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): Omit<PartialObjectRecordWithId, 'createdBy'> {
let recordWithoutCreatedByUpdate = record;
const createdByFieldMetadataId =
objectMetadataItemWithFieldMaps.fieldIdByName['createdBy'];
const createdByFieldMetadata =
objectMetadataItemWithFieldMaps.fieldsById[createdByFieldMetadataId];
if (!isDefined(createdByFieldMetadata)) {
throw new CommonQueryRunnerException(
`Missing createdBy field metadata for object ${objectMetadataItemWithFieldMaps.nameSingular}`,
CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD,
);
}
if ('createdBy' in record && createdByFieldMetadata.isCustom === false) {
const { createdBy: _createdBy, ...recordWithoutCreatedBy } = record;
recordWithoutCreatedByUpdate = recordWithoutCreatedBy;
}
return recordWithoutCreatedByUpdate;
}
}
@@ -0,0 +1,3 @@
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
export type PartialObjectRecordWithId = Partial<ObjectRecord> & { id: string };
@@ -0,0 +1,110 @@
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
describe('buildWhereConditions', () => {
const records: Partial<ObjectRecord>[] = [
{
id: 'record-1',
uniqueText: 'alpha',
emailsField: { primaryEmail: 'alpha@example.com' },
},
{
id: 'record-2',
uniqueText: 'beta',
emailsField: { primaryEmail: 'beta@example.com' },
},
{
id: 'record-3',
// uniqueText intentionally missing to validate filtering of undefined
emailsField: { primaryEmail: undefined },
},
];
it('returns empty array when no conflicting fields provided', () => {
const where = buildWhereConditions(records, []);
expect(where).toEqual([]);
});
it('builds a single where condition for a flat field using all defined values', () => {
const where = buildWhereConditions(records, [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
]);
expect(where).toHaveLength(1);
const condition = where[0];
expect(Object.keys(condition)).toEqual(['uniqueText']);
const operator = condition.uniqueText;
expect(operator.type.toLowerCase()).toBe('in');
expect(operator.value).toEqual(['alpha', 'beta']);
});
it('skips adding a condition when all values for a field are undefined', () => {
const where = buildWhereConditions(
[{ id: '1' }, { id: '2' }],
[
{
baseField: 'uniqueText',
fullPath: 'uniqueText',
column: 'uniqueText',
},
],
);
expect(where).toEqual([]);
});
it('builds conditions for nested paths', () => {
const where = buildWhereConditions(records, [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
]);
expect(where).toHaveLength(1);
const condition = where[0];
expect(Object.keys(condition)).toEqual(['emailsFieldPrimaryEmail']);
const operator = condition.emailsFieldPrimaryEmail;
expect(operator.type.toLowerCase()).toBe('in');
expect(operator.value).toEqual(['alpha@example.com', 'beta@example.com']);
});
it('builds multiple conditions when multiple conflicting fields are provided', () => {
const where = buildWhereConditions(records, [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
]);
expect(where).toHaveLength(2);
expect(where.map((condition) => Object.keys(condition)[0]).sort()).toEqual([
'emailsFieldPrimaryEmail',
'uniqueText',
]);
const uniqueTextOperator = where.find((c) => 'uniqueText' in c)?.uniqueText;
const emailOperator = where.find(
(c) => 'emailsFieldPrimaryEmail' in c,
)?.emailsFieldPrimaryEmail;
expect(uniqueTextOperator?.value).toEqual(['alpha', 'beta']);
expect(emailOperator?.value).toEqual([
'alpha@example.com',
'beta@example.com',
]);
});
});
@@ -0,0 +1,107 @@
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util';
describe('categorizeRecords', () => {
const conflictingFields = [
{ baseField: 'id', fullPath: 'id', column: 'id' },
{
baseField: 'uniqueText',
fullPath: 'uniqueText',
column: 'uniqueText',
},
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
];
const existingRecords: PartialObjectRecordWithId[] = [
{
id: 'r1',
uniqueText: 'alpha',
emailsField: { primaryEmail: 'alpha@example.com' },
},
{
id: 'r2',
uniqueText: 'beta',
emailsField: { primaryEmail: 'beta@example.com' },
},
];
it('return records to insert only', () => {
const records: Partial<ObjectRecord>[] = [
{ uniqueText: 'gamma' },
{ emailsField: { primaryEmail: 'nobody@example.com' } },
];
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
existingRecords,
);
expect(recordsToUpdate).toHaveLength(0);
expect(recordsToInsert).toHaveLength(2);
expect(recordsToInsert).toEqual(records);
});
it('return records to update only', () => {
const records: Partial<ObjectRecord>[] = [
{ uniqueText: 'alpha', name: 'Updated A' },
{ emailsField: { primaryEmail: 'beta@example.com' }, name: 'Updated B' },
];
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
existingRecords,
);
expect(recordsToInsert).toHaveLength(0);
expect(recordsToUpdate).toHaveLength(2);
const ids = recordsToUpdate.map((r) => r.id);
expect(ids.sort()).toEqual(['r1', 'r2']);
expect(recordsToUpdate).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'r1',
name: 'Updated A',
uniqueText: 'alpha',
}),
expect.objectContaining({ id: 'r2', name: 'Updated B' }),
]),
);
});
it('return records to insert and update', () => {
const records: Partial<ObjectRecord>[] = [
{ uniqueText: 'alpha' },
{ uniqueText: 'gamma' },
{ emailsField: { primaryEmail: 'beta@example.com' } },
];
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
existingRecords,
);
expect(recordsToUpdate).toHaveLength(2);
expect(recordsToInsert).toHaveLength(1);
expect(recordsToInsert[0]).toEqual({ uniqueText: 'gamma' });
expect(recordsToUpdate).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'r1', uniqueText: 'alpha' }),
expect.objectContaining({ id: 'r2' }),
]),
);
});
});
@@ -0,0 +1,139 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { objectMetadataMapItemMock } from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('getConflictingFields', () => {
const workspaceId = 'workspaceId';
const objectMetadataId = 'objectMetadataId';
const idField = getMockFieldMetadataEntity({
workspaceId,
objectMetadataId,
id: 'id-field-id',
name: 'id',
type: FieldMetadataType.UUID,
isUnique: true,
});
const uniqueTextField = getMockFieldMetadataEntity({
workspaceId,
objectMetadataId,
id: 'unique-text-id',
name: 'uniqueText',
type: FieldMetadataType.TEXT,
isUnique: true,
});
const emailsUniqueField = getMockFieldMetadataEntity({
workspaceId,
objectMetadataId,
id: 'emails-unique-id',
name: 'emailsField',
type: FieldMetadataType.EMAILS,
isUnique: true,
});
const phonesNotUniqueField = getMockFieldMetadataEntity({
workspaceId,
objectMetadataId,
id: 'phones-not-unique-id',
name: 'phonesField',
type: FieldMetadataType.PHONES,
isUnique: false,
});
const addressUniqueFieldNoIncludedProp = getMockFieldMetadataEntity({
workspaceId,
objectMetadataId,
id: 'address-unique-id',
name: 'addressField',
type: FieldMetadataType.ADDRESS,
isUnique: true,
});
const buildObjectMetadataWithFields = (
fields: (typeof idField)[],
): ObjectMetadataItemWithFieldMaps => {
const fieldsById = fields.reduce<Record<string, typeof idField>>(
(acc, field) => {
acc[field.id] = field;
return acc;
},
{},
);
return {
...objectMetadataMapItemMock,
fieldsById,
fieldIdByName: Object.fromEntries(
Object.values(fieldsById).map((f) => [f.name, f.id]),
),
} as ObjectMetadataItemWithFieldMaps;
};
it('returns id and unique non-composite fields as conflicts', () => {
const objectMetadata = buildObjectMetadataWithFields([
idField,
uniqueTextField,
]);
const result = getConflictingFields(objectMetadata);
expect(result).toEqual(
expect.arrayContaining([
{ baseField: 'id', fullPath: 'id', column: 'id' },
{
baseField: 'uniqueText',
fullPath: 'uniqueText',
column: 'uniqueText',
},
]),
);
});
it('returns composite field with included unique property using full path and computed column', () => {
const objectMetadata = buildObjectMetadataWithFields([
idField,
emailsUniqueField,
]);
const result = getConflictingFields(objectMetadata);
expect(result).toEqual(
expect.arrayContaining([
{ baseField: 'id', fullPath: 'id', column: 'id' },
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
]),
);
});
it('does not include composite fields without included unique property', () => {
const objectMetadata = buildObjectMetadataWithFields([
idField,
addressUniqueFieldNoIncludedProp,
]);
const result = getConflictingFields(objectMetadata);
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
});
it('ignores non-unique fields', () => {
const objectMetadata = buildObjectMetadataWithFields([
idField,
phonesNotUniqueField,
]);
const result = getConflictingFields(objectMetadata);
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
});
});
@@ -0,0 +1,98 @@
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util';
import { CommonQueryRunnerExceptionCode } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
describe('getMatchingRecordId', () => {
const existingRecords: PartialObjectRecordWithId[] = [
{
id: 'recordId1',
uniqueText: 'alpha',
emailsField: { primaryEmail: 'alpha@example.com' },
},
{
id: 'recordId2',
uniqueText: 'beta',
emailsField: { primaryEmail: 'beta@example.com' },
},
];
it('returns the matching record id when exactly one field matches one existing record', () => {
const record = {
emailsField: { primaryEmail: 'alpha@example.com' },
};
const conflictingFields = [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
expect(id).toBe('recordId1');
});
it('returns undefined when no existing record matches any conflicting field', () => {
const record = {
emailsField: { primaryEmail: 'nobody@example.com' },
};
const conflictingFields = [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
expect(id).toBeUndefined();
});
it('returns the matching id if multiple conflicting fields point to the same existing record', () => {
const record = {
id: 'recordId1',
uniqueText: 'alpha',
};
const conflictingFields = [
{ baseField: 'id', fullPath: 'id', column: 'id' },
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
expect(id).toBe('recordId1');
});
it('throws when conflicting fields match different existing records', () => {
const record = {
uniqueText: 'alpha',
emailsField: { primaryEmail: 'beta@example.com' },
};
const conflictingFields = [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
];
expect(() =>
getMatchingRecordId(record, conflictingFields, existingRecords),
).toThrow();
try {
getMatchingRecordId(record, conflictingFields, existingRecords);
} catch (error) {
expect(error.code).toBe(
CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
);
}
});
});
@@ -0,0 +1,36 @@
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
describe('getValueFromPath', () => {
const baseRecord: Partial<ObjectRecord> = {
id: 'recordId',
name: 'John Doe',
parent: { child: 'nested-value', empty: '' },
emailsField: { primaryEmail: 'john@example.com' },
};
it('returns direct field value for single-level path', () => {
const value = getValueFromPath(baseRecord, 'name');
expect(value).toBe('John Doe');
});
it('returns nested value for two-level path', () => {
const value = getValueFromPath(baseRecord, 'parent.child');
expect(value).toBe('nested-value');
});
it('returns undefined when parent field does not exist', () => {
const value = getValueFromPath(baseRecord, 'missing.child');
expect(value).toBeUndefined();
});
it('returns undefined when child field does not exist on existing parent', () => {
const value = getValueFromPath(baseRecord, 'parent.missing');
expect(value).toBeUndefined();
});
});
@@ -0,0 +1,29 @@
import { isDefined } from 'twenty-shared/utils';
import { type FindOperator, In } from 'typeorm';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
export const buildWhereConditions = (
records: Partial<ObjectRecord>[],
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
): Record<string, FindOperator<string>>[] => {
const whereConditions: Record<string, FindOperator<string>>[] = [];
for (const field of conflictingFields) {
const fieldValues = records
.map((record) => getValueFromPath(record, field.fullPath))
.filter(isDefined);
if (fieldValues.length > 0) {
whereConditions.push({ [field.column]: In(fieldValues) });
}
}
return whereConditions;
};
@@ -0,0 +1,38 @@
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util';
export const categorizeRecords = (
records: Partial<ObjectRecord>[],
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
existingRecords: PartialObjectRecordWithId[],
): {
recordsToUpdate: PartialObjectRecordWithId[];
recordsToInsert: Partial<ObjectRecord>[];
} => {
const recordsToUpdate: PartialObjectRecordWithId[] = [];
const recordsToInsert: Partial<ObjectRecord>[] = [];
for (const record of records) {
const matchingRecordId = getMatchingRecordId(
record,
conflictingFields,
existingRecords,
);
if (isDefined(matchingRecordId)) {
recordsToUpdate.push({ ...record, id: matchingRecordId });
} else {
recordsToInsert.push(record);
}
}
return { recordsToUpdate, recordsToInsert };
};
@@ -0,0 +1,42 @@
import { capitalize } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const getConflictingFields = (
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): {
baseField: string;
fullPath: string;
column: string;
}[] => {
return Object.values(objectMetadataItemWithFieldMaps.fieldsById)
.filter((field) => field.isUnique || field.name === 'id')
.flatMap((field) => {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
return [
{
baseField: field.name,
fullPath: field.name,
column: field.name,
},
];
}
const property = compositeType.properties.find(
(prop) => prop.isIncludedInUniqueConstraint,
);
return property
? [
{
baseField: field.name,
fullPath: `${field.name}.${property.name}`,
column: `${field.name}${capitalize(property.name)}`,
},
]
: [];
});
};
@@ -0,0 +1,64 @@
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
export const getMatchingRecordId = (
record: Partial<ObjectRecord>,
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
existingRecords: PartialObjectRecordWithId[],
): string | undefined => {
const matchingRecordIds = conflictingFields.reduce<string[]>((acc, field) => {
const requestFieldValue = getValueFromPath(record, field.fullPath);
const matchingRecord = existingRecords.find((existingRecord) => {
const existingFieldValue = getValueFromPath(
existingRecord,
field.fullPath,
);
return (
isDefined(existingFieldValue) &&
existingFieldValue === requestFieldValue
);
});
if (isDefined(matchingRecord)) {
acc.push(matchingRecord.id);
}
return acc;
}, []);
if ([...new Set(matchingRecordIds)].length > 1) {
const conflictingFieldsValues = conflictingFields
.map((field) => {
const value = getValueFromPath(record, field.fullPath);
return isDefined(value) ? `${field.fullPath}: ${value}` : undefined;
})
.filter(isDefined)
.join(', ');
throw new CommonQueryRunnerException(
`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
{
userFriendlyMessage: msg`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
},
);
}
return matchingRecordIds[0];
};
@@ -0,0 +1,16 @@
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
export const getValueFromPath = (
record: Partial<ObjectRecord>,
path: string,
): string | undefined => {
const pathParts = path.split('.');
if (pathParts.length === 1) {
return record[path];
}
const [parentField, childField] = pathParts;
return record[parentField]?.[childField];
};
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { CreateOneQueryArgs } from 'src/engine/api/common/types/common-query-args.type';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerService {
constructor(
private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService,
) {
super();
}
async run({
args,
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
}: {
args: CreateOneQueryArgs;
authContext: AuthContext;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): Promise<ObjectRecord> {
const result = await this.commonCreateManyQueryRunnerService.run({
args: {
...args,
data: [args.data],
},
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
});
return result[0];
}
}
@@ -1,7 +1,11 @@
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
export const CommonQueryRunners = [
CommonFindOneQueryRunnerService,
CommonFindManyQueryRunnerService,
CommonCreateOneQueryRunnerService,
CommonCreateManyQueryRunnerService,
];
@@ -9,4 +9,6 @@ export enum CommonQueryRunnerExceptionCode {
ARGS_CONFLICT = 'ARGS_CONFLICT',
INVALID_ARGS_FIRST = 'INVALID_ARGS_FIRST',
INVALID_ARGS_LAST = 'INVALID_ARGS_LAST',
UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT',
MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD',
}
@@ -20,9 +20,12 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
throw error;
default: {
return assertUnreachable(error.code);
}
@@ -19,11 +19,14 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
throw new BadRequestException(error.message);
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundException('Record not found');
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new UnauthorizedException(error.message);
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
throw error;
default: {
return assertUnreachable(error.code);
}
@@ -1,4 +1,5 @@
import {
type ObjectRecord,
type ObjectRecordFilter,
type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
@@ -8,6 +9,7 @@ import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/com
export enum CommonQueryNames {
findOne = 'findOne',
findMany = 'findMany',
createMany = 'createMany',
}
export interface FindOneQueryArgs {
@@ -25,4 +27,14 @@ export interface FindManyQueryArgs {
after?: string;
}
export type CommonQueryArgs = FindOneQueryArgs | FindManyQueryArgs;
export interface CreateManyQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult;
data: Partial<ObjectRecord>[];
upsert?: boolean;
}
export interface CreateOneQueryArgs {
selectedFieldsResult: CommonSelectedFieldsResult;
data: Partial<ObjectRecord>;
upsert?: boolean;
}