common api - null equivalence (#15926)

closes https://github.com/twentyhq/core-team-issues/issues/1629

To do before requesting review : 
- filter update

Migration to come in an other PR


Strat : 
1/  Null transformation

- [x] Transform NULL equivalent value to NULL in field validation in
common api - pre-query - with feature flag
- [ ] Same logic in ORM (Not done, complex to handle feature flag here)
- [x] Transform NULL value to equivalent in data formatting in ORM -
post-query

2/ Migration (in other PR) for fieldMetadata not nullable with default
defaultValue (empty string, ...)

- [ ] Remove NOT NULL db constraint
- [ ] Update record value to NULL
- [ ] Update field metadata : isNullable:true
- [ ] Update uniqueIndex whereClause (also for standard uniqueIndex) 
- [ ] Activate feature flag

3/ Update metadata creation

- [x] No more default default value
- [x] Update standard field nullability
- [x] Remove index default whereClause for standard field

4/ Update filter

- [x] When filtering on NULL or empty string, be sure all records are
returned (the one with NULL + the one with "")

5/ Test

- [ ] Strat. to do
This commit is contained in:
Etienne
2025-11-24 18:57:47 +01:00
committed by GitHub
parent 3ed6d2a16b
commit 208c0857ee
59 changed files with 884 additions and 300 deletions
@@ -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',
}
@@ -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);
@@ -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 */
}
@@ -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';
@@ -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,