Null equivalence - update filter (#16123)

This commit is contained in:
Etienne
2025-11-27 13:31:39 +00:00
committed by GitHub
parent 76ed82b598
commit 57ae12ff7c
7 changed files with 475 additions and 56 deletions
@@ -501,8 +501,24 @@ describe('evaluateFilterConditions', () => {
'TEXT',
);
const filter3 = createFilter(
ViewFilterOperand.CONTAINS,
null,
'',
'TEXT',
);
const filter4 = createFilter(
ViewFilterOperand.CONTAINS,
'',
null,
'TEXT',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter4] })).toBe(true);
});
it('should handle DoesNotContain operand with strings', () => {
@@ -519,8 +535,16 @@ describe('evaluateFilterConditions', () => {
'TEXT',
);
const filter3 = createFilter(
ViewFilterOperand.DOES_NOT_CONTAIN,
null,
'',
'TEXT',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
it('should handle Contains operand with arrays', () => {
@@ -537,8 +561,32 @@ describe('evaluateFilterConditions', () => {
'ARRAY',
);
const filter3 = createFilter(
ViewFilterOperand.CONTAINS,
null,
[],
'ARRAY',
);
const filter4 = createFilter(
ViewFilterOperand.CONTAINS,
[],
null,
'ARRAY',
);
const filter5 = createFilter(
ViewFilterOperand.CONTAINS,
null,
['apple'],
'ARRAY',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter4] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter5] })).toBe(false);
});
it('should handle DoesNotContain operand with arrays', () => {
@@ -555,8 +603,16 @@ describe('evaluateFilterConditions', () => {
'ARRAY',
);
const filter3 = createFilter(
ViewFilterOperand.DOES_NOT_CONTAIN,
null,
['apple'],
'ARRAY',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(true);
});
});
@@ -0,0 +1,233 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
DEFAULT_ARRAY_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 { findDefaultNullEquivalentValue } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/find-default-null-equivalent-value.util';
describe('findDefaultNullEquivalentValue', () => {
describe('Simple Types', () => {
describe('TEXT', () => {
it('should return DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for null', () => {
expect(
findDefaultNullEquivalentValue({
value: null,
fieldMetadataType: FieldMetadataType.TEXT,
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for empty string', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.TEXT,
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return undefined for non-null equivalent value', () => {
expect(
findDefaultNullEquivalentValue({
value: 'value',
fieldMetadataType: FieldMetadataType.TEXT,
}),
).toBeUndefined();
});
});
describe('ARRAY', () => {
it('should return DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for null', () => {
expect(
findDefaultNullEquivalentValue({
value: null,
fieldMetadataType: FieldMetadataType.ARRAY,
}),
).toBe(DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for empty array', () => {
expect(
findDefaultNullEquivalentValue({
value: [],
fieldMetadataType: FieldMetadataType.ARRAY,
}),
).toBe(DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
});
describe('ACTOR', () => {
it('should return text default for name', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.ACTOR,
key: 'name',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return undefined for context', () => {
expect(
findDefaultNullEquivalentValue({
value: {},
fieldMetadataType: FieldMetadataType.ACTOR,
key: 'context',
}),
).toBe(undefined);
});
});
describe('ADDRESS', () => {
it.each([
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressPostcode',
'addressCountry',
])('should return text default for %s', (key) => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.ADDRESS,
key,
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('EMAILS', () => {
it('should return text default for primaryEmail', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.EMAILS,
key: 'primaryEmail',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return array default for additionalEmails', () => {
expect(
findDefaultNullEquivalentValue({
value: [],
fieldMetadataType: FieldMetadataType.EMAILS,
key: 'additionalEmails',
}),
).toBe(DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('LINKS', () => {
it('should return text default for primaryLinkUrl', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.LINKS,
key: 'primaryLinkUrl',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return text default for primaryLinkLabel', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.LINKS,
key: 'primaryLinkLabel',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('PHONES', () => {
it('should return text default for primaryPhoneNumber', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.PHONES,
key: 'primaryPhoneNumber',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return text default for primaryPhoneCountryCode', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.PHONES,
key: 'primaryPhoneCountryCode',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return text default for primaryPhoneCallingCode', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.PHONES,
key: 'primaryPhoneCallingCode',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('RICH_TEXT_V2', () => {
it('should return undefined for blocknote', () => {
expect(
findDefaultNullEquivalentValue({
value: {},
fieldMetadataType: FieldMetadataType.RICH_TEXT_V2,
key: 'blocknote',
}),
).toBe(undefined);
});
it('should return text default for markdown', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.RICH_TEXT_V2,
key: 'markdown',
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('FULL_NAME', () => {
it.each(['firstName', 'lastName'])(
'should return text default for %s',
(key) => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.FULL_NAME,
key,
}),
).toBe(DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
},
);
});
});
it('should return undefined for unknown type', () => {
expect(
findDefaultNullEquivalentValue({
value: null,
fieldMetadataType: 'UNKNOWN' as any,
}),
).toBeUndefined();
});
it('should return undefined for unknown composite key', () => {
expect(
findDefaultNullEquivalentValue({
value: '',
fieldMetadataType: FieldMetadataType.ACTOR,
key: 'unknown',
}),
).toBeUndefined();
});
@@ -10,9 +10,13 @@ import {
ViewFilterOperand,
type ViewFilterOperandDeprecated,
} from 'twenty-shared/types';
import { convertViewFilterOperandToCoreOperand as convertViewFilterOperandDeprecated } from 'twenty-shared/utils';
import {
convertViewFilterOperandToCoreOperand as convertViewFilterOperandDeprecated,
isDefined,
} from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
import { findDefaultNullEquivalentValue } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/find-default-null-equivalent-value.util';
import { parseAndEvaluateRelativeDateFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util';
type ResolvedFilterWithPotentiallyDeprecatedOperand = Omit<
@@ -59,7 +63,11 @@ function evaluateFilter(
case 'ARRAY':
case 'array':
case 'RAW_JSON':
return evaluateTextAndArrayFilter(filterWithConvertedOperand);
return evaluateTextAndArrayFilter(
filterWithConvertedOperand,
filter.type,
filter.compositeFieldSubFieldName,
);
case 'SELECT':
return evaluateSelectFilter(filterWithConvertedOperand);
case 'BOOLEAN':
@@ -146,12 +154,31 @@ function contains(leftValue: unknown, rightValue: unknown): boolean {
return String(leftValue).includes(String(rightValue));
}
function evaluateTextAndArrayFilter(filter: ResolvedFilter): boolean {
function evaluateTextAndArrayFilter(
filter: ResolvedFilter,
filterType: string,
compositeFieldSubFieldName: string | undefined,
): boolean {
//TODO : nullEquivalentRightValue to remove once feature flag removed + workflow action based on common api
const nullEquivalentRightValue = findDefaultNullEquivalentValue({
value: filter.rightOperand,
fieldMetadataType: filterType,
key: compositeFieldSubFieldName,
});
switch (filter.operand) {
case ViewFilterOperand.CONTAINS:
return contains(filter.leftOperand, filter.rightOperand);
return (
contains(filter.leftOperand, filter.rightOperand) ||
(isDefined(nullEquivalentRightValue) &&
!isNotEmptyTextOrArray(filter.leftOperand))
);
case ViewFilterOperand.DOES_NOT_CONTAIN:
return !contains(filter.leftOperand, filter.rightOperand);
return (
!contains(filter.leftOperand, filter.rightOperand) ||
(isDefined(nullEquivalentRightValue) &&
isNotEmptyTextOrArray(filter.leftOperand))
);
case ViewFilterOperand.IS_EMPTY:
return !isNotEmptyTextOrArray(filter.leftOperand);
@@ -0,0 +1,151 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
DEFAULT_ARRAY_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 { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-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 findDefaultNullEquivalentValue = ({
value,
fieldMetadataType,
key,
}: {
value: unknown;
fieldMetadataType: string;
key?: string;
}) => {
switch (fieldMetadataType) {
case FieldMetadataType.TEXT:
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case FieldMetadataType.MULTI_SELECT:
case FieldMetadataType.ARRAY:
return isNullEquivalentArrayFieldValue(value)
? DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case FieldMetadataType.ACTOR: {
switch (key) {
case 'name':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.ADDRESS: {
switch (key) {
case 'addressStreet1':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressStreet2':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressCity':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressState':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressPostcode':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressCountry':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.EMAILS: {
switch (key) {
case 'primaryEmail':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'additionalEmails':
return isNullEquivalentArrayFieldValue(value)
? DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.LINKS: {
switch (key) {
case 'primaryLinkUrl':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryLinkLabel':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'secondaryLinks':
return isNullEquivalentArrayFieldValue(value)
? DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.PHONES: {
switch (key) {
case 'primaryPhoneNumber':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryPhoneCountryCode':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryPhoneCallingCode':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'additionalPhones':
return isNullEquivalentArrayFieldValue(value)
? DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.RICH_TEXT_V2: {
switch (key) {
case 'markdown':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.FULL_NAME: {
switch (key) {
case 'firstName':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'lastName':
return isNullEquivalentTextFieldValue(value)
? DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
}
return undefined;
};