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,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) {
@@ -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':