Files
twenty/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts
T
oniani1 3f87d27d5d fix: use AND instead of OR in neq filter for null-equivalent values (#19071)
Fixes #19070

The `neq` operator in `compute-where-condition-parts.ts` uses `OR` where
it should use `AND` when handling null-equivalent values.

Currently generates:
```sql
field != '' OR field IS NOT NULL
```

For a row where `field = ''`:
- `'' != ''` = false
- `'' IS NOT NULL` = true
- `false OR true` = true -- row incorrectly passes the filter

The `eq` operator correctly uses `OR field IS NULL` because it's
additive (match value or its null equivalent). By De Morgan's law, the
negation `neq` needs `AND field IS NOT NULL` -- exclude if the value
doesn't match AND is not a null equivalent.

With the fix:
```sql
field != '' AND field IS NOT NULL
```
- `'' != ''` = false, `'' IS NOT NULL` = true, `false AND true` = false
-- correctly excluded
- `NULL != ''` = NULL, `NULL IS NOT NULL` = false, `NULL AND false` =
false -- correctly excluded
- `'Alice' != ''` = true, `'Alice' IS NOT NULL` = true, `true AND true`
= true -- correctly included

Affects `neq` filters on TEXT fields and all composite sub-fields
(firstName, lastName, primaryEmail, primaryPhoneNumber, address
sub-fields, etc.) when filtering against null-equivalent values.

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-07 07:36:41 +00:00

165 lines
5.8 KiB
TypeScript

import { randomBytes } from 'crypto';
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 { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
type WhereConditionParts = {
sql: string;
params: ObjectLiteral;
};
export const computeWhereConditionParts = ({
operator,
objectNameSingular,
key,
subFieldKey,
value,
fieldMetadataType,
useDirectTableReference = false,
}: {
operator: string;
objectNameSingular: string;
key: string;
subFieldKey?: string;
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
value: any;
fieldMetadataType: FieldMetadataType;
useDirectTableReference?: boolean;
}): WhereConditionParts => {
const paramSuffix = randomBytes(5).toString('hex');
const secondParamSuffix = randomBytes(5).toString('hex');
const fieldReference = useDirectTableReference
? `"${key}"`
: `"${objectNameSingular}"."${key}"`;
//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: `${fieldReference} = '{}'${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`,
params: {},
};
case 'eq':
return {
sql: `${fieldReference} = :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'neq':
return {
sql: `${fieldReference} != :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` AND ${fieldReference} IS NOT NULL` : ''}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'gt':
return {
sql: `${fieldReference} > :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'gte':
return {
sql: `${fieldReference} >= :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'lt':
return {
sql: `${fieldReference} < :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'lte':
return {
sql: `${fieldReference} <= :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'in':
return {
sql: `${fieldReference} IN (:...${key}${paramSuffix})`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'is':
return {
sql: `${fieldReference} IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}${hasNullEquivalentFieldValue ? ` OR ${fieldReference} = :${key}${secondParamSuffix}` : ''}`,
params: hasNullEquivalentFieldValue
? { [`${key}${secondParamSuffix}`]: nullEquivalentFieldValue }
: {},
};
case 'like':
return {
sql: `${fieldReference}::text LIKE :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`,
params: { [`${key}${paramSuffix}`]: `${value}` },
};
case 'ilike':
return {
sql: `${fieldReference}::text ILIKE :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`,
params: { [`${key}${paramSuffix}`]: `${value}` },
};
case 'startsWith':
return {
sql: `${fieldReference}::text ^@ :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: `${value}` },
};
case 'endsWith':
return {
sql: `RIGHT(${fieldReference}::text, LENGTH(:${key}${paramSuffix})) = :${key}${paramSuffix}`,
params: { [`${key}${paramSuffix}`]: `${value}` },
};
case 'contains':
return {
sql: `${fieldReference} @> ARRAY[:...${key}${paramSuffix}]`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'search': {
const tsQuery = formatSearchTerms(value, 'and');
return {
sql: `(
${fieldReference} @@ to_tsquery('simple', public.unaccent_immutable(:${key}${paramSuffix}Ts)) OR
public.unaccent_immutable(${fieldReference}::text) ILIKE public.unaccent_immutable(:${key}${paramSuffix}Like)
)`,
params: {
[`${key}${paramSuffix}Ts`]: tsQuery,
[`${key}${paramSuffix}Like`]: `%${value}%`,
},
};
}
case 'notContains':
return {
sql: `NOT (${fieldReference}::text[] && ARRAY[:...${key}${paramSuffix}]::text[])`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'containsAny':
return {
sql: `${fieldReference}::text[] && ARRAY[:...${key}${paramSuffix}]::text[]`,
params: { [`${key}${paramSuffix}`]: value },
};
case 'containsIlike':
return {
sql: `EXISTS (SELECT 1 FROM unnest(${fieldReference}) AS elem WHERE elem ILIKE :${key}${paramSuffix})`,
params: { [`${key}${paramSuffix}`]: value },
};
default:
throw new GraphqlQueryRunnerException(
`Operator "${operator}" is not supported`,
GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
};