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
@@ -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);
});
});
@@ -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);
});
});
@@ -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<FieldMetadataType.TEXT>;
case FieldMetadataType.EMAILS:
return {
primaryEmail: "''",
additionalEmails: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.EMAILS>;
case FieldMetadataType.FULL_NAME:
return {
firstName: "''",
lastName: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.FULL_NAME>;
case FieldMetadataType.ADDRESS:
return {
addressStreet1: "''",
addressStreet2: "''",
addressCity: "''",
addressState: "''",
addressCountry: "''",
addressPostcode: "''",
addressLat: null,
addressLng: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.ADDRESS>;
case FieldMetadataType.CURRENCY:
return {
amountMicros: null,
currencyCode: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.CURRENCY>;
case FieldMetadataType.LINKS:
return {
primaryLinkLabel: "''",
primaryLinkUrl: "''",
secondaryLinks: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.LINKS>;
case FieldMetadataType.PHONES:
return {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: "''",
additionalPhones: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.PHONES>;
case FieldMetadataType.ACTOR:
return {
source: `'${FieldActorSource.MANUAL}'`,
@@ -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;
}
@@ -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<string, string | null>)?.[name] ===
(defaultDefaultValue as Record<string, string | null>)?.[name],
) ?? false;
return doUniquePropertiesHaveDefaultValues;
};
@@ -27,7 +27,6 @@ export const prepareCustomFieldMetadataForCreation = (
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
isNullable: generateNullable(
fieldMetadataInput.type,
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
@@ -34,7 +34,6 @@ export const getDefaultFlatFieldMetadata = ({
isCustom: true,
isLabelSyncedWithName: createFieldInput.isLabelSyncedWithName ?? false,
isNullable: generateNullable(
createFieldInput.type,
createFieldInput.isNullable,
createFieldInput.isRemoteCreation,
),
@@ -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 {
@@ -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" != \'\'');
});
});
@@ -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<FieldMetadataEntity, 'type' | 'name'>,
fieldMetadata: Pick<FieldMetadataEntity, 'type' | 'name' | 'defaultValue'>,
) => {
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,
];
}
})