Fix phone unique constraints (#20261)

## Summary

Closes #20195

Fix phone field unique constraints so phone numbers are considered
unique by both `primaryPhoneNumber` and `primaryPhoneCallingCode`.

- Include `primaryPhoneCallingCode` in the shared phone composite unique
constraint metadata
- Align the frontend settings composite field config with the backend
metadata
- Return all included unique composite subfields when building
create-many conflict fields
- Match composite unique conflict fields as a group during create-many
upserts

## Root Cause

Phone composite metadata only marked `primaryPhoneNumber` as part of the
unique constraint. That made different international phone numbers with
the same national number conflict, for example `+1 123456789` and `+32
123456789`.

## Test Plan

- `yarn workspace twenty-shared build`
- `jest --runTestsByPath <index action handler and create-many utility
specs>`
- `prettier --check <touched files>`
- `oxlint --type-aware <touched files>`
- `nx run twenty-shared:typecheck`
- `nx run twenty-server:typecheck`
- `nx run twenty-front:typecheck`

---------

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
MkDev11
2026-05-13 04:57:19 -07:00
committed by GitHub
parent ac653182b2
commit bbc55193f5
58 changed files with 1669 additions and 286 deletions
@@ -1,12 +1,9 @@
import { isNull } from '@sniptt/guards';
import { isNull, isString } from '@sniptt/guards';
import { DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE } from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
export const isNullEquivalentTextFieldValue = (value: unknown): boolean => {
if (isNull(value)) return true;
return (
typeof value === 'string' &&
value === DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
);
return isString(value) && value === DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE;
};
@@ -8,6 +8,7 @@ import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm';
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 { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.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';
@@ -231,7 +232,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CreateManyQueryArgs;
selectedFieldsResult: CommonSelectedFieldsResult;
}): Promise<InsertResult> {
const conflictingFields = getConflictingFields(
const conflictingFieldGroups = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
);
@@ -240,12 +241,12 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatFieldMetadataMaps,
args,
conflictingFields,
conflictingFieldGroups,
});
const { recordsToUpdate, recordsToInsert } = categorizeRecords(
args.data,
conflictingFields,
conflictingFieldGroups,
existingRecords,
);
@@ -289,23 +290,22 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatFieldMetadataMaps,
args,
conflictingFields,
conflictingFieldGroups,
}: {
repository: WorkspaceRepository<ObjectLiteral>;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
args: CreateManyQueryArgs;
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[];
conflictingFieldGroups: ConflictingFieldGroup[];
}): Promise<PartialObjectRecordWithId[]> {
const queryBuilder = repository.createQueryBuilder(
flatObjectMetadata.nameSingular,
);
const whereConditions = buildWhereConditions(args.data, conflictingFields);
const whereConditions = buildWhereConditions(
args.data,
conflictingFieldGroups,
);
if (whereConditions.length === 0) {
return [];
@@ -0,0 +1,9 @@
export type ConflictingProperty = {
fullPath: string;
column: string;
};
export type ConflictingFieldGroup = {
baseField: string;
conflictingProperties: ConflictingProperty[];
};
@@ -1,5 +1,6 @@
import { type ObjectRecord } from 'twenty-shared/types';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
describe('buildWhereConditions', () => {
@@ -28,9 +29,16 @@ describe('buildWhereConditions', () => {
});
it('builds a single where condition for a flat field using all defined values', () => {
const where = buildWhereConditions(records, [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
]);
const groups: ConflictingFieldGroup[] = [
{
baseField: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
];
const where = buildWhereConditions(records, groups);
expect(where).toHaveLength(1);
const condition = where[0];
@@ -44,28 +52,34 @@ describe('buildWhereConditions', () => {
});
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',
},
],
);
const groups: ConflictingFieldGroup[] = [
{
baseField: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
];
const where = buildWhereConditions([{ id: '1' }, { id: '2' }], groups);
expect(where).toEqual([]);
});
it('builds conditions for nested paths', () => {
const where = buildWhereConditions(records, [
const groups: ConflictingFieldGroup[] = [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
]);
];
const where = buildWhereConditions(records, groups);
expect(where).toHaveLength(1);
const condition = where[0];
@@ -79,14 +93,25 @@ describe('buildWhereConditions', () => {
});
it('builds multiple conditions when multiple conflicting fields are provided', () => {
const where = buildWhereConditions(records, [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
const groups: ConflictingFieldGroup[] = [
{
baseField: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
]);
];
const where = buildWhereConditions(records, groups);
expect(where).toHaveLength(2);
@@ -1,20 +1,27 @@
import { type ObjectRecord } from 'twenty-shared/types';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
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' },
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
baseField: 'uniqueText',
fullPath: 'uniqueText',
column: 'uniqueText',
conflictingProperties: [{ fullPath: 'uniqueText', column: 'uniqueText' }],
},
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
];
@@ -39,7 +46,7 @@ describe('categorizeRecords', () => {
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
conflictingFieldGroups,
existingRecords,
);
@@ -56,7 +63,7 @@ describe('categorizeRecords', () => {
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
conflictingFieldGroups,
existingRecords,
);
@@ -88,7 +95,7 @@ describe('categorizeRecords', () => {
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
records,
conflictingFields,
conflictingFieldGroups,
existingRecords,
);
@@ -54,6 +54,13 @@ describe('getConflictingFields', () => {
isUnique: true,
});
const phonesUniqueField = createMockField({
id: 'phones-unique-id',
name: 'phonesField',
type: FieldMetadataType.PHONES,
isUnique: true,
});
const phonesNotUniqueField = createMockField({
id: 'phones-not-unique-id',
name: 'phonesField',
@@ -125,11 +132,15 @@ describe('getConflictingFields', () => {
expect(result).toEqual(
expect.arrayContaining([
{ baseField: 'id', fullPath: 'id', column: 'id' },
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
baseField: 'uniqueText',
fullPath: 'uniqueText',
column: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
]),
);
@@ -147,16 +158,58 @@ describe('getConflictingFields', () => {
expect(result).toEqual(
expect.arrayContaining([
{ baseField: 'id', fullPath: 'id', column: 'id' },
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
]),
);
});
it('returns every included unique property for phone composite fields', () => {
const fields = [idField, phonesUniqueField];
const flatObjectMetadata = buildFlatObjectMetadata(fields);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
);
expect(result).toEqual([
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
baseField: 'phonesField',
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
column: 'phonesFieldPrimaryPhoneNumber',
},
{
fullPath: 'phonesField.primaryPhoneCountryCode',
column: 'phonesFieldPrimaryPhoneCountryCode',
},
{
fullPath: 'phonesField.primaryPhoneCallingCode',
column: 'phonesFieldPrimaryPhoneCallingCode',
},
],
},
]);
});
it('does not include composite fields without included unique property', () => {
const fields = [idField, addressUniqueFieldNoIncludedProp];
const flatObjectMetadata = buildFlatObjectMetadata(fields);
@@ -167,7 +220,12 @@ describe('getConflictingFields', () => {
flatFieldMetadataMaps,
);
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
expect(result).toEqual([
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
]);
});
it('ignores non-unique fields', () => {
@@ -180,6 +238,11 @@ describe('getConflictingFields', () => {
flatFieldMetadataMaps,
);
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
expect(result).toEqual([
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
]);
});
});
@@ -1,3 +1,4 @@
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
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';
@@ -8,11 +9,19 @@ describe('getMatchingRecordId', () => {
id: 'recordId1',
uniqueText: 'alpha',
emailsField: { primaryEmail: 'alpha@example.com' },
phonesField: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+1',
},
},
{
id: 'recordId2',
uniqueText: 'beta',
emailsField: { primaryEmail: 'beta@example.com' },
phonesField: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+32',
},
},
];
@@ -21,33 +30,115 @@ describe('getMatchingRecordId', () => {
emailsField: { primaryEmail: 'alpha@example.com' },
};
const conflictingFields = [
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
const id = getMatchingRecordId(
record,
conflictingFieldGroups,
existingRecords,
);
expect(id).toBe('recordId1');
});
it('returns the matching record id when every composite unique field matches the same existing record', () => {
const record = {
phonesField: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+32',
},
};
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'phonesField',
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
column: 'phonesFieldPrimaryPhoneNumber',
},
{
fullPath: 'phonesField.primaryPhoneCallingCode',
column: 'phonesFieldPrimaryPhoneCallingCode',
},
],
},
];
const id = getMatchingRecordId(
record,
conflictingFieldGroups,
existingRecords,
);
expect(id).toBe('recordId2');
});
it('returns undefined when only part of a composite unique field matches', () => {
const record = {
phonesField: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
},
};
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'phonesField',
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
column: 'phonesFieldPrimaryPhoneNumber',
},
{
fullPath: 'phonesField.primaryPhoneCallingCode',
column: 'phonesFieldPrimaryPhoneCallingCode',
},
],
},
];
const id = getMatchingRecordId(
record,
conflictingFieldGroups,
existingRecords,
);
expect(id).toBeUndefined();
});
it('returns undefined when no existing record matches any conflicting field', () => {
const record = {
emailsField: { primaryEmail: 'nobody@example.com' },
};
const conflictingFields = [
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
const id = getMatchingRecordId(
record,
conflictingFieldGroups,
existingRecords,
);
expect(id).toBeUndefined();
});
@@ -58,12 +149,24 @@ describe('getMatchingRecordId', () => {
uniqueText: 'alpha',
};
const conflictingFields = [
{ baseField: 'id', fullPath: 'id', column: 'id' },
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'id',
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
baseField: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
];
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
const id = getMatchingRecordId(
record,
conflictingFieldGroups,
existingRecords,
);
expect(id).toBe('recordId1');
});
@@ -74,21 +177,30 @@ describe('getMatchingRecordId', () => {
emailsField: { primaryEmail: 'beta@example.com' },
};
const conflictingFields = [
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
baseField: 'uniqueText',
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
{
baseField: 'emailsField',
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
column: 'emailsFieldPrimaryEmail',
},
],
},
];
expect(() =>
getMatchingRecordId(record, conflictingFields, existingRecords),
getMatchingRecordId(record, conflictingFieldGroups, existingRecords),
).toThrow();
try {
getMatchingRecordId(record, conflictingFields, existingRecords);
getMatchingRecordId(record, conflictingFieldGroups, existingRecords);
} catch (error) {
expect(error.code).toBe(
CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
@@ -2,25 +2,26 @@ import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FindOperator, In } from 'typeorm';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
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;
}[],
conflictingFieldGroups: ConflictingFieldGroup[],
): Record<string, FindOperator<string>>[] => {
const whereConditions: Record<string, FindOperator<string>>[] = [];
for (const field of conflictingFields) {
for (const conflictingProperty of conflictingFieldGroups.flatMap(
(group) => group.conflictingProperties,
)) {
const fieldValues = records
.map((record) => getValueFromPath(record, field.fullPath))
.map((record) => getValueFromPath(record, conflictingProperty.fullPath))
.filter(isDefined);
if (fieldValues.length > 0) {
whereConditions.push({ [field.column]: In(fieldValues) });
whereConditions.push({
[conflictingProperty.column]: In(fieldValues),
});
}
}
@@ -1,16 +1,13 @@
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
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;
}[],
conflictingFieldGroups: ConflictingFieldGroup[],
existingRecords: PartialObjectRecordWithId[],
): {
recordsToUpdate: PartialObjectRecordWithId[];
@@ -22,7 +19,7 @@ export const categorizeRecords = (
for (const record of records) {
const matchingRecordId = getMatchingRecordId(
record,
conflictingFields,
conflictingFieldGroups,
existingRecords,
);
@@ -1,6 +1,7 @@
import { compositeTypeDefinitions } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -9,41 +10,30 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
export const getConflictingFields = (
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): {
baseField: string;
fullPath: string;
column: string;
}[] => {
): ConflictingFieldGroup[] => {
return getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
)
.filter((field) => field.isUnique || field.name === 'id')
.flatMap((field) => {
.map((field) => {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
return [
{
baseField: field.name,
fullPath: field.name,
column: field.name,
},
];
return {
baseField: field.name,
conflictingProperties: [{ fullPath: field.name, column: field.name }],
};
}
const property = compositeType.properties.find(
(prop) => prop.isIncludedInUniqueConstraint,
);
const conflictingProperties = compositeType.properties
.filter((prop) => prop.isIncludedInUniqueConstraint)
.map((property) => ({
fullPath: `${field.name}.${property.name}`,
column: `${field.name}${capitalize(property.name)}`,
}));
return property
? [
{
baseField: field.name,
fullPath: `${field.name}.${property.name}`,
column: `${field.name}${capitalize(property.name)}`,
},
]
: [];
});
return { baseField: field.name, conflictingProperties };
})
.filter((group) => group.conflictingProperties.length > 0);
};
@@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
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 {
@@ -11,41 +12,51 @@ import {
export const getMatchingRecordId = (
record: Partial<ObjectRecord>,
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
conflictingFieldGroups: ConflictingFieldGroup[],
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,
const matchingRecordIds = conflictingFieldGroups.reduce<string[]>(
(acc, fieldGroup) => {
const requestFieldValues = fieldGroup.conflictingProperties.map(
(conflictingProperty) => ({
conflictingProperty,
value: getValueFromPath(record, conflictingProperty.fullPath),
}),
);
return (
isDefined(existingFieldValue) &&
existingFieldValue === requestFieldValue
if (requestFieldValues.some(({ value }) => !isDefined(value))) {
return acc;
}
const matchingRecord = existingRecords.find((existingRecord) =>
requestFieldValues.every(({ conflictingProperty, value }) => {
const existingFieldValue = getValueFromPath(
existingRecord,
conflictingProperty.fullPath,
);
return isDefined(existingFieldValue) && existingFieldValue === value;
}),
);
});
if (isDefined(matchingRecord)) {
acc.push(matchingRecord.id);
}
if (isDefined(matchingRecord)) {
acc.push(matchingRecord.id);
}
return acc;
}, []);
return acc;
},
[],
);
if ([...new Set(matchingRecordIds)].length > 1) {
const conflictingFieldsValues = conflictingFields
.map((field) => {
const value = getValueFromPath(record, field.fullPath);
const conflictingFieldsValues = conflictingFieldGroups
.flatMap((group) => group.conflictingProperties)
.map((conflictingProperty) => {
const value = getValueFromPath(record, conflictingProperty.fullPath);
return isDefined(value) ? `${field.fullPath}: ${value}` : undefined;
return isDefined(value)
? `${conflictingProperty.fullPath}: ${value}`
: undefined;
})
.filter(isDefined)
.join(', ');
@@ -1,61 +0,0 @@
import {
FieldActorSource,
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
export function deprecatedGenerateDefaultValue(
type: FieldMetadataType,
): FieldMetadataDefaultValue {
switch (type) {
case FieldMetadataType.TEXT:
return "''" satisfies FieldMetadataDefaultValue<FieldMetadataType.TEXT>;
case FieldMetadataType.EMAILS:
return {
primaryEmail: "''",
additionalEmails: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.EMAILS>;
case FieldMetadataType.FULL_NAME:
return {
firstName: "''",
lastName: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.FULL_NAME>;
case FieldMetadataType.ADDRESS:
return {
addressStreet1: "''",
addressStreet2: "''",
addressCity: "''",
addressState: "''",
addressCountry: "''",
addressPostcode: "''",
addressLat: null,
addressLng: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.ADDRESS>;
case FieldMetadataType.CURRENCY:
return {
amountMicros: null,
currencyCode: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.CURRENCY>;
case FieldMetadataType.LINKS:
return {
primaryLinkLabel: "''",
primaryLinkUrl: "''",
secondaryLinks: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.LINKS>;
case FieldMetadataType.PHONES:
return {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: "''",
additionalPhones: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.PHONES>;
case FieldMetadataType.ACTOR:
return {
source: `'${FieldActorSource.MANUAL}'`,
name: "'System'",
workspaceMemberId: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.ACTOR>;
default:
return null;
}
}
@@ -0,0 +1,30 @@
import { nullifyEmptyActorDefaultValue } from '../nullify-empty-actor-default-value.util';
describe('nullifyEmptyActorDefaultValue', () => {
it('returns null when all sub-fields are null or empty-string equivalents', () => {
expect(
nullifyEmptyActorDefaultValue({
source: null,
workspaceMemberId: null,
name: "''",
context: null,
}),
).toBeNull();
});
it('returns normalized object when source has a value', () => {
expect(
nullifyEmptyActorDefaultValue({
source: 'MANUAL',
workspaceMemberId: null,
name: "''",
context: null,
}),
).toEqual({
source: 'MANUAL',
workspaceMemberId: null,
name: null,
context: null,
});
});
});
@@ -0,0 +1,66 @@
import { nullifyEmptyAddressDefaultValue } from '../nullify-empty-address-default-value.util';
describe('nullifyEmptyAddressDefaultValue', () => {
it('returns null when all sub-fields are empty-string equivalents or null', () => {
expect(
nullifyEmptyAddressDefaultValue({
addressStreet1: "''",
addressStreet2: '',
addressCity: '',
addressState: null,
addressCountry: null,
addressPostcode: null,
addressLat: null,
addressLng: null,
}),
).toBeNull();
});
it('returns normalized object when addressCity has a value', () => {
expect(
nullifyEmptyAddressDefaultValue({
addressStreet1: "''",
addressStreet2: null,
addressCity: 'Paris',
addressState: '',
addressCountry: null,
addressPostcode: null,
addressLat: null,
addressLng: null,
}),
).toEqual({
addressStreet1: null,
addressStreet2: null,
addressCity: 'Paris',
addressState: null,
addressCountry: null,
addressPostcode: null,
addressLat: null,
addressLng: null,
});
});
it('returns object when only numeric coords are set', () => {
expect(
nullifyEmptyAddressDefaultValue({
addressStreet1: null,
addressStreet2: null,
addressCity: null,
addressState: null,
addressCountry: null,
addressPostcode: null,
addressLat: 48.8566,
addressLng: 2.3522,
}),
).toEqual({
addressStreet1: null,
addressStreet2: null,
addressCity: null,
addressState: null,
addressCountry: null,
addressPostcode: null,
addressLat: 48.8566,
addressLng: 2.3522,
});
});
});
@@ -0,0 +1,30 @@
import { nullifyEmptyCurrencyDefaultValue } from '../nullify-empty-currency-default-value.util';
describe('nullifyEmptyCurrencyDefaultValue', () => {
it('returns null when both sub-fields are null or empty-string equivalent', () => {
expect(
nullifyEmptyCurrencyDefaultValue({
amountMicros: null,
currencyCode: "''",
}),
).toBeNull();
});
it('returns normalized object when amountMicros has a value', () => {
expect(
nullifyEmptyCurrencyDefaultValue({
amountMicros: 5000000,
currencyCode: "''",
}),
).toEqual({ amountMicros: 5000000, currencyCode: null });
});
it('returns normalized object when currencyCode has a value', () => {
expect(
nullifyEmptyCurrencyDefaultValue({
amountMicros: null,
currencyCode: 'EUR',
}),
).toEqual({ amountMicros: null, currencyCode: 'EUR' });
});
});
@@ -0,0 +1,21 @@
import { nullifyEmptyEmailsDefaultValue } from '../nullify-empty-emails-default-value.util';
describe('nullifyEmptyEmailsDefaultValue', () => {
it('returns null when all sub-fields are empty-string equivalents', () => {
expect(
nullifyEmptyEmailsDefaultValue({
primaryEmail: "''",
additionalEmails: [],
}),
).toBeNull();
});
it('returns normalized object when primaryEmail has a value', () => {
expect(
nullifyEmptyEmailsDefaultValue({
primaryEmail: 'user@example.com',
additionalEmails: [],
}),
).toEqual({ primaryEmail: 'user@example.com', additionalEmails: null });
});
});
@@ -0,0 +1,15 @@
import { nullifyEmptyFullNameDefaultValue } from '../nullify-empty-full-name-default-value.util';
describe('nullifyEmptyFullNameDefaultValue', () => {
it('returns null when both sub-fields are empty-string equivalents', () => {
expect(
nullifyEmptyFullNameDefaultValue({ firstName: "''", lastName: '' }),
).toBeNull();
});
it('returns normalized object when lastName has a value', () => {
expect(
nullifyEmptyFullNameDefaultValue({ firstName: "''", lastName: 'Doe' }),
).toEqual({ firstName: null, lastName: 'Doe' });
});
});
@@ -0,0 +1,27 @@
import { nullifyEmptyLinksDefaultValue } from '../nullify-empty-links-default-value.util';
describe('nullifyEmptyLinksDefaultValue', () => {
it('returns null when all sub-fields are empty-string equivalents', () => {
expect(
nullifyEmptyLinksDefaultValue({
primaryLinkLabel: '',
primaryLinkUrl: "''",
secondaryLinks: null,
}),
).toBeNull();
});
it('returns normalized object when primaryLinkUrl has a value', () => {
expect(
nullifyEmptyLinksDefaultValue({
primaryLinkLabel: "''",
primaryLinkUrl: 'https://twenty.com',
secondaryLinks: null,
}),
).toEqual({
primaryLinkLabel: null,
primaryLinkUrl: 'https://twenty.com',
secondaryLinks: null,
});
});
});
@@ -0,0 +1,30 @@
import { nullifyEmptyPhonesDefaultValue } from '../nullify-empty-phones-default-value.util';
describe('nullifyEmptyPhonesDefaultValue', () => {
it('returns null when all fields are null-equivalent', () => {
expect(
nullifyEmptyPhonesDefaultValue({
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: null,
additionalPhones: null,
}),
).toBeNull();
});
it('returns normalized object when primaryPhoneNumber has a value', () => {
expect(
nullifyEmptyPhonesDefaultValue({
primaryPhoneNumber: '+33612345678',
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: '',
additionalPhones: null,
}),
).toEqual({
primaryPhoneNumber: '+33612345678',
primaryPhoneCountryCode: null,
primaryPhoneCallingCode: null,
additionalPhones: null,
});
});
});
@@ -0,0 +1,15 @@
import { nullifyEmptyRichTextDefaultValue } from '../nullify-empty-rich-text-default-value.util';
describe('nullifyEmptyRichTextDefaultValue', () => {
it('returns null when both sub-fields are empty-string equivalents', () => {
expect(
nullifyEmptyRichTextDefaultValue({ blocknote: "''", markdown: '' }),
).toBeNull();
});
it('returns normalized object when blocknote has a value', () => {
expect(
nullifyEmptyRichTextDefaultValue({ blocknote: '[]', markdown: "''" }),
).toEqual({ blocknote: '[]', markdown: null });
});
});
@@ -5,6 +5,8 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
type GetDefaultFlatFieldMetadataArgs = {
@@ -23,6 +25,8 @@ export const getDefaultFlatFieldMetadata = ({
);
const createdAt = new Date().toISOString();
const resolvedDefaultValue =
defaultValue ?? generateDefaultValue(createFieldInput.type);
return {
description: createFieldInput.description ?? null,
@@ -42,7 +46,12 @@ export const getDefaultFlatFieldMetadata = ({
type: createFieldInput.type,
universalIdentifier: createFieldInput.universalIdentifier ?? v4(),
options: createFieldInput.options ?? null,
defaultValue: defaultValue ?? generateDefaultValue(createFieldInput.type),
defaultValue: isCompositeFieldMetadataType(createFieldInput.type)
? nullifyEmptyCompositeDefaultValue({
defaultValue: resolvedDefaultValue,
fieldType: createFieldInput.type,
})
: resolvedDefaultValue,
createdAt,
updatedAt: createdAt,
isUIReadOnly: createFieldInput.isUIReadOnly ?? false,
@@ -0,0 +1,2 @@
export const isNullEquivalentTextDefaultValue = (value: unknown): boolean =>
value === "''" || value === '';
@@ -0,0 +1,37 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyActorDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
source?: string | null;
workspaceMemberId?: string | null;
name?: string | null;
context?: object | null;
};
const source = v.source ?? null;
const workspaceMemberId = v.workspaceMemberId ?? null;
const name = isNullEquivalentTextDefaultValue(v.name)
? null
: (v.name ?? null);
const context = v.context ?? null;
if (
source === null &&
workspaceMemberId === null &&
name === null &&
context === null
) {
return null;
}
return { source, workspaceMemberId, name, context };
};
@@ -0,0 +1,68 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyAddressDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
addressStreet1?: string | null;
addressStreet2?: string | null;
addressCity?: string | null;
addressState?: string | null;
addressCountry?: string | null;
addressPostcode?: string | null;
addressLat?: number | null;
addressLng?: number | null;
};
const addressStreet1 = isNullEquivalentTextDefaultValue(v.addressStreet1)
? null
: (v.addressStreet1 ?? null);
const addressStreet2 = isNullEquivalentTextDefaultValue(v.addressStreet2)
? null
: (v.addressStreet2 ?? null);
const addressCity = isNullEquivalentTextDefaultValue(v.addressCity)
? null
: (v.addressCity ?? null);
const addressState = isNullEquivalentTextDefaultValue(v.addressState)
? null
: (v.addressState ?? null);
const addressCountry = isNullEquivalentTextDefaultValue(v.addressCountry)
? null
: (v.addressCountry ?? null);
const addressPostcode = isNullEquivalentTextDefaultValue(v.addressPostcode)
? null
: (v.addressPostcode ?? null);
const addressLat = v.addressLat ?? null;
const addressLng = v.addressLng ?? null;
if (
addressStreet1 === null &&
addressStreet2 === null &&
addressCity === null &&
addressState === null &&
addressCountry === null &&
addressPostcode === null &&
addressLat === null &&
addressLng === null
) {
return null;
}
return {
addressStreet1,
addressStreet2,
addressCity,
addressState,
addressCountry,
addressPostcode,
addressLat,
addressLng,
};
};
@@ -0,0 +1,45 @@
import {
FieldMetadataType,
type FieldMetadataDefaultValueForAnyType,
} from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
import { nullifyEmptyActorDefaultValue } from './nullify-empty-actor-default-value.util';
import { nullifyEmptyAddressDefaultValue } from './nullify-empty-address-default-value.util';
import { nullifyEmptyCurrencyDefaultValue } from './nullify-empty-currency-default-value.util';
import { nullifyEmptyEmailsDefaultValue } from './nullify-empty-emails-default-value.util';
import { nullifyEmptyFullNameDefaultValue } from './nullify-empty-full-name-default-value.util';
import { nullifyEmptyLinksDefaultValue } from './nullify-empty-links-default-value.util';
import { nullifyEmptyPhonesDefaultValue } from './nullify-empty-phones-default-value.util';
import { nullifyEmptyRichTextDefaultValue } from './nullify-empty-rich-text-default-value.util';
export const nullifyEmptyCompositeDefaultValue = ({
defaultValue,
fieldType,
}: {
defaultValue: FieldMetadataDefaultValueForAnyType;
fieldType: CompositeFieldMetadataType;
}): FieldMetadataDefaultValueForAnyType => {
switch (fieldType) {
case FieldMetadataType.PHONES:
return nullifyEmptyPhonesDefaultValue(defaultValue);
case FieldMetadataType.EMAILS:
return nullifyEmptyEmailsDefaultValue(defaultValue);
case FieldMetadataType.LINKS:
return nullifyEmptyLinksDefaultValue(defaultValue);
case FieldMetadataType.ADDRESS:
return nullifyEmptyAddressDefaultValue(defaultValue);
case FieldMetadataType.FULL_NAME:
return nullifyEmptyFullNameDefaultValue(defaultValue);
case FieldMetadataType.ACTOR:
return nullifyEmptyActorDefaultValue(defaultValue);
case FieldMetadataType.CURRENCY:
return nullifyEmptyCurrencyDefaultValue(defaultValue);
case FieldMetadataType.RICH_TEXT:
return nullifyEmptyRichTextDefaultValue(defaultValue);
default:
assertUnreachable(fieldType);
}
};
@@ -0,0 +1,28 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyCurrencyDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
amountMicros?: number | null;
currencyCode?: string | null;
};
const amountMicros = v.amountMicros ?? null;
const currencyCode = isNullEquivalentTextDefaultValue(v.currencyCode)
? null
: (v.currencyCode ?? null);
if (amountMicros === null && currencyCode === null) {
return null;
}
return { amountMicros, currencyCode };
};
@@ -0,0 +1,32 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyEmailsDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
primaryEmail?: string | null;
additionalEmails?: object | null;
};
const primaryEmail = isNullEquivalentTextDefaultValue(v.primaryEmail)
? null
: (v.primaryEmail ?? null);
const additionalEmails = isNullEquivalentArrayFieldValue(v.additionalEmails)
? null
: (v.additionalEmails ?? null);
if (primaryEmail === null && additionalEmails === null) {
return null;
}
return { primaryEmail, additionalEmails };
};
@@ -0,0 +1,30 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyFullNameDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
firstName?: string | null;
lastName?: string | null;
};
const firstName = isNullEquivalentTextDefaultValue(v.firstName)
? null
: (v.firstName ?? null);
const lastName = isNullEquivalentTextDefaultValue(v.lastName)
? null
: (v.lastName ?? null);
if (firstName === null && lastName === null) {
return null;
}
return { firstName, lastName };
};
@@ -0,0 +1,40 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyLinksDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
primaryLinkLabel?: string | null;
primaryLinkUrl?: string | null;
secondaryLinks?: object | null;
};
const primaryLinkLabel = isNullEquivalentTextDefaultValue(v.primaryLinkLabel)
? null
: (v.primaryLinkLabel ?? null);
const primaryLinkUrl = isNullEquivalentTextDefaultValue(v.primaryLinkUrl)
? null
: (v.primaryLinkUrl ?? null);
const secondaryLinks = isNullEquivalentArrayFieldValue(v.secondaryLinks)
? null
: (v.secondaryLinks ?? null);
if (
primaryLinkLabel === null &&
primaryLinkUrl === null &&
secondaryLinks === null
) {
return null;
}
return { primaryLinkLabel, primaryLinkUrl, secondaryLinks };
};
@@ -0,0 +1,56 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyPhonesDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
primaryPhoneNumber?: string | null;
primaryPhoneCountryCode?: string | null;
primaryPhoneCallingCode?: string | null;
additionalPhones?: object | null;
};
const primaryPhoneNumber = isNullEquivalentTextDefaultValue(
v.primaryPhoneNumber,
)
? null
: (v.primaryPhoneNumber ?? null);
const primaryPhoneCountryCode = isNullEquivalentTextDefaultValue(
v.primaryPhoneCountryCode,
)
? null
: (v.primaryPhoneCountryCode ?? null);
const primaryPhoneCallingCode = isNullEquivalentTextDefaultValue(
v.primaryPhoneCallingCode,
)
? null
: (v.primaryPhoneCallingCode ?? null);
const additionalPhones = isNullEquivalentArrayFieldValue(v.additionalPhones)
? null
: (v.additionalPhones ?? null);
if (
primaryPhoneNumber === null &&
primaryPhoneCountryCode === null &&
primaryPhoneCallingCode === null &&
additionalPhones === null
) {
return null;
}
return {
primaryPhoneNumber,
primaryPhoneCountryCode,
primaryPhoneCallingCode,
additionalPhones,
};
};
@@ -0,0 +1,30 @@
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util';
export const nullifyEmptyRichTextDefaultValue = (
defaultValue: FieldMetadataDefaultValueForAnyType,
): FieldMetadataDefaultValueForAnyType => {
if (!isDefined(defaultValue)) {
return null;
}
const v = defaultValue as {
blocknote?: string | null;
markdown?: string | null;
};
const blocknote = isNullEquivalentTextDefaultValue(v.blocknote)
? null
: (v.blocknote ?? null);
const markdown = isNullEquivalentTextDefaultValue(v.markdown)
? null
: (v.markdown ?? null);
if (blocknote === null && markdown === null) {
return null;
}
return { blocknote, markdown };
};
@@ -13,6 +13,8 @@ import {
import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant';
import { type FlatFieldMetadataEditableProperties } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util';
type SanitizeRawUpdateFieldInputArgs = {
@@ -45,6 +47,17 @@ export const sanitizeRawUpdateFieldInput = ({
...option,
}));
if (
updatedEditableFieldProperties.defaultValue !== undefined &&
isCompositeFieldMetadataType(existingFlatFieldMetadata.type)
) {
updatedEditableFieldProperties.defaultValue =
nullifyEmptyCompositeDefaultValue({
defaultValue: updatedEditableFieldProperties.defaultValue,
fieldType: existingFlatFieldMetadata.type,
});
}
if (!isStandardField || isSystemBuild) {
return {
updatedEditableFieldProperties,
@@ -19,6 +19,7 @@ export type EntitySchemaFieldMetadata<
| 'type'
| 'settings'
| 'isNullable'
| 'isUnique'
| 'defaultValue'
| 'options'
| 'objectMetadataId'
@@ -73,6 +74,7 @@ export const buildEntitySchemaMetadataMaps = (
type: field.type,
settings: field.settings,
isNullable: field.isNullable,
isUnique: field.isUnique,
defaultValue: field.defaultValue,
options: field.options,
objectMetadataId: field.objectMetadataId,
@@ -0,0 +1,42 @@
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
import {
type CompositeProperty,
type FieldMetadataDefaultValueForAnyType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const isCompositeFieldDefaultValueCompatibleWithUniqueIndex = ({
fieldType,
compositeProperties,
defaultValue,
}: {
fieldType: CompositeFieldMetadataType;
compositeProperties: CompositeProperty[];
defaultValue?: FieldMetadataDefaultValueForAnyType;
}) => {
if (!isDefined(defaultValue)) {
return true;
}
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
defaultValue,
fieldType,
});
if (!isDefined(normalizedDefaultValue)) {
return true;
}
const uniqueCompositeProperties = compositeProperties.filter(
(property) => property.isIncludedInUniqueConstraint === true,
);
return uniqueCompositeProperties.some((compositeProperty) => {
return !isDefined(
normalizedDefaultValue[
compositeProperty.name as keyof typeof normalizedDefaultValue
],
);
});
};
@@ -15,6 +15,8 @@ import { IndexExceptionCode } from 'src/engine/metadata-modules/flat-index-metad
import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
import { isCompositeFieldDefaultValueCompatibleWithUniqueIndex } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util';
@Injectable()
export class FlatIndexValidatorService {
@@ -147,8 +149,23 @@ export class FlatIndexValidatorService {
}
if (flatIndexToValidate.isUnique) {
const compositeType = isCompositeUniversalFlatFieldMetadata(
relatedFlatField,
)
? compositeTypeDefinitions.get(relatedFlatField.type)
: undefined;
const canUseDefaultValueInUniqueIndex = isDefined(compositeType)
? isCompositeFieldDefaultValueCompatibleWithUniqueIndex({
fieldType:
relatedFlatField.type as CompositeFieldMetadataType,
compositeProperties: compositeType.properties,
defaultValue: relatedFlatField.defaultValue,
})
: !isDefined(relatedFlatField.defaultValue);
if (
isDefined(relatedFlatField.defaultValue) &&
!canUseDefaultValueInUniqueIndex &&
relatedFlatField.isUnique
) {
const fieldName = relatedFlatField.name;
@@ -163,11 +180,9 @@ export class FlatIndexValidatorService {
const isCompositeFieldWithNonIncludedUniqueConstraint =
isCompositeUniversalFlatFieldMetadata(relatedFlatField) &&
!compositeTypeDefinitions
.get(relatedFlatField.type)
?.properties.some(
(property) => property.isIncludedInUniqueConstraint,
);
!compositeType?.properties.some(
(property) => property.isIncludedInUniqueConstraint,
);
if (
[
@@ -0,0 +1,44 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatIndexFieldMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { computeFlatIndexFieldColumnNames } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
describe('computeFlatIndexFieldColumnNames', () => {
const phoneFieldMetadataId = 'phone-field-metadata-id';
const phoneFieldUniversalIdentifier = 'phone-field-universal-identifier';
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
[phoneFieldUniversalIdentifier]: {
id: phoneFieldMetadataId,
universalIdentifier: phoneFieldUniversalIdentifier,
name: 'phone',
type: FieldMetadataType.PHONES,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[phoneFieldMetadataId]: phoneFieldUniversalIdentifier,
},
universalIdentifiersByApplicationId: {},
} as MetadataFlatEntityMaps<'fieldMetadata'>;
it('returns every unique subfield column for phone composite fields', () => {
const flatIndexFieldMetadatas = [
{
fieldMetadataId: phoneFieldMetadataId,
} as FlatIndexFieldMetadata,
];
expect(
computeFlatIndexFieldColumnNames({
flatIndexFieldMetadatas,
flatFieldMetadataMaps,
}),
).toEqual([
'phonePrimaryPhoneNumber',
'phonePrimaryPhoneCountryCode',
'phonePrimaryPhoneCallingCode',
]);
});
});
@@ -246,6 +246,90 @@ describe('Generate Column Definitions', () => {
default: "'USD'::text",
});
});
it('should serialize null-equivalent unique composite defaults as NULL', () => {
const phonesField = getFlatFieldMetadataMock({
universalIdentifier: 'phone',
objectMetadataId: mockObjectId,
type: FieldMetadataType.PHONES,
name: 'phone',
isUnique: true,
defaultValue: {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "'US'",
primaryPhoneCallingCode: "'+1'",
additionalPhones: null,
},
});
const columns = generateColumnDefinitions({
flatFieldMetadata: phonesField,
flatObjectMetadata: mockObjectMetadata,
workspaceId,
});
expect(columns).toHaveLength(4);
expect(columns).toEqual([
expect.objectContaining({
name: 'phonePrimaryPhoneNumber',
default: 'NULL',
}),
expect.objectContaining({
name: 'phonePrimaryPhoneCountryCode',
default: "'US'::text",
}),
expect.objectContaining({
name: 'phonePrimaryPhoneCallingCode',
default: "'+1'::text",
}),
expect.objectContaining({
name: 'phoneAdditionalPhones',
default: 'NULL',
}),
]);
});
it('should serialize normalized unique phone defaults from metadata input', () => {
const phonesField = getFlatFieldMetadataMock({
universalIdentifier: 'phone',
objectMetadataId: mockObjectId,
type: FieldMetadataType.PHONES,
name: 'phone',
isUnique: true,
defaultValue: {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
additionalPhones: null,
},
});
const columns = generateColumnDefinitions({
flatFieldMetadata: phonesField,
flatObjectMetadata: mockObjectMetadata,
workspaceId,
});
expect(columns).toHaveLength(4);
expect(columns).toEqual([
expect.objectContaining({
name: 'phonePrimaryPhoneNumber',
default: 'NULL',
}),
expect.objectContaining({
name: 'phonePrimaryPhoneCountryCode',
default: 'NULL',
}),
expect.objectContaining({
name: 'phonePrimaryPhoneCallingCode',
default: 'NULL',
}),
expect.objectContaining({
name: 'phoneAdditionalPhones',
default: 'NULL',
}),
]);
});
});
describe('Default Value Schema Generation', () => {
@@ -27,6 +27,7 @@ import {
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception';
import { fieldMetadataTypeToColumnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util';
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
export const generateCompositeColumnDefinition = ({
compositeProperty,
@@ -58,9 +59,14 @@ export const generateCompositeColumnDefinition = ({
parentFlatFieldMetadata.name,
compositeProperty,
);
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
defaultValue: parentFlatFieldMetadata.defaultValue,
fieldType: parentFlatFieldMetadata.type as CompositeFieldMetadataType,
});
const defaultValue =
// @ts-expect-error - TODO: fix this
parentFlatFieldMetadata.defaultValue?.[compositeProperty.name];
normalizedDefaultValue?.[
compositeProperty.name as keyof typeof normalizedDefaultValue
];
const columnType = fieldMetadataTypeToColumnType(compositeProperty.type);
const serializedDefaultValue = serializeDefaultValue({
columnName,