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:
+2
-5
@@ -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;
|
||||
};
|
||||
|
||||
+10
-10
@@ -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 [];
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type ConflictingProperty = {
|
||||
fullPath: string;
|
||||
column: string;
|
||||
};
|
||||
|
||||
export type ConflictingFieldGroup = {
|
||||
baseField: string;
|
||||
conflictingProperties: ConflictingProperty[];
|
||||
};
|
||||
+47
-22
@@ -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);
|
||||
|
||||
|
||||
+16
-9
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+71
-8
@@ -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' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+130
-18
@@ -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,
|
||||
|
||||
+9
-8
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-6
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+16
-26
@@ -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);
|
||||
};
|
||||
|
||||
+36
-25
@@ -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(', ');
|
||||
|
||||
Reference in New Issue
Block a user