merge records composite type (#14005)

/closes #13989

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
neo773
2025-09-08 17:50:26 +02:00
committed by GitHub
co-authored by Charles Bochet
parent 9e81618773
commit ad944e1d2c
19 changed files with 1277 additions and 16 deletions
@@ -26,6 +26,7 @@ import {
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util';
import { type 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 { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@@ -188,15 +189,19 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
} else if (recordsWithValues.length === 1) {
mergedResult[fieldName] = recordsWithValues[0].value;
} else {
const priorityValue = recordsWithValues.find(
(item) => item.recordId === priorityRecordId,
);
const fieldMetadata = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).find((field) => field?.name === fieldName);
if (priorityValue) {
mergedResult[fieldName] = priorityValue.value;
} else {
mergedResult[fieldName] = recordsWithValues[0].value;
if (!fieldMetadata) {
return;
}
mergedResult[fieldName] = mergeFieldValues(
fieldMetadata.type,
recordsWithValues,
priorityRecordId,
);
}
});
@@ -211,11 +216,7 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv
objectMetadataItemWithFieldMaps.fieldsById,
).find((field) => field?.name === fieldName);
if (fieldMetadata?.isSystem) {
return true;
}
return false;
return fieldMetadata?.isSystem ?? false;
}
private createDryRunResponse(
@@ -0,0 +1,90 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util';
describe('mergeFieldValues', () => {
const PRIORITY_RECORD_ID = 'priority-record-id';
const RECORDS_WITH_VALUES = [
{ value: 'value1', recordId: 'record1' },
{ value: 'value2', recordId: PRIORITY_RECORD_ID },
{ value: 'value3', recordId: 'record3' },
];
describe('default field types', () => {
it('should return priority field value for text fields', () => {
const result = mergeFieldValues(
FieldMetadataType.TEXT,
RECORDS_WITH_VALUES,
PRIORITY_RECORD_ID,
);
expect(result).toBe('value2');
});
it('should throw error when priority record is not found', () => {
const recordsWithoutPriorityValue = [
{ value: 'value1', recordId: 'record1' },
{ value: null, recordId: PRIORITY_RECORD_ID },
{ value: 'value3', recordId: 'record3' },
];
expect(() =>
mergeFieldValues(
FieldMetadataType.TEXT,
recordsWithoutPriorityValue,
'non-existent-id',
),
).toThrow('Priority record with ID non-existent-id not found');
});
});
describe('array field types', () => {
it('should merge array values', () => {
const arrayRecords = [
{ value: ['a', 'b'], recordId: 'record1' },
{ value: ['b', 'c'], recordId: PRIORITY_RECORD_ID },
{ value: ['c', 'd'], recordId: 'record3' },
];
const result = mergeFieldValues(
FieldMetadataType.ARRAY,
arrayRecords,
PRIORITY_RECORD_ID,
);
expect(result).toEqual(['a', 'b', 'c', 'd']);
});
});
describe('arrayable field types', () => {
it('should merge emails for EMAILS field', () => {
const emailRecords = [
{
value: {
primaryEmail: 'first@example.com',
additionalEmails: ['extra1@example.com'],
},
recordId: 'record1',
},
{
value: {
primaryEmail: 'priority@example.com',
additionalEmails: ['extra2@example.com'],
},
recordId: PRIORITY_RECORD_ID,
},
];
const result = mergeFieldValues(
FieldMetadataType.EMAILS,
emailRecords,
PRIORITY_RECORD_ID,
);
expect(result).toEqual({
primaryEmail: 'priority@example.com',
additionalEmails: ['extra1@example.com', 'extra2@example.com'],
});
});
});
});
@@ -0,0 +1,111 @@
import { selectPriorityFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util';
describe('selectPriorityFieldValue', () => {
it('should return priority record value when available', () => {
const recordsWithValues = [
{
recordId: '1',
value: 'priority value',
},
{
recordId: '2',
value: 'other value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBe('priority value');
});
it('should throw error when priority record not found', () => {
const recordsWithValues = [
{
recordId: '2',
value: 'first value',
},
{
recordId: '3',
value: 'second value',
},
];
expect(() => selectPriorityFieldValue(recordsWithValues, '1')).toThrow(
'Priority record with ID 1 not found in merge candidates',
);
});
it('should return null when priority record has no value', () => {
const recordsWithValues = [
{
recordId: '1',
value: null,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should return null when priority record has empty string', () => {
const recordsWithValues = [
{
recordId: '1',
value: '',
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should return null when priority record has undefined value', () => {
const recordsWithValues = [
{
recordId: '1',
value: undefined,
},
{
recordId: '2',
value: 'fallback value',
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toBeNull();
});
it('should throw error when no records exist', () => {
expect(() => selectPriorityFieldValue([], '1')).toThrow(
'Priority record with ID 1 not found in merge candidates',
);
});
it('should handle complex object values', () => {
const recordsWithValues = [
{
recordId: '1',
value: { name: 'priority object', id: 1 },
},
{
recordId: '2',
value: { name: 'fallback object', id: 2 },
},
];
const result = selectPriorityFieldValue(recordsWithValues, '1');
expect(result).toEqual({ name: 'priority object', id: 1 });
});
});
@@ -0,0 +1,29 @@
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
type RecordWithValue<T> = { value: T; recordId: string };
export const mergeArrayFieldValues = <T>(
recordsWithValues: RecordWithValue<T>[],
): T[] | null => {
const allValues: T[] = [];
recordsWithValues.forEach((record) => {
if (record.value === null || record.value === undefined) {
return;
}
if (!Array.isArray(record.value)) {
throw new Error(
`Expected array value but received ${typeof record.value}`,
);
}
allValues.push(
...record.value.filter((value) => hasRecordFieldValue(value)),
);
});
const uniqueValues = Array.from(new Set(allValues));
return uniqueValues.length > 0 ? uniqueValues : null;
};
@@ -0,0 +1,53 @@
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import { parseArrayOrJsonStringToArray } from 'src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util';
import { type EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type';
export const mergeEmailsFieldValues = (
recordsWithValues: { value: EmailsMetadata; recordId: string }[],
priorityRecordId: string,
): EmailsMetadata => {
if (recordsWithValues.length === 0) {
return {
primaryEmail: '',
additionalEmails: null,
};
}
let primaryEmail = '';
const priorityRecord = recordsWithValues.find(
(record) => record.recordId === priorityRecordId,
);
if (
priorityRecord &&
hasRecordFieldValue(priorityRecord.value.primaryEmail)
) {
primaryEmail = priorityRecord.value.primaryEmail;
} else {
const fallbackRecord = recordsWithValues.find((record) =>
hasRecordFieldValue(record.value.primaryEmail),
);
primaryEmail = fallbackRecord?.value.primaryEmail || '';
}
const allAdditionalEmails: string[] = [];
recordsWithValues.forEach((record) => {
const additionalEmails = parseArrayOrJsonStringToArray<string>(
record.value.additionalEmails,
);
allAdditionalEmails.push(
...additionalEmails.filter((email) => hasRecordFieldValue(email)),
);
});
const uniqueAdditionalEmails = Array.from(new Set(allAdditionalEmails));
return {
primaryEmail,
additionalEmails:
uniqueAdditionalEmails.length > 0 ? uniqueAdditionalEmails : null,
};
};
@@ -0,0 +1,44 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type';
import { type LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { type PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
import { mergeArrayFieldValues } from './merge-array-field-values.util';
import { mergeEmailsFieldValues } from './merge-emails-field-values.util';
import { mergeLinksFieldValues } from './merge-links-field-values.util';
import { mergePhonesFieldValues } from './merge-phones-field-values.util';
import { selectPriorityFieldValue } from './select-priority-field-value.util';
export const mergeFieldValues = (
fieldType: FieldMetadataType,
recordsWithValues: { value: unknown; recordId: string }[],
priorityRecordId: string,
): unknown => {
switch (fieldType) {
case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT:
return mergeArrayFieldValues(recordsWithValues);
case FieldMetadataType.EMAILS:
return mergeEmailsFieldValues(
recordsWithValues as { value: EmailsMetadata; recordId: string }[],
priorityRecordId,
);
case FieldMetadataType.PHONES:
return mergePhonesFieldValues(
recordsWithValues as { value: PhonesMetadata; recordId: string }[],
priorityRecordId,
);
case FieldMetadataType.LINKS:
return mergeLinksFieldValues(
recordsWithValues as { value: LinksMetadata; recordId: string }[],
priorityRecordId,
);
default:
return selectPriorityFieldValue(recordsWithValues, priorityRecordId);
}
};
@@ -0,0 +1,69 @@
import uniqBy from 'lodash.uniqby';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import {
type LinkMetadata,
type LinksMetadata,
} from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { parseArrayOrJsonStringToArray } from './parse-additional-items.util';
export const mergeLinksFieldValues = (
recordsWithValues: { value: LinksMetadata; recordId: string }[],
priorityRecordId: string,
): LinksMetadata => {
if (recordsWithValues.length === 0) {
return {
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: null,
};
}
let primaryLinkUrl = '';
let primaryLinkLabel = '';
const priorityRecord = recordsWithValues.find(
(record) => record.recordId === priorityRecordId,
);
if (
priorityRecord &&
hasRecordFieldValue(priorityRecord.value.primaryLinkUrl)
) {
primaryLinkUrl = priorityRecord.value.primaryLinkUrl;
primaryLinkLabel = priorityRecord.value.primaryLinkLabel;
} else {
const fallbackRecord = recordsWithValues.find((record) =>
hasRecordFieldValue(record.value.primaryLinkUrl),
);
if (fallbackRecord) {
primaryLinkUrl = fallbackRecord.value.primaryLinkUrl;
primaryLinkLabel = fallbackRecord.value.primaryLinkLabel;
}
}
const allSecondaryLinks: LinkMetadata[] = [];
recordsWithValues.forEach((record) => {
const secondaryLinks = parseArrayOrJsonStringToArray<LinkMetadata>(
record.value.secondaryLinks,
);
allSecondaryLinks.push(
...secondaryLinks.filter((link) => hasRecordFieldValue(link.url)),
);
});
const uniqueSecondaryLinks = uniqBy(allSecondaryLinks, 'url');
const result = {
primaryLinkLabel,
primaryLinkUrl,
secondaryLinks:
uniqueSecondaryLinks.length > 0 ? uniqueSecondaryLinks : null,
};
return result;
};
@@ -0,0 +1,71 @@
import { type CountryCode } from 'libphonenumber-js';
import uniqBy from 'lodash.uniqby';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import {
type AdditionalPhoneMetadata,
type PhonesMetadata,
} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
export const mergePhonesFieldValues = (
recordsWithValues: { value: PhonesMetadata; recordId: string }[],
priorityRecordId: string,
): PhonesMetadata => {
if (recordsWithValues.length === 0) {
return {
primaryPhoneNumber: '',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '',
additionalPhones: null,
};
}
let primaryPhoneNumber = '';
let primaryPhoneCountryCode: CountryCode | null = null;
let primaryPhoneCallingCode = '';
const priorityRecord = recordsWithValues.find(
(record) => record.recordId === priorityRecordId,
);
if (
priorityRecord &&
hasRecordFieldValue(priorityRecord.value.primaryPhoneNumber)
) {
primaryPhoneNumber = priorityRecord.value.primaryPhoneNumber;
primaryPhoneCountryCode = priorityRecord.value.primaryPhoneCountryCode;
primaryPhoneCallingCode = priorityRecord.value.primaryPhoneCallingCode;
} else {
const fallbackRecord = recordsWithValues.find((record) =>
hasRecordFieldValue(record.value.primaryPhoneNumber),
);
if (fallbackRecord) {
primaryPhoneNumber = fallbackRecord.value.primaryPhoneNumber;
primaryPhoneCountryCode = fallbackRecord.value.primaryPhoneCountryCode;
primaryPhoneCallingCode = fallbackRecord.value.primaryPhoneCallingCode;
}
}
const allAdditionalPhones: AdditionalPhoneMetadata[] = [];
recordsWithValues.forEach((record) => {
if (Array.isArray(record.value.additionalPhones)) {
allAdditionalPhones.push(
...record.value.additionalPhones.filter((phone) =>
hasRecordFieldValue(phone.number),
),
);
}
});
const uniqueAdditionalPhones = uniqBy(allAdditionalPhones, 'number');
return {
primaryPhoneNumber,
primaryPhoneCountryCode: primaryPhoneCountryCode!,
primaryPhoneCallingCode,
additionalPhones:
uniqueAdditionalPhones.length > 0 ? uniqueAdditionalPhones : null,
};
};
@@ -0,0 +1,19 @@
export const parseArrayOrJsonStringToArray = <T>(
value: T[] | string | null | undefined,
): T[] => {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
@@ -0,0 +1,24 @@
import { isDefined } from 'twenty-shared/utils';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
export const selectPriorityFieldValue = <T>(
recordsWithValues: { value: T; recordId: string }[],
priorityRecordId: string,
): T | null => {
const priorityRecord = recordsWithValues.find(
(record) => record.recordId === priorityRecordId,
);
if (!isDefined(priorityRecord)) {
throw new Error(
`Priority record with ID ${priorityRecordId} not found in merge candidates`,
);
}
if (hasRecordFieldValue(priorityRecord.value)) {
return priorityRecord.value;
}
return null;
};