diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3c31003a88..5640ac7455 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1275,6 +1275,7 @@ export enum FeatureFlagKey { IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED', + IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 21833292ac..0467daa696 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1258,6 +1258,7 @@ export enum FeatureFlagKey { IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED', + IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts index 88673c76b0..8a3c69ea57 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts @@ -7,8 +7,7 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/ import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { type ObjectPermissions } from 'twenty-shared/types'; -import { capitalize } from 'twenty-shared/utils'; -import { isEmptyObject } from '~/utils/isEmptyObject'; +import { capitalize, isEmptyObject } from 'twenty-shared/utils'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export type GetRecordFromCacheArgs = { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 3993aa2d35..0161e72d91 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -40,9 +40,8 @@ import { type TSVectorFilter, type UUIDFilter, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { isEmptyObject } from '~/utils/isEmptyObject'; const isLeafFilter = ( filter: RecordGqlOperationFilter, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts index df7671f8c9..e59804d494 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts @@ -8,12 +8,15 @@ import { } from '@/spreadsheet-import/types'; import { isNonEmptyString } from '@sniptt/guards'; import { parsePhoneNumberWithError, type CountryCode } from 'libphonenumber-js'; -import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { + assertUnreachable, + isDefined, + isEmptyObject, +} from 'twenty-shared/utils'; import { z } from 'zod'; import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; import { castToString } from '~/utils/castToString'; import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; -import { isEmptyObject } from '~/utils/isEmptyObject'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; type BuildRecordFromImportedStructuredRowArgs = { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts index 9692ddc9c7..26e4f78407 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts @@ -7,9 +7,8 @@ import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPre import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; import { filterOutputSchema } from '@/workflow/workflow-variables/utils/filterOutputSchema'; import { useRecoilValue } from 'recoil'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; -import { isEmptyObject } from '~/utils/isEmptyObject'; export const useAvailableVariablesInWorkflowStep = ({ shouldDisplayRecordFields, diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index ba80070114..8c1de92b96 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -16,6 +16,7 @@ IS_IMAP_SMTP_CALDAV_ENABLED=false CALENDAR_PROVIDER_GOOGLE_ENABLED=false MESSAGING_PROVIDER_MICROSOFT_ENABLED=false CALENDAR_PROVIDER_MICROSOFT_ENABLED=false +TELEMETRY_ENABLED=false AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant.ts new file mode 100644 index 0000000000..7955744559 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant.ts @@ -0,0 +1,67 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export const POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE = ''; + +export const DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE = ''; + +export const DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE = {}; + +export const POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE = '{}'; + +export const DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE = []; + +export const POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE = '{}'; + +const DEFAULT_ADDRESS_FIELD_NULL_EQUIVALENT_VALUE = { + addressStreet1: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + addressStreet2: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + addressCity: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + addressState: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + addressCountry: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + addressPostcode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_EMAILS_FIELD_NULL_EQUIVALENT_VALUE = { + primaryEmail: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + additionalEmails: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_LINKS_FIELD_NULL_EQUIVALENT_VALUE = { + primaryLinkUrl: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + primaryLinkLabel: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + secondaryLinks: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_PHONES_FIELD_NULL_EQUIVALENT_VALUE = { + primaryPhoneNumber: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + primaryPhoneCountryCode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + primaryPhoneCallingCode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + additionalPhones: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_FULL_NAME_FIELD_NULL_EQUIVALENT_VALUE = { + firstName: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + lastName: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_ACTOR_FIELD_NULL_EQUIVALENT_VALUE = { + name: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, + context: DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE, +}; + +const DEFAULT_RICH_TEXT_V2_FIELD_NULL_EQUIVALENT_VALUE = { + markdown: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +}; + +export const DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE: Partial< + Record> +> = { + [FieldMetadataType.ADDRESS]: DEFAULT_ADDRESS_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.EMAILS]: DEFAULT_EMAILS_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.LINKS]: DEFAULT_LINKS_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.PHONES]: DEFAULT_PHONES_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.FULL_NAME]: DEFAULT_FULL_NAME_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.ACTOR]: DEFAULT_ACTOR_FIELD_NULL_EQUIVALENT_VALUE, + [FieldMetadataType.RICH_TEXT_V2]: + DEFAULT_RICH_TEXT_V2_FIELD_NULL_EQUIVALENT_VALUE, +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts index 0f41d7d166..d2661301b5 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts @@ -44,6 +44,8 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { transformEmailsValue } from 'src/engine/core-modules/record-transformer/utils/transform-emails-value.util'; import { transformLinksValue } from 'src/engine/core-modules/record-transformer/utils/transform-links-value.util'; @@ -55,7 +57,10 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ @Injectable() export class DataArgProcessor { - constructor(private readonly recordPositionService: RecordPositionService) {} + constructor( + private readonly recordPositionService: RecordPositionService, + private readonly featureFlagService: FeatureFlagService, + ) {} async process({ partialRecordInputs, @@ -76,6 +81,12 @@ export class DataArgProcessor { assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); + const isNullEquivalenceEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_NULL_EQUIVALENCE_ENABLED, + workspace.id, + ); + const processedRecords: Partial[] = []; for (const record of partialRecordInputs) { @@ -115,6 +126,7 @@ export class DataArgProcessor { fieldMetadata, key, value, + isNullEquivalenceEnabled, ); } processedRecords.push(processedRecord); @@ -139,6 +151,7 @@ export class DataArgProcessor { fieldMetadata: FieldMetadataEntity, key: string, value: unknown, + isNullEquivalenceEnabled: boolean, ): Promise { switch (fieldMetadata.type) { case FieldMetadataType.POSITION: @@ -154,7 +167,7 @@ export class DataArgProcessor { case FieldMetadataType.TEXT: { const validatedValue = validateTextFieldOrThrow(value, key); - return transformTextField(validatedValue); + return transformTextField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.DATE_TIME: case FieldMetadataType.DATE: @@ -179,19 +192,19 @@ export class DataArgProcessor { fieldMetadata.options?.map((option) => option.value), ); - return transformArrayField(validatedValue); + return transformArrayField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.UUID: return validateUUIDFieldOrThrow(value, key); case FieldMetadataType.ARRAY: { const validatedValue = validateArrayFieldOrThrow(value, key); - return transformArrayField(validatedValue); + return transformArrayField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.RAW_JSON: { const validatedValue = validateRawJsonFieldOrThrow(value, key); - return transformRawJsonField(validatedValue); + return transformRawJsonField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.RELATION: case FieldMetadataType.MORPH_RELATION: { @@ -222,18 +235,18 @@ export class DataArgProcessor { case FieldMetadataType.EMAILS: { const validatedValue = validateEmailsFieldOrThrow(value, key); - return transformEmailsValue(validatedValue); + return transformEmailsValue(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.FULL_NAME: { const validatedValue = validateFullNameFieldOrThrow(value, key); - return transformFullNameField(validatedValue); + return transformFullNameField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.ADDRESS: { const validatedValue = validateAddressFieldOrThrow(value, key); - return transformAddressField(validatedValue); + return transformAddressField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.CURRENCY: { const validatedValue = validateCurrencyFieldOrThrow(value, key); @@ -243,7 +256,7 @@ export class DataArgProcessor { case FieldMetadataType.ACTOR: { const validatedValue = validateActorFieldOrThrow(value, key); - return transformActorField(validatedValue); + return transformActorField(validatedValue, isNullEquivalenceEnabled); } case FieldMetadataType.RICH_TEXT_V2: { const validatedValue = validateRichTextV2FieldOrThrow(value, key); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util.ts index 5003d2a645..f6d8f4715b 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util.ts @@ -2,16 +2,21 @@ import { isNull, isUndefined } from '@sniptt/guards'; import { type FieldActorSource } from 'twenty-shared/types'; import { transformRawJsonField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util'; +import { transformTextField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util'; export const transformActorField = ( value: { source?: FieldActorSource | null; context?: object | string | null; + name?: string | null; + workspaceMemberId?: string | null; } | null, isNullEquivalenceEnabled: boolean = false, ): { source?: FieldActorSource | null; context?: object | string | null; + name?: string | null; + workspaceMemberId?: string | null; } | null => { if (isNull(value)) return null; @@ -20,5 +25,11 @@ export const transformActorField = ( context: isUndefined(value.context) ? undefined : transformRawJsonField(value.context, isNullEquivalenceEnabled), + name: isUndefined(value.name) + ? undefined + : transformTextField(value.name, isNullEquivalenceEnabled), + workspaceMemberId: isUndefined(value.workspaceMemberId) + ? undefined + : value.workspaceMemberId, }; }; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts index 47c0d34672..5cc7cb4263 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts @@ -1,4 +1,4 @@ -import { isNull } from '@sniptt/guards'; +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; export const transformArrayField = ( value: string | string[] | null, @@ -6,9 +6,7 @@ export const transformArrayField = ( ): string[] | null => { if (typeof value === 'string') return [value]; - return isNullEquivalenceEnabled && - !isNull(value) && - Object.keys(value).length === 0 + return isNullEquivalenceEnabled && isNullEquivalentArrayFieldValue(value) ? null : value; }; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util.ts index d1a4f66ff4..56726c6948 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util.ts @@ -1,13 +1,11 @@ //Json.parse() for RawJsonField is done in formatFieldMetadataValue in ORM -import { isNull } from '@sniptt/guards'; +import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util'; export const transformRawJsonField = ( value: object | string | null, isNullEquivalenceEnabled: boolean = false, ): object | string | null => { - return isNullEquivalenceEnabled && - !isNull(value) && - Object.keys(value).length === 0 + return isNullEquivalenceEnabled && isNullEquivalentRawJsonFieldValue(value) ? null : value; }; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util.ts index 059bd34e0a..08ebfa4c4d 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util.ts @@ -1,8 +1,10 @@ -import { isNonEmptyString } from '@sniptt/guards'; +import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util'; export const transformTextField = ( value: string | null, isNullEquivalenceEnabled: boolean = false, ): string | null => { - return isNullEquivalenceEnabled && !isNonEmptyString(value) ? null : value; + return isNullEquivalenceEnabled && isNullEquivalentTextFieldValue(value) + ? null + : value; }; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/find-postgres-default-null-equivalent-value.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/find-postgres-default-null-equivalent-value.util.spec.ts new file mode 100644 index 0000000000..03e4fbd5ea --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/find-postgres-default-null-equivalent-value.util.spec.ts @@ -0,0 +1,256 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, + POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE, + POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant'; +import { findPostgresDefaultNullEquivalentValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util'; + +describe('findPostgresDefaultNullEquivalentValue', () => { + describe('Simple Types', () => { + describe('TEXT', () => { + it('should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for null', () => { + expect( + findPostgresDefaultNullEquivalentValue(null, FieldMetadataType.TEXT), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for empty string', () => { + expect( + findPostgresDefaultNullEquivalentValue('', FieldMetadataType.TEXT), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it("should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => { + expect( + findPostgresDefaultNullEquivalentValue( + 'NULL', + FieldMetadataType.TEXT, + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return undefined for non-null equivalent value', () => { + expect( + findPostgresDefaultNullEquivalentValue( + 'value', + FieldMetadataType.TEXT, + ), + ).toBeUndefined(); + }); + }); + + describe('RAW_JSON', () => { + it('should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for null', () => { + expect( + findPostgresDefaultNullEquivalentValue( + null, + FieldMetadataType.RAW_JSON, + ), + ).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for empty object', () => { + expect( + findPostgresDefaultNullEquivalentValue( + {}, + FieldMetadataType.RAW_JSON, + ), + ).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it("should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => { + expect( + findPostgresDefaultNullEquivalentValue( + 'NULL', + FieldMetadataType.RAW_JSON, + ), + ).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('ARRAY', () => { + it('should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for null', () => { + expect( + findPostgresDefaultNullEquivalentValue(null, FieldMetadataType.ARRAY), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for empty array', () => { + expect( + findPostgresDefaultNullEquivalentValue([], FieldMetadataType.ARRAY), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it("should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => { + expect( + findPostgresDefaultNullEquivalentValue( + 'NULL', + FieldMetadataType.ARRAY, + ), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + }); + + describe('ACTOR', () => { + it('should return text default for name', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.ACTOR, + 'name', + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return json default for context', () => { + expect( + findPostgresDefaultNullEquivalentValue( + {}, + FieldMetadataType.ACTOR, + 'context', + ), + ).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('ADDRESS', () => { + it.each([ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ])('should return text default for %s', (key) => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.ADDRESS, + key, + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('EMAILS', () => { + it('should return text default for primaryEmail', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.EMAILS, + 'primaryEmail', + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return array default for additionalEmails', () => { + expect( + findPostgresDefaultNullEquivalentValue( + [], + FieldMetadataType.EMAILS, + 'additionalEmails', + ), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('LINKS', () => { + it('should return text default for primaryLinkUrl', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.LINKS, + 'primaryLinkUrl', + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return array default for secondaryLinks', () => { + expect( + findPostgresDefaultNullEquivalentValue( + [], + FieldMetadataType.LINKS, + 'secondaryLinks', + ), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('PHONES', () => { + it('should return text default for primaryPhoneNumber', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.PHONES, + 'primaryPhoneNumber', + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return array default for additionalPhones', () => { + expect( + findPostgresDefaultNullEquivalentValue( + [], + FieldMetadataType.PHONES, + 'additionalPhones', + ), + ).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('RICH_TEXT_V2', () => { + it('should return json default for blocknote', () => { + expect( + findPostgresDefaultNullEquivalentValue( + {}, + FieldMetadataType.RICH_TEXT_V2, + 'blocknote', + ), + ).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE); + }); + + it('should return text default for markdown', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.RICH_TEXT_V2, + 'markdown', + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }); + }); + + describe('FULL_NAME', () => { + it.each(['firstName', 'lastName'])( + 'should return text default for %s', + (key) => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.FULL_NAME, + key, + ), + ).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE); + }, + ); + }); +}); + +it('should return undefined for unknown type', () => { + expect( + findPostgresDefaultNullEquivalentValue(null, 'UNKNOWN' as any), + ).toBeUndefined(); +}); + +it('should return undefined for unknown composite key', () => { + expect( + findPostgresDefaultNullEquivalentValue( + '', + FieldMetadataType.ACTOR, + 'unknown', + ), + ).toBeUndefined(); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-array-field.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-array-field.util.spec.ts new file mode 100644 index 0000000000..fc4af1496e --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-array-field.util.spec.ts @@ -0,0 +1,42 @@ +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; + +describe('isNullEquivalentArrayFieldValue', () => { + describe('null-equivalent values', () => { + it('should return true when value is null', () => { + const result = isNullEquivalentArrayFieldValue(null); + + expect(result).toBe(true); + }); + + it('should return true when value is an empty array', () => { + const result = isNullEquivalentArrayFieldValue([]); + + expect(result).toBe(true); + }); + }); + + describe('non-null-equivalent values', () => { + it('should return false when value is undefined', () => { + const result = isNullEquivalentArrayFieldValue(undefined); + + expect(result).toBe(false); + }); + it('should return false when value is an array with one item', () => { + const result = isNullEquivalentArrayFieldValue(['item']); + + expect(result).toBe(false); + }); + + it('should return false when value is a string', () => { + const result = isNullEquivalentArrayFieldValue('hello'); + + expect(result).toBe(false); + }); + + it('should return false when value is an empty string', () => { + const result = isNullEquivalentArrayFieldValue(''); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-raw-json-field.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-raw-json-field.util.spec.ts new file mode 100644 index 0000000000..bdb882232a --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-raw-json-field.util.spec.ts @@ -0,0 +1,48 @@ +import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util'; + +describe('isNullEquivalentRawJsonFieldValue', () => { + describe('null-equivalent values', () => { + it('should return true when value is null', () => { + const result = isNullEquivalentRawJsonFieldValue(null); + + expect(result).toBe(true); + }); + + it('should return true when value is an empty object', () => { + const result = isNullEquivalentRawJsonFieldValue({}); + + expect(result).toBe(true); + }); + + it('should return true when value is an empty array', () => { + const result = isNullEquivalentRawJsonFieldValue([]); + + expect(result).toBe(true); + }); + }); + + describe('non-null-equivalent values', () => { + it('should return false when value is undefined', () => { + const result = isNullEquivalentRawJsonFieldValue(undefined); + + expect(result).toBe(false); + }); + it('should return false when value is an object with properties', () => { + const result = isNullEquivalentRawJsonFieldValue({ key: 'value' }); + + expect(result).toBe(false); + }); + + it('should return false when value is a string', () => { + const result = isNullEquivalentRawJsonFieldValue('hello'); + + expect(result).toBe(false); + }); + + it('should return false when value is an empty string', () => { + const result = isNullEquivalentRawJsonFieldValue(''); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-text-field.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-text-field.util.spec.ts new file mode 100644 index 0000000000..8b2a7e2d66 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/__tests__/is-null-equivalent-text-field.util.spec.ts @@ -0,0 +1,31 @@ +import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util'; + +describe('isNullEquivalentTextFieldValue', () => { + describe('null-equivalent values', () => { + it('should return true when value is an empty string', () => { + const result = isNullEquivalentTextFieldValue(''); + + expect(result).toBe(true); + }); + + it('should return true when value is null', () => { + const result = isNullEquivalentTextFieldValue(null); + + expect(result).toBe(true); + }); + }); + + describe('non-null-equivalent values', () => { + it('should return false when value is a non-empty string', () => { + const result = isNullEquivalentTextFieldValue('hello'); + + expect(result).toBe(false); + }); + + it('should return false when value is undefined', () => { + const result = isNullEquivalentTextFieldValue(undefined); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util.ts new file mode 100644 index 0000000000..e18a5de4c5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util.ts @@ -0,0 +1,160 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, + POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE, + POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant'; +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; +import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util'; +import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util'; + +export const findPostgresDefaultNullEquivalentValue = ( + value: unknown, + fieldMetadataType: FieldMetadataType, + key?: string, +) => { + switch (fieldMetadataType) { + case FieldMetadataType.TEXT: + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case FieldMetadataType.RAW_JSON: + return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case FieldMetadataType.ARRAY: + return isNullEquivalentArrayFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case FieldMetadataType.ACTOR: { + switch (key) { + case 'name': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'context': + return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + case FieldMetadataType.ADDRESS: { + switch (key) { + case 'addressStreet1': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'addressStreet2': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'addressCity': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'addressState': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'addressPostcode': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'addressCountry': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + case FieldMetadataType.EMAILS: { + switch (key) { + case 'primaryEmail': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'additionalEmails': + return isNullEquivalentArrayFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + case FieldMetadataType.LINKS: { + switch (key) { + case 'primaryLinkUrl': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'primaryLinkLabel': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'secondaryLinks': + return isNullEquivalentArrayFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + + default: + return undefined; + } + } + case FieldMetadataType.PHONES: { + switch (key) { + case 'primaryPhoneNumber': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'primaryPhoneCountryCode': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'primaryPhoneCallingCode': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'additionalPhones': + return isNullEquivalentArrayFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + case FieldMetadataType.RICH_TEXT_V2: { + switch (key) { + case 'blocknote': + return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'markdown': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + case FieldMetadataType.FULL_NAME: { + switch (key) { + case 'firstName': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + case 'lastName': + return isNullEquivalentTextFieldValue(value) || value === 'NULL' + ? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE + : undefined; + default: + return undefined; + } + } + } + + return undefined; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util.ts new file mode 100644 index 0000000000..46d8bcb5c3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util.ts @@ -0,0 +1,3 @@ +export const isNullEquivalentArrayFieldValue = (value: unknown): boolean => { + return (Array.isArray(value) && value.length === 0) || value === null; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util.ts new file mode 100644 index 0000000000..5aa1ec4d19 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util.ts @@ -0,0 +1,8 @@ +import { isNull } from '@sniptt/guards'; +import { isEmptyObject } from 'twenty-shared/utils'; + +export const isNullEquivalentRawJsonFieldValue = (value: unknown): boolean => { + if (isNull(value)) return true; + + return isEmptyObject(value); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts new file mode 100644 index 0000000000..326025a4d5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts @@ -0,0 +1,12 @@ +import { isNull } 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 + ); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util.ts index b4d63aab5d..f8b7b44673 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util.ts @@ -3,6 +3,8 @@ import { FieldActorSource } from 'twenty-shared/types'; import { validateRatingAndSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-rating-and-select-field-or-throw.util'; import { validateRawJsonFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-raw-json-field-or-throw.util'; +import { validateTextFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-text-field-or-throw.util'; +import { validateUUIDFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-uuid-field-or-throw.util'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, @@ -28,6 +30,12 @@ export const validateActorFieldOrThrow = ( case 'context': validateRawJsonFieldOrThrow(subFieldValue, `${fieldName}.${subField}`); break; + case 'name': + validateTextFieldOrThrow(subFieldValue, `${fieldName}.${subField}`); + break; + case 'workspaceMemberId': + validateUUIDFieldOrThrow(subFieldValue, `${fieldName}.${subField}`); + break; default: throw new CommonQueryRunnerException( `Invalid subfield ${subField} for actor field "${fieldName}"`, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 60450d6a19..afe54f1b37 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -1,6 +1,6 @@ +import { compositeTypeDefinitions } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; import { type WhereExpressionBuilder } from 'typeorm'; -import { compositeTypeDefinitions } from 'twenty-shared/types'; import { GraphqlQueryRunnerException, @@ -60,12 +60,12 @@ export class GraphqlQueryFilterFieldParser { GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, ); } - const { sql, params } = computeWhereConditionParts({ operator, objectNameSingular, key, value, + fieldMetadataType: fieldMetadata.type, }); if (isFirst) { @@ -125,7 +125,9 @@ export class GraphqlQueryFilterFieldParser { operator, objectNameSingular, key: fullFieldName, + subFieldKey, value, + fieldMetadataType: fieldMetadata.type, }); if (isFirst && index === 0) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index 79aa7bd40b..e05a93211c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -1,5 +1,8 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type ObjectLiteral } from 'typeorm'; +import { findPostgresDefaultNullEquivalentValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util'; import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, @@ -15,30 +18,45 @@ export const computeWhereConditionParts = ({ operator, objectNameSingular, key, + subFieldKey, value, + fieldMetadataType, }: { operator: string; objectNameSingular: string; key: string; + subFieldKey?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; + fieldMetadataType: FieldMetadataType; }): WhereConditionParts => { const uuid = Math.random().toString(36).slice(2, 7); + const secondUuid = Math.random().toString(36).slice(2, 7); + + //TODO : Remove filter null equivalence injection once feature flag removed + null equivalence transformation added in ORM + const nullEquivalentFieldValue = findPostgresDefaultNullEquivalentValue( + value, + fieldMetadataType, + subFieldKey, + ); + + const hasNullEquivalentFieldValue = isDefined(nullEquivalentFieldValue); + switch (operator) { case 'isEmptyArray': return { - sql: `"${objectNameSingular}"."${key}" = '{}'`, + sql: `"${objectNameSingular}"."${key}" = '{}'${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`, params: {}, }; case 'eq': return { - sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`, params: { [`${key}${uuid}`]: value }, }; case 'neq': return { - sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NOT NULL` : ''}`, params: { [`${key}${uuid}`]: value }, }; case 'gt': @@ -68,17 +86,19 @@ export const computeWhereConditionParts = ({ }; case 'is': return { - sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`, - params: {}, + sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" = :${key}${secondUuid}` : ''}`, + params: hasNullEquivalentFieldValue + ? { [`${key}${secondUuid}`]: nullEquivalentFieldValue } + : {}, }; case 'like': return { - sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`, params: { [`${key}${uuid}`]: `${value}` }, }; case 'ilike': return { - sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`, params: { [`${key}${uuid}`]: `${value}` }, }; case 'startsWith': diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index e93bf085b6..f2f77696d0 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -16,4 +16,5 @@ export enum FeatureFlagKey { IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED = 'IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED', + IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts index 4b385cd534..022504f56a 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts @@ -128,11 +128,13 @@ export class UpsertRecordService { ? conflictPathsUniqueFieldsToUpdate : ['id']; + //TODO : To delete once IS_NULL_EQUIVALENCE_ENABLED feature flag removed const indexPredicate = uniqueFieldsToUpdate .map((field) => computeUniqueIndexWhereClause({ type: field.type, name: field.name, + defaultValue: field.defaultValue, }), ) .filter(isDefined); diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts index 1c74bef302..3112574473 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-emails-value.util.ts @@ -1,15 +1,21 @@ import { isNonEmptyString } from '@sniptt/guards'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const transformEmailsValue = (value: any): any => { +export const transformEmailsValue = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + isNullEquivalenceEnabled: boolean = false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any => { if (!value) { return value; } - let additionalEmails = value?.additionalEmails; + let additionalEmails: string | null = value?.additionalEmails; const primaryEmail = value?.primaryEmail ? value.primaryEmail.toLowerCase() - : ''; + : isNullEquivalenceEnabled + ? null + : ''; if (additionalEmails) { try { @@ -22,6 +28,13 @@ export const transformEmailsValue = (value: any): any => { additionalEmails = JSON.stringify( emailArray.map((email) => email.toLowerCase()), ); + + if (isNullEquivalenceEnabled) { + additionalEmails = + Array.isArray(emailArray) && emailArray.length === 0 + ? null + : additionalEmails; + } } catch { /* empty */ } diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts index 1ddef8f80c..5fc8861cec 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts @@ -1,11 +1,11 @@ import { isNonEmptyString } from '@sniptt/guards'; import isEmpty from 'lodash.isempty'; +import { type LinkMetadataNullable } from 'twenty-shared/types'; import { isDefined, lowercaseUrlOriginAndRemoveTrailingSlash, parseJson, } from 'twenty-shared/utils'; -import { type LinkMetadataNullable } from 'twenty-shared/types'; import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links'; diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts index be469a6ace..4f97bd160e 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-phones-value.util.ts @@ -5,6 +5,10 @@ import { parsePhoneNumberWithError, } from 'libphonenumber-js'; import isEmpty from 'lodash.isempty'; +import { + type AdditionalPhoneMetadata, + type PhonesMetadata, +} from 'twenty-shared/types'; import { getCountryCodesForCallingCode, isDefined, @@ -12,10 +16,6 @@ import { parseJson, removeUndefinedFields, } from 'twenty-shared/utils'; -import { - type AdditionalPhoneMetadata, - type PhonesMetadata, -} from 'twenty-shared/types'; import { RecordTransformerException, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/generate-nullable.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/generate-nullable.spec.ts index 1540701759..623ae4f47c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/generate-nullable.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/generate-nullable.spec.ts @@ -1,22 +1,12 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; describe('generateNullable', () => { - it('should generate a nullable value false for TEXT, EMAIL, PHONE no matter what the input is', () => { - expect(generateNullable(FieldMetadataType.TEXT, false)).toEqual(false); - - expect(generateNullable(FieldMetadataType.TEXT, true)).toEqual(false); - - expect(generateNullable(FieldMetadataType.TEXT)).toEqual(false); - }); - it('should should return true if no input is given', () => { - expect(generateNullable(FieldMetadataType.DATE_TIME)).toEqual(true); + expect(generateNullable()).toEqual(true); }); it('should should return the input value if the input value is given', () => { - expect(generateNullable(FieldMetadataType.DATE_TIME, true)).toEqual(true); - expect(generateNullable(FieldMetadataType.DATE_TIME, false)).toEqual(false); + expect(generateNullable(true)).toEqual(true); + expect(generateNullable(false)).toEqual(false); }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-valid-unique-input.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-valid-unique-input.util.spec.ts deleted file mode 100644 index 76b04d0f36..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-valid-unique-input.util.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - -import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util'; - -describe('isValidUniqueFieldDefaultValueCombination', () => { - it('should return true if the field has a custom default value and is not unique', () => { - const result = isValidUniqueFieldDefaultValueCombination({ - defaultValue: "'custom value'", - isUnique: false, - type: FieldMetadataType.TEXT, - }); - - expect(result).toBe(true); - }); - - it('should return true if the field has standard default value and is unique', () => { - const result = isValidUniqueFieldDefaultValueCombination({ - defaultValue: "''", - isUnique: true, - type: FieldMetadataType.TEXT, - }); - - expect(result).toBe(true); - }); - - it('should return false if the field has custom default value and is unique', () => { - const result = isValidUniqueFieldDefaultValueCombination({ - defaultValue: "'custom value'", - isUnique: true, - type: FieldMetadataType.TEXT, - }); - - expect(result).toBe(false); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts index 7fadc21d04..2e83cd5d0e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts @@ -1,4 +1,4 @@ -import { FieldMetadataType, FieldActorSource } from 'twenty-shared/types'; +import { FieldActorSource, FieldMetadataType } from 'twenty-shared/types'; import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; @@ -7,47 +7,6 @@ export function generateDefaultValue( type: FieldMetadataType, ): FieldMetadataDefaultValue { switch (type) { - case FieldMetadataType.TEXT: - return "''" satisfies FieldMetadataDefaultValue; - case FieldMetadataType.EMAILS: - return { - primaryEmail: "''", - additionalEmails: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.FULL_NAME: - return { - firstName: "''", - lastName: "''", - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.ADDRESS: - return { - addressStreet1: "''", - addressStreet2: "''", - addressCity: "''", - addressState: "''", - addressCountry: "''", - addressPostcode: "''", - addressLat: null, - addressLng: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.CURRENCY: - return { - amountMicros: null, - currencyCode: "''", - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.LINKS: - return { - primaryLinkLabel: "''", - primaryLinkUrl: "''", - secondaryLinks: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.PHONES: - return { - primaryPhoneNumber: "''", - primaryPhoneCountryCode: "''", - primaryPhoneCallingCode: "''", - additionalPhones: null, - } satisfies FieldMetadataDefaultValue; case FieldMetadataType.ACTOR: return { source: `'${FieldActorSource.MANUAL}'`, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-nullable.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-nullable.ts index fecae223cd..a0b8ada40d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-nullable.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-nullable.ts @@ -1,6 +1,4 @@ -import { FieldMetadataType } from 'twenty-shared/types'; export function generateNullable( - type: FieldMetadataType, inputNullableValue?: boolean, isRemoteCreation?: boolean, ): boolean { @@ -8,10 +6,5 @@ export function generateNullable( return true; } - switch (type) { - case FieldMetadataType.TEXT: - return false; - default: - return inputNullableValue ?? true; - } + return inputNullableValue ?? true; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts deleted file mode 100644 index f46afa1a31..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - compositeTypeDefinitions, - type FieldMetadataType, -} from 'twenty-shared/types'; - -import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; - -import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; - -export const isValidUniqueFieldDefaultValueCombination = ({ - defaultValue, - isUnique, - type, -}: { - defaultValue: FieldMetadataDefaultValue; - isUnique: boolean; - type: FieldMetadataType; -}) => { - if (!isUnique) return true; - - const defaultDefaultValue = generateDefaultValue(type); - - if (!isCompositeFieldMetadataType(type)) - return defaultValue === defaultDefaultValue; - - const doUniquePropertiesHaveDefaultValues = - compositeTypeDefinitions - .get(type) - ?.properties.filter((property) => property.isIncludedInUniqueConstraint) - .every( - ({ name }) => - (defaultValue as Record)?.[name] === - (defaultDefaultValue as Record)?.[name], - ) ?? false; - - return doUniquePropertiesHaveDefaultValues; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts index ca6cfe1606..45e0639ef4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts @@ -27,7 +27,6 @@ export const prepareCustomFieldMetadataForCreation = ( objectMetadataId: fieldMetadataInput.objectMetadataId, workspaceId: fieldMetadataInput.workspaceId, isNullable: generateNullable( - fieldMetadataInput.type, fieldMetadataInput.isNullable, fieldMetadataInput.isRemoteCreation, ), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts index f9a822d08e..f710cfb968 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -34,7 +34,6 @@ export const getDefaultFlatFieldMetadata = ({ isCustom: true, isLabelSyncedWithName: createFieldInput.isLabelSyncedWithName ?? false, isNullable: generateNullable( - createFieldInput.type, createFieldInput.isNullable, createFieldInput.isRemoteCreation, ), diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts index e23cbe3a42..359f2c8030 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts @@ -3,9 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import isEmpty from 'lodash.isempty'; import { - type FieldMetadataType, type CompositeType, compositeTypeDefinitions, + type FieldMetadataType, } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { In, type QueryRunner, Repository } from 'typeorm'; @@ -19,7 +19,6 @@ import { } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { type IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; -import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; @@ -197,7 +196,6 @@ export class IndexMetadataService { computeObjectTargetTable(objectMetadata), updatedFieldMetadata.name, ])}`, - indexWhereClause: computeUniqueIndexWhereClause(updatedFieldMetadata), }); return { diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts deleted file mode 100644 index 061297cbf3..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-index-where-clause.util.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FieldMetadataType } from 'twenty-shared/types'; - -import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; -import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; - -describe('computeUniqueIndexWhereClause', () => { - it('should return undefined if standard default value is not defined', () => { - const fieldMetadata = getMockFieldMetadataEntity({ - workspaceId: 'workspace-id', - objectMetadataId: 'object-id', - type: FieldMetadataType.UUID, - name: 'testField', - }); - - const result = computeUniqueIndexWhereClause(fieldMetadata); - - expect(result).toBeUndefined(); - }); - - it('should return a where clause for a an atomic type field', () => { - const fieldMetadata = getMockFieldMetadataEntity({ - workspaceId: 'workspace-id', - objectMetadataId: 'object-id', - type: FieldMetadataType.TEXT, - name: 'testTextField', - }); - - const result = computeUniqueIndexWhereClause(fieldMetadata); - - expect(result).toBe('"testTextField" != \'\''); - }); - - it('should return a where clause for a composite type field', () => { - const fieldMetadata = getMockFieldMetadataEntity({ - workspaceId: 'workspace-id', - objectMetadataId: 'object-id', - type: FieldMetadataType.EMAILS, - name: 'testEmailsField', - }); - - const result = computeUniqueIndexWhereClause(fieldMetadata); - - expect(result).toBe('"testEmailsFieldPrimaryEmail" != \'\''); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts index 0a70e7eb5f..16a111e5f8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util.ts @@ -11,19 +11,19 @@ import { FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; -import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { IndexMetadataException, IndexMetadataExceptionCode, } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception'; +//TODO : To delete once IS_NULL_EQUIVALENCE_ENABLED feature flag removed export const computeUniqueIndexWhereClause = ( - fieldMetadata: Pick, + fieldMetadata: Pick, ) => { - const defaultDefaultValue = generateDefaultValue(fieldMetadata.type); + const defaultValue = fieldMetadata.defaultValue; - if (!isDefined(defaultDefaultValue)) return; + if (!isDefined(defaultValue)) return; if ( fieldMetadata.type === FieldMetadataType.RELATION || @@ -36,7 +36,7 @@ export const computeUniqueIndexWhereClause = ( } if (!isCompositeFieldMetadataType(fieldMetadata.type)) { - return `"${fieldMetadata.name}" != ${defaultDefaultValue}`; + return `"${fieldMetadata.name}" != ${defaultValue}`; } const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); @@ -48,7 +48,7 @@ export const computeUniqueIndexWhereClause = ( ); } - const defaultDefaultValueProperties = Object.keys(defaultDefaultValue); + const defaultDefaultValueProperties = Object.keys(defaultValue); const columnNamesWithDefaultValues = compositeType.properties .filter( @@ -57,13 +57,13 @@ export const computeUniqueIndexWhereClause = ( defaultDefaultValueProperties.includes(property.name), ) .map((property) => { - const defaultValue = - defaultDefaultValue[property.name as keyof typeof defaultDefaultValue]; + const defaultValueProperty = + defaultValue[property.name as keyof typeof defaultValue]; - if (isNonEmptyString(defaultValue)) { + if (isNonEmptyString(defaultValueProperty)) { return [ computeCompositeColumnName(fieldMetadata, property), - defaultValue, + defaultValueProperty, ]; } }) diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 28e23f64af..496bb18226 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -140,6 +140,7 @@ describe('WorkspaceEntityManager', () => { IS_WORKFLOW_RUN_STOPPAGE_ENABLED: false, IS_DASHBOARD_V2_ENABLED: false, IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false, + IS_NULL_EQUIVALENCE_ENABLED: false, }, eventEmitterService: { emitMutationEvent: jest.fn(), @@ -167,6 +168,7 @@ describe('WorkspaceEntityManager', () => { IS_WORKFLOW_RUN_STOPPAGE_ENABLED: false, IS_DASHBOARD_V2_ENABLED: false, IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false, + IS_NULL_EQUIVALENCE_ENABLED: false, }, permissionsPerRoleId: {}, } as WorkspaceDataSource; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index d43237ab7f..7aa7a4cfd2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -1,11 +1,18 @@ import { isPlainObject } from '@nestjs/common/utils/shared.utils'; +import { isNull } from '@sniptt/guards'; import { FieldMetadataType, compositeTypeDefinitions, } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { + DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE, + DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE, + DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE, + DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE, +} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; @@ -46,7 +53,11 @@ export function formatResult( for (const [key, value] of Object.entries(data)) { const compositePropertyArgs = compositeFieldMetadataMap.get(key); - const fieldMetadataId = objectMetadataItemWithFieldMaps.fieldIdByName[key]; + const fieldMetadataId = + objectMetadataItemWithFieldMaps.fieldIdByName[key] || + objectMetadataItemWithFieldMaps.fieldIdByName[ + compositePropertyArgs?.parentField ?? '' + ]; const fieldMetadata = objectMetadataItemWithFieldMaps.fieldsById[ fieldMetadataId @@ -66,7 +77,7 @@ export function formatResult( ); } else if (fieldMetadata) { // @ts-expect-error legacy noImplicitAny - newData[key] = formatFieldMetadataValue(value, fieldMetadata); + newData[key] = formatFieldMetadataValue(value, fieldMetadata.type); } else { // @ts-expect-error legacy noImplicitAny newData[key] = value; @@ -99,7 +110,7 @@ export function formatResult( ); } - if (!compositePropertyArgs) { + if (!compositePropertyArgs || !isDefined(fieldMetadata)) { continue; } @@ -112,7 +123,13 @@ export function formatResult( } // @ts-expect-error legacy noImplicitAny - newData[parentField][compositeProperty.name] = value; + newData[parentField][compositeProperty.name] = isNull(value) + ? transformCompositeFieldNullValue( + value, + compositeProperty.name, + fieldMetadata, + ) + : value; } const fieldMetadataItemsOfTypeDateOnly = Object.values( @@ -162,17 +179,50 @@ export function getCompositeFieldMetadataMap( function formatFieldMetadataValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, - fieldMetadata: FieldMetadataEntity, + fieldMetadataType: FieldMetadataType, ) { if ( typeof value === 'string' && - (fieldMetadata.type === FieldMetadataType.MULTI_SELECT || - fieldMetadata.type === FieldMetadataType.ARRAY) + (fieldMetadataType === FieldMetadataType.MULTI_SELECT || + fieldMetadataType === FieldMetadataType.ARRAY) ) { const cleanedValue = value.replace(/{|}/g, '').trim(); return cleanedValue ? cleanedValue.split(',') : []; } + if (isNull(value)) { + if ( + fieldMetadataType === FieldMetadataType.MULTI_SELECT || + fieldMetadataType === FieldMetadataType.ARRAY + ) { + return DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE; + } + + if (fieldMetadataType === FieldMetadataType.RAW_JSON) { + return DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE; + } + + if (fieldMetadataType === FieldMetadataType.TEXT) { + return DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE; + } + + return value; + } + return value; } + +function transformCompositeFieldNullValue( + value: unknown, + compositePropertyName: string, + fieldMetadata: FieldMetadataEntity, +) { + if (!isNull(value)) return value; + + return ( + DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE[fieldMetadata.type]?.[ + compositePropertyName + ] ?? value + ); +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts index 18989fc56d..9cbde4f73b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts @@ -46,7 +46,7 @@ describe('WorkspaceMigrationIndexFactory', () => { expect(firstMigration.indexes[0].columns).toEqual(['simpleField']); expect(firstMigration.indexes[0].type).toBe('BTREE'); expect(firstMigration.indexes[0].isUnique).toBe(true); - expect(firstMigration.indexes[0].where).toBe('"simpleField" != \'\''); + expect(firstMigration.indexes[0].where).toBeNull(); }); it('should create index migrations for relation fields', async () => { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts index a089c37670..03de688634 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts @@ -1,7 +1,7 @@ import { - FieldMetadataType, type CompositeType, compositeTypeDefinitions, + FieldMetadataType, } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -92,17 +92,13 @@ export const createIndexMigration = async ( .flat() .filter(isDefined); - const defaultWhereClause = indexMetadata.isUnique - ? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != ''` - : null; - return { name: indexMetadata.name, action: WorkspaceMigrationIndexActionType.CREATE, isUnique: indexMetadata.isUnique, columns, type: indexMetadata.indexType, - where: indexMetadata.indexWhereClause ?? defaultWhereClause, + where: indexMetadata.indexWhereClause, }; }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-index-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-index-metadata-validator.service.ts index 9f86807fd8..3760918a75 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-index-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-index-metadata-validator.service.ts @@ -8,7 +8,6 @@ import { } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util'; import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util'; @@ -147,11 +146,7 @@ export class FlatIndexValidatorService { if (flatIndexToValidate.isUnique) { if ( isDefined(relatedFlatField.defaultValue) && - !isValidUniqueFieldDefaultValueCombination({ - defaultValue: relatedFlatField.defaultValue, - isUnique: relatedFlatField.isUnique ?? false, - type: relatedFlatField.type, - }) + relatedFlatField.isUnique ) { const fieldName = relatedFlatField.name; const fieldType = relatedFlatField.type; diff --git a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts index 4daa9a6fd1..1f2647da4f 100644 --- a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts +++ b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts @@ -1,8 +1,8 @@ import { msg } from '@lingui/core/macro'; import { + ActorMetadata, FieldMetadataType, RelationOnDeleteAction, - ActorMetadata, } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; @@ -68,6 +68,7 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Attachment type (deprecated - use fileCategory)`, icon: 'IconList', }) + @WorkspaceIsNullable() type: string; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index 426a58ae28..085347b5e7 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -1,10 +1,10 @@ import { msg } from '@lingui/core/macro'; import { - FieldMetadataType, - RelationOnDeleteAction, ActorMetadata, AddressMetadata, + FieldMetadataType, LinksMetadata, + RelationOnDeleteAction, type CurrencyMetadata, } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; @@ -30,8 +30,8 @@ import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-re import { COMPANY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { - type FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, + type FieldTypeAndNameMetadata, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; @@ -71,6 +71,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { description: msg`The company name`, icon: 'IconBuildingSkyscraper', }) + @WorkspaceIsNullable() name: string; @WorkspaceField({ @@ -84,6 +85,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { }, }) @WorkspaceIsUnique() + @WorkspaceIsNullable() domainName: LinksMetadata; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts index 8bcc2d7e1e..d2a1cd8b17 100644 --- a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts +++ b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts @@ -1,8 +1,8 @@ import { msg } from '@lingui/core/macro'; import { + ActorMetadata, FieldMetadataType, RelationOnDeleteAction, - ActorMetadata, type RichTextV2Metadata, } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; @@ -71,6 +71,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Note title`, icon: 'IconNotes', }) + @WorkspaceIsNullable() title: string; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index f2b8c6a53e..424c6521d9 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -1,9 +1,9 @@ import { msg } from '@lingui/core/macro'; import { - FieldMetadataType, - RelationOnDeleteAction, ActorMetadata, type CurrencyMetadata, + FieldMetadataType, + RelationOnDeleteAction, } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; @@ -63,6 +63,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity { description: msg`The opportunity name`, icon: 'IconTargetArrow', }) + @WorkspaceIsNullable() name: string; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index f4796c1040..5c7993992d 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -1,10 +1,10 @@ import { msg } from '@lingui/core/macro'; import { - FieldMetadataType, - RelationOnDeleteAction, ActorMetadata, EmailsMetadata, + FieldMetadataType, PhonesMetadata, + RelationOnDeleteAction, type FullNameMetadata, type LinksMetadata, } from 'twenty-shared/types'; @@ -31,8 +31,8 @@ import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-re import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { - type FieldTypeAndNameMetadata, getTsVectorColumnExpressionFromFields, + type FieldTypeAndNameMetadata, } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; @@ -96,6 +96,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { }, }) @WorkspaceIsUnique() + @WorkspaceIsNullable() emails: EmailsMetadata; @WorkspaceField({ @@ -125,6 +126,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Contact’s job title`, icon: 'IconBriefcase', }) + @WorkspaceIsNullable() jobTitle: string; @WorkspaceField({ @@ -147,6 +149,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { maxNumberOfValues: 1, }, }) + @WorkspaceIsNullable() phones: PhonesMetadata; @WorkspaceField({ @@ -156,6 +159,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Contact’s city`, icon: 'IconMap', }) + @WorkspaceIsNullable() city: string; @WorkspaceField({ @@ -166,6 +170,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { icon: 'IconFileUpload', }) @WorkspaceIsSystem() + @WorkspaceIsNullable() avatarUrl: string; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts index 1001d6bcae..00b61cac9f 100644 --- a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts +++ b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts @@ -1,8 +1,8 @@ import { msg } from '@lingui/core/macro'; import { + ActorMetadata, FieldMetadataType, RelationOnDeleteAction, - ActorMetadata, type RichTextV2Metadata, } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; @@ -74,6 +74,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Task title`, icon: 'IconNotes', }) + @WorkspaceIsNullable() title: string; @WorkspaceField({ diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/text-field-create-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/text-field-create-input-validation.integration-spec.ts.snap index c1b79e36be..afe7fe87c5 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/text-field-create-input-validation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/text-field-create-input-validation.integration-spec.ts.snap @@ -6,8 +6,6 @@ exports[`Create input validation - TEXT Gql create input - failure TEXT - should exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":1} 1`] = `"String cannot represent a non string value: 1"`; -exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":null} 1`] = `"null value in column "textField" of relation "_apiInputValidationTestObject" violates not-null constraint"`; - exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":true} 1`] = `"String cannot represent a non string value: true"`; exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":[]} 1`] = `"["Invalid string value [] for text field \\"textField\\""]"`; @@ -16,6 +14,4 @@ exports[`Create input validation - TEXT Rest create input - failure TEXT - shoul exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":1} 1`] = `"["Invalid string value 1 for text field \\"textField\\""]"`; -exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":null} 1`] = `"["null value in column \\"textField\\" of relation \\"_apiInputValidationTestObject\\" violates not-null constraint"]"`; - exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":true} 1`] = `"["Invalid string value true for text field \\"textField\\""]"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts index 8439334146..4cd5737704 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts @@ -7,11 +7,6 @@ export const failingCreateInputByFieldMetadataType: { }[]; } = { [FieldMetadataType.TEXT]: [ - { - input: { - textField: null, - }, - }, { input: { textField: {}, diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts index 544edfc8bb..6175e831d4 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts @@ -134,7 +134,10 @@ export const successfulCreateInputByFieldMetadataType: { rawJsonField: {}, }, validateInput: (record: Record) => { - return Object.keys(record.rawJsonField).length === 0; + return ( + typeof record.rawJsonField === 'object' && + Object.keys(record.rawJsonField).length === 0 + ); }, }, { @@ -142,7 +145,10 @@ export const successfulCreateInputByFieldMetadataType: { rawJsonField: null, }, validateInput: (record: Record) => { - return record.rawJsonField === null; + return ( + typeof record.rawJsonField === 'object' && + Object.keys(record.rawJsonField).length === 0 + ); }, }, { @@ -182,7 +188,9 @@ export const successfulCreateInputByFieldMetadataType: { arrayField: [], }, validateInput: (record: Record) => { - return record.arrayField.length === 0; + return ( + Array.isArray(record.arrayField) && record.arrayField.length === 0 + ); }, }, { @@ -190,7 +198,9 @@ export const successfulCreateInputByFieldMetadataType: { arrayField: null, }, validateInput: (record: Record) => { - return record.arrayField === null; + return ( + Array.isArray(record.arrayField) && record.arrayField.length === 0 + ); }, }, ], @@ -229,7 +239,10 @@ export const successfulCreateInputByFieldMetadataType: { multiSelectField: [], }, validateInput: (record: Record) => { - return record.multiSelectField.length === 0; + return ( + Array.isArray(record.multiSelectField) && + record.multiSelectField.length === 0 + ); }, }, { @@ -237,7 +250,10 @@ export const successfulCreateInputByFieldMetadataType: { multiSelectField: null, }, validateInput: (record: Record) => { - return record.multiSelectField === null; + return ( + Array.isArray(record.multiSelectField) && + record.multiSelectField.length === 0 + ); }, }, ], diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts index 9ed57b09da..1dee44c6ca 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant.ts @@ -4,7 +4,7 @@ import { TEST_UUID_FIELD_VALUE, } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util'; import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; export const successfulFilterInputByFieldMetadataType: { [K in FieldMetadataTypesToTestForFilterInputValidation]: { @@ -803,7 +803,10 @@ export const successfulFilterInputByFieldMetadataType: { gqlFilterInput: { multiSelectField: { is: 'NULL' } }, restFilterInput: 'multiSelectField[is]:NULL', validateFilter: (record: Record) => { - return record.multiSelectField === null; + return ( + Array.isArray(record.multiSelectField) && + record.multiSelectField.length === 0 + ); }, }, { @@ -892,7 +895,7 @@ export const successfulFilterInputByFieldMetadataType: { gqlFilterInput: { rawJsonField: { is: 'NULL' } }, restFilterInput: 'rawJsonField[is]:NULL', validateFilter: (record: Record) => { - return record.rawJsonField === null; + return isEmptyObject(record.rawJsonField); }, }, { @@ -923,7 +926,9 @@ export const successfulFilterInputByFieldMetadataType: { gqlFilterInput: { arrayField: { is: 'NULL' } }, restFilterInput: 'arrayField[is]:NULL', validateFilter: (record: Record) => { - return record.arrayField === null; + return ( + Array.isArray(record.arrayField) && record.arrayField.length === 0 + ); }, }, //TODO - null and empty array should be equivalent diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/multi-select-field-filter-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/multi-select-field-filter-input-validation.integration-spec.ts index 0632db0b7d..d0a2e6950b 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/multi-select-field-filter-input-validation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/multi-select-field-filter-input-validation.integration-spec.ts @@ -55,7 +55,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => { ); }); - // TODO : Refacto-common - Uncomment this describe('Rest filter input - failure', () => { it.each( failingTestCases.map((testCase) => ({ diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-phone-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-phone-field-metadata.integration-spec.ts index 6b2cfcfef8..1ad1b38179 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-phone-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-phone-field-metadata.integration-spec.ts @@ -40,7 +40,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext { it('should return true for empty object', () => { diff --git a/packages/twenty-front/src/utils/isEmptyObject.ts b/packages/twenty-shared/src/utils/validation/isEmptyObject.ts similarity index 100% rename from packages/twenty-front/src/utils/isEmptyObject.ts rename to packages/twenty-shared/src/utils/validation/isEmptyObject.ts