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
@@ -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, Record<string, unknown>>
> = {
[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,
};
@@ -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<ObjectRecord>[] = [];
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<unknown> {
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);
@@ -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,
};
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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();
});
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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;
};
@@ -0,0 +1,3 @@
export const isNullEquivalentArrayFieldValue = (value: unknown): boolean => {
return (Array.isArray(value) && value.length === 0) || value === null;
};
@@ -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);
};
@@ -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
);
};
@@ -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}"`,