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:
+42
@@ -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
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
+21
-6
@@ -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 (
|
||||
[
|
||||
|
||||
+44
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+84
@@ -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', () => {
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user