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:
@@ -24,6 +24,7 @@ export const useFieldMetadataItem = () => {
|
||||
| 'options'
|
||||
| 'settings'
|
||||
| 'isLabelSyncedWithName'
|
||||
| 'isUnique'
|
||||
> & {
|
||||
objectMetadataId: string;
|
||||
relationCreationPayload?: RelationCreationPayload;
|
||||
|
||||
+2
-2
@@ -209,7 +209,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
|
||||
.primaryPhoneCallingCode,
|
||||
isImportable: true,
|
||||
isFilterable: true,
|
||||
isIncludedInUniqueConstraint: false,
|
||||
isIncludedInUniqueConstraint: true,
|
||||
},
|
||||
{
|
||||
subFieldName:
|
||||
@@ -220,7 +220,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
|
||||
.primaryPhoneCountryCode,
|
||||
isImportable: true,
|
||||
isFilterable: false,
|
||||
isIncludedInUniqueConstraint: false,
|
||||
isIncludedInUniqueConstraint: true,
|
||||
},
|
||||
{
|
||||
subFieldName:
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { RebuildUniquePhoneIndexesCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command';
|
||||
import { NormalizeCompositeFieldDefaultsCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
WorkspaceMigrationModule,
|
||||
],
|
||||
providers: [
|
||||
RebuildUniquePhoneIndexesCommand,
|
||||
NormalizeCompositeFieldDefaultsCommand,
|
||||
],
|
||||
})
|
||||
export class V2_5_UpgradeVersionCommandModule {}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import {
|
||||
createIndexInWorkspaceSchema,
|
||||
dropIndexFromWorkspaceSchema,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.5.0', 1778000000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-5:rebuild-unique-phone-indexes',
|
||||
description:
|
||||
'Rebuild unique phone field indexes to include the phone calling code column.',
|
||||
})
|
||||
export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
dataSource,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (!dataSource) {
|
||||
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatFieldMetadataMaps, flatIndexMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatIndexMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
const uniquePhoneIndexes = Object.values(
|
||||
flatIndexMaps.byUniversalIdentifier,
|
||||
).filter((flatIndex): flatIndex is FlatIndexMetadata => {
|
||||
if (!isDefined(flatIndex) || !flatIndex.isUnique) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return flatIndex.flatIndexFieldMetadatas.some((indexField) => {
|
||||
const relatedField = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: indexField.fieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
return relatedField?.type === FieldMetadataType.PHONES;
|
||||
});
|
||||
});
|
||||
|
||||
if (uniquePhoneIndexes.length === 0) {
|
||||
this.logger.log(
|
||||
`No unique phone indexes found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rebuild ${uniquePhoneIndexes.length} unique phone indexes for workspace ${workspaceId}: ${uniquePhoneIndexes
|
||||
.map((index) => index.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
let isQueryRunnerConnected = false;
|
||||
let isTransactionStarted = false;
|
||||
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
isQueryRunnerConnected = true;
|
||||
|
||||
await queryRunner.startTransaction();
|
||||
isTransactionStarted = true;
|
||||
|
||||
for (const uniquePhoneIndex of uniquePhoneIndexes) {
|
||||
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: uniquePhoneIndex.objectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
await dropIndexFromWorkspaceSchema({
|
||||
indexName: uniquePhoneIndex.name,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
});
|
||||
|
||||
await createIndexInWorkspaceSchema({
|
||||
flatIndexMetadata: uniquePhoneIndex,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
|
||||
queryRunner,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
if (isTransactionStarted) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (isQueryRunnerConnected) {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
compositeTypeDefinitions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
|
||||
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.5.0', 1778000001000)
|
||||
@Command({
|
||||
name: 'upgrade:2-5:normalize-composite-field-defaults',
|
||||
description:
|
||||
'Normalize composite field default values: remove empty-string values from metadata and backfill workspace data with NULL.',
|
||||
})
|
||||
export class NormalizeCompositeFieldDefaultsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
dataSource,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (!dataSource) {
|
||||
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
const affectedFields = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter((field) => isCompositeFieldMetadataType(field.type))
|
||||
.filter((field) => {
|
||||
const compositeType = compositeTypeDefinitions.get(
|
||||
field.type as FieldMetadataType,
|
||||
);
|
||||
|
||||
if (!isDefined(compositeType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
|
||||
defaultValue: field.defaultValue,
|
||||
fieldType: field.type as CompositeFieldMetadataType,
|
||||
});
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
if (
|
||||
normalizedDefaultValue?.[
|
||||
property.name as keyof typeof normalizedDefaultValue
|
||||
] !==
|
||||
field.defaultValue?.[
|
||||
property.name as keyof typeof field.defaultValue
|
||||
]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (affectedFields.length === 0) {
|
||||
this.logger.log(
|
||||
`No composite fields with non-null default values found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would normalize ${affectedFields.length} composite field(s) for workspace ${workspaceId}: ${affectedFields.map((f) => f.name).join(', ')}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const backfillTargets: Array<{ tableName: string; columnName: string }> =
|
||||
[];
|
||||
|
||||
for (const field of affectedFields) {
|
||||
const flatObjectMetadata =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
field.objectMetadataUniversalIdentifier
|
||||
];
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
this.logger.warn(
|
||||
`Object metadata not found for field ${field.name} (${field.id}), skipping data backfill for this field`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableName = computeObjectTargetTable(flatObjectMetadata);
|
||||
const compositeType = compositeTypeDefinitions.get(
|
||||
field.type as FieldMetadataType,
|
||||
);
|
||||
|
||||
if (!isDefined(compositeType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
|
||||
defaultValue: field.defaultValue,
|
||||
fieldType: field.type as CompositeFieldMetadataType,
|
||||
});
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
if (
|
||||
normalizedDefaultValue?.[
|
||||
property.name as keyof typeof normalizedDefaultValue
|
||||
] !==
|
||||
field.defaultValue?.[property.name as keyof typeof field.defaultValue]
|
||||
) {
|
||||
backfillTargets.push({
|
||||
tableName,
|
||||
columnName: computeCompositeColumnName(field.name, property),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsByApplication = affectedFields.reduce<
|
||||
Map<string, typeof affectedFields>
|
||||
>((acc, field) => {
|
||||
const key = field.applicationUniversalIdentifier;
|
||||
const group = acc.get(key) ?? [];
|
||||
|
||||
group.push(field);
|
||||
acc.set(key, group);
|
||||
|
||||
return acc;
|
||||
}, new Map());
|
||||
|
||||
for (const [
|
||||
applicationUniversalIdentifier,
|
||||
fields,
|
||||
] of fieldsByApplication) {
|
||||
const flatFieldMetadatasToUpdate = fields.map((field) => ({
|
||||
...field,
|
||||
defaultValue: nullifyEmptyCompositeDefaultValue({
|
||||
defaultValue: field.defaultValue,
|
||||
fieldType: field.type as CompositeFieldMetadataType,
|
||||
}),
|
||||
}));
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: flatFieldMetadatasToUpdate,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
applicationUniversalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while normalizing composite field defaults',
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
this.logger.log(
|
||||
`Normalized defaultValue for composite field "${field.name}" (${field.id}) in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
|
||||
for (const { tableName, columnName } of backfillTargets) {
|
||||
await dataSource.query(
|
||||
`UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${columnName}" = NULL
|
||||
WHERE "${columnName}"::text IN ('', '""')`,
|
||||
undefined,
|
||||
undefined,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Backfilled NULL for "${schemaName}"."${tableName}"."${columnName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -8,6 +8,7 @@ import { V2_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module';
|
||||
import { V2_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-3/2-3-upgrade-version-command.module';
|
||||
import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-4/2-4-upgrade-version-command.module';
|
||||
import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
V2_2_UpgradeVersionCommandModule,
|
||||
V2_3_UpgradeVersionCommandModule,
|
||||
V2_4_UpgradeVersionCommandModule,
|
||||
V2_5_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
+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(', ');
|
||||
|
||||
-61
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+66
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
@@ -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' });
|
||||
});
|
||||
});
|
||||
+21
@@ -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 });
|
||||
});
|
||||
});
|
||||
+15
@@ -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' });
|
||||
});
|
||||
});
|
||||
+27
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
@@ -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 });
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const isNullEquivalentTextDefaultValue = (value: unknown): boolean =>
|
||||
value === "''" || value === '';
|
||||
+37
@@ -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 };
|
||||
};
|
||||
+68
@@ -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,
|
||||
};
|
||||
};
|
||||
+45
@@ -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);
|
||||
}
|
||||
};
|
||||
+28
@@ -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 };
|
||||
};
|
||||
+32
@@ -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 };
|
||||
};
|
||||
+30
@@ -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 };
|
||||
};
|
||||
+40
@@ -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 };
|
||||
};
|
||||
+56
@@ -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,
|
||||
};
|
||||
};
|
||||
+30
@@ -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
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+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,
|
||||
|
||||
+73
-3
@@ -9,6 +9,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const OBJECT_SINGULAR = 'uniquePhonesTestObject';
|
||||
const OBJECT_PLURAL = 'uniquePhonesTestObjects';
|
||||
const OBJECT_TABLE_NAME = `_${OBJECT_SINGULAR}`;
|
||||
const FIELD_NAME = 'phone';
|
||||
|
||||
describe('unique PHONES field with empty values', () => {
|
||||
@@ -36,6 +37,12 @@ describe('unique PHONES field with empty values', () => {
|
||||
type: FieldMetadataType.PHONES,
|
||||
objectMetadataId: createdObjectMetadataId,
|
||||
isUnique: true,
|
||||
defaultValue: {
|
||||
primaryPhoneNumber: "''",
|
||||
primaryPhoneCountryCode: "'US'",
|
||||
primaryPhoneCallingCode: "'+1'",
|
||||
additionalPhones: null,
|
||||
},
|
||||
isLabelSyncedWithName: false,
|
||||
},
|
||||
gqlFields: `
|
||||
@@ -48,7 +55,7 @@ describe('unique PHONES field with empty values', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdRecordIdsForCleaning.length > 0) {
|
||||
await deleteRecordsByIds(OBJECT_SINGULAR, createdRecordIdsForCleaning);
|
||||
await deleteRecordsByIds(OBJECT_TABLE_NAME, createdRecordIdsForCleaning);
|
||||
createdRecordIdsForCleaning = [];
|
||||
}
|
||||
});
|
||||
@@ -104,23 +111,86 @@ describe('unique PHONES field with empty values', () => {
|
||||
const firstResponse = await createOneOperation({
|
||||
objectMetadataSingularName: OBJECT_SINGULAR,
|
||||
input: { id: firstId },
|
||||
gqlFields: `id`,
|
||||
gqlFields: `
|
||||
id
|
||||
${FIELD_NAME} {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
primaryPhoneCallingCode
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(firstResponse.errors).toBeUndefined();
|
||||
expect(firstResponse.data.createOneResponse[FIELD_NAME]).toMatchObject({
|
||||
primaryPhoneNumber: '',
|
||||
primaryPhoneCountryCode: 'US',
|
||||
primaryPhoneCallingCode: '+1',
|
||||
});
|
||||
createdRecordIdsForCleaning.push(firstId);
|
||||
|
||||
const secondResponse = await createOneOperation({
|
||||
objectMetadataSingularName: OBJECT_SINGULAR,
|
||||
input: { id: secondId },
|
||||
gqlFields: `
|
||||
id
|
||||
${FIELD_NAME} {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
primaryPhoneCallingCode
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(secondResponse.errors).toBeUndefined();
|
||||
expect(secondResponse.data.createOneResponse[FIELD_NAME]).toMatchObject({
|
||||
primaryPhoneNumber: '',
|
||||
primaryPhoneCountryCode: 'US',
|
||||
primaryPhoneCallingCode: '+1',
|
||||
});
|
||||
createdRecordIdsForCleaning.push(secondId);
|
||||
});
|
||||
|
||||
it('should allow creating when same phoneNumber but different phoneCallingCode', async () => {
|
||||
const firstId = faker.string.uuid();
|
||||
const secondId = faker.string.uuid();
|
||||
|
||||
const firstResponse = await createOneOperation({
|
||||
objectMetadataSingularName: OBJECT_SINGULAR,
|
||||
input: {
|
||||
id: firstId,
|
||||
[FIELD_NAME]: {
|
||||
primaryPhoneNumber: '4155552671',
|
||||
primaryPhoneCallingCode: '+1',
|
||||
primaryPhoneCountryCode: 'US',
|
||||
},
|
||||
},
|
||||
gqlFields: `id`,
|
||||
});
|
||||
|
||||
expect(firstResponse.errors).toBeUndefined();
|
||||
expect(firstResponse.data.createOneResponse.id).toBe(firstId);
|
||||
createdRecordIdsForCleaning.push(firstId);
|
||||
|
||||
const secondResponse = await createOneOperation({
|
||||
objectMetadataSingularName: OBJECT_SINGULAR,
|
||||
input: {
|
||||
id: secondId,
|
||||
[FIELD_NAME]: {
|
||||
primaryPhoneNumber: '4155552671',
|
||||
primaryPhoneCallingCode: '+32',
|
||||
primaryPhoneCountryCode: 'BE',
|
||||
},
|
||||
},
|
||||
gqlFields: `id`,
|
||||
});
|
||||
|
||||
expect(secondResponse.errors).toBeUndefined();
|
||||
expect(secondResponse.data.createOneResponse.id).toBe(secondId);
|
||||
createdRecordIdsForCleaning.push(secondId);
|
||||
});
|
||||
|
||||
it('should still enforce uniqueness when two records share the same non-empty primaryPhoneNumber', async () => {
|
||||
it('should still enforce uniqueness when two records share the same non-empty primaryPhoneNumber and primaryPhoneCallingCode', async () => {
|
||||
const firstId = faker.string.uuid();
|
||||
const secondId = faker.string.uuid();
|
||||
|
||||
|
||||
+10
-19
@@ -86,11 +86,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
label: 'Shipping Address',
|
||||
defaultValue: {
|
||||
addressStreet1: "'456 Oak Ave'",
|
||||
addressStreet2: "''",
|
||||
addressStreet2: null,
|
||||
addressCity: "'New York'",
|
||||
addressState: "''",
|
||||
addressPostcode: "''",
|
||||
addressCountry: "''",
|
||||
addressState: null,
|
||||
addressPostcode: null,
|
||||
addressCountry: null,
|
||||
addressLat: null,
|
||||
addressLng: null,
|
||||
},
|
||||
@@ -103,16 +103,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'billingAddress',
|
||||
label: 'Billing Address',
|
||||
defaultValue: {
|
||||
addressStreet1: "''",
|
||||
addressStreet2: "''",
|
||||
addressCity: "''",
|
||||
addressState: "''",
|
||||
addressPostcode: "''",
|
||||
addressCountry: "''",
|
||||
addressLat: null,
|
||||
addressLng: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -126,11 +117,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
subFields: ['addressStreet1', 'addressCity', 'addressCountry'],
|
||||
},
|
||||
defaultValue: {
|
||||
addressStreet1: "''",
|
||||
addressStreet2: "''",
|
||||
addressCity: "''",
|
||||
addressState: "''",
|
||||
addressPostcode: "''",
|
||||
addressStreet1: null,
|
||||
addressStreet2: null,
|
||||
addressCity: null,
|
||||
addressState: null,
|
||||
addressPostcode: null,
|
||||
addressCountry: "'USA'",
|
||||
addressLat: null,
|
||||
addressLng: null,
|
||||
|
||||
+1
-4
@@ -72,10 +72,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'cost',
|
||||
label: 'Cost',
|
||||
defaultValue: {
|
||||
amountMicros: null,
|
||||
currencyCode: "''",
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-4
@@ -57,10 +57,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'emptyEmails',
|
||||
label: 'Empty Emails',
|
||||
defaultValue: {
|
||||
primaryEmail: "''",
|
||||
additionalEmails: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-5
@@ -48,7 +48,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
label: 'Customer Name',
|
||||
defaultValue: {
|
||||
firstName: "'Jane'",
|
||||
lastName: "''",
|
||||
lastName: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -59,10 +59,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'authorName',
|
||||
label: 'Author Name',
|
||||
defaultValue: {
|
||||
firstName: "''",
|
||||
lastName: "''",
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-5
@@ -58,11 +58,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'emptyLinks',
|
||||
label: 'Empty Links',
|
||||
defaultValue: {
|
||||
primaryLinkLabel: "''",
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-4
@@ -59,10 +59,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
name: 'body',
|
||||
label: 'Body',
|
||||
defaultValue: {
|
||||
blocknote: null,
|
||||
markdown: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+9
-9
@@ -77,11 +77,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
defaultValue: {
|
||||
addressStreet1: "'456 Oak Ave'",
|
||||
addressStreet2: "''",
|
||||
addressStreet2: null,
|
||||
addressCity: "'Boston'",
|
||||
addressState: "''",
|
||||
addressPostcode: "''",
|
||||
addressCountry: "''",
|
||||
addressState: null,
|
||||
addressPostcode: null,
|
||||
addressCountry: null,
|
||||
addressLat: null,
|
||||
addressLng: null,
|
||||
},
|
||||
@@ -104,11 +104,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
subFields: ['addressStreet1', 'addressCity', 'addressPostcode'],
|
||||
},
|
||||
defaultValue: {
|
||||
addressStreet1: "''",
|
||||
addressStreet2: "''",
|
||||
addressCity: "''",
|
||||
addressState: "''",
|
||||
addressPostcode: "''",
|
||||
addressStreet1: null,
|
||||
addressStreet2: null,
|
||||
addressCity: null,
|
||||
addressState: null,
|
||||
addressPostcode: null,
|
||||
addressCountry: "'USA'",
|
||||
addressLat: null,
|
||||
addressLng: null,
|
||||
|
||||
+1
-4
@@ -62,10 +62,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
title: 'currency field default value with empty values',
|
||||
context: {
|
||||
input: {
|
||||
defaultValue: {
|
||||
amountMicros: null,
|
||||
currencyCode: "''",
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-4
@@ -50,10 +50,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
title: 'emails field default value with empty values',
|
||||
context: {
|
||||
input: {
|
||||
defaultValue: {
|
||||
primaryEmail: "''",
|
||||
additionalEmails: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-5
@@ -42,7 +42,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
input: {
|
||||
defaultValue: {
|
||||
firstName: "'Jane'",
|
||||
lastName: "''",
|
||||
lastName: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,10 +51,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
title: 'full name field default value with empty values',
|
||||
context: {
|
||||
input: {
|
||||
defaultValue: {
|
||||
firstName: "''",
|
||||
lastName: "''",
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-5
@@ -51,11 +51,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
title: 'links field default value with empty values',
|
||||
context: {
|
||||
input: {
|
||||
defaultValue: {
|
||||
primaryLinkLabel: "''",
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-6
@@ -52,12 +52,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{
|
||||
title: 'phones field default value with empty values',
|
||||
context: {
|
||||
input: {
|
||||
defaultValue: {
|
||||
primaryPhoneNumber: "''",
|
||||
primaryPhoneCountryCode: "''",
|
||||
primaryPhoneCallingCode: "''",
|
||||
additionalPhones: null,
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,12 +17,14 @@ export const phonesCompositeType: CompositeType = {
|
||||
type: FieldMetadataType.TEXT,
|
||||
hidden: false,
|
||||
isRequired: false,
|
||||
isIncludedInUniqueConstraint: true,
|
||||
},
|
||||
{
|
||||
name: 'primaryPhoneCallingCode',
|
||||
type: FieldMetadataType.TEXT,
|
||||
hidden: false,
|
||||
isRequired: false,
|
||||
isIncludedInUniqueConstraint: true,
|
||||
},
|
||||
{
|
||||
name: 'additionalPhones',
|
||||
|
||||
Reference in New Issue
Block a user