# Implementation Details - Added support for 5 operators: `contains`, `containsAny`, `containsAll`, `matches`, and `fuzzy` - Works on any field of type `TS_VECTOR` - Added PostgreSQL `pg_trgm` extension for fuzzy search functionality. The extension provides the `similarity()` function needed for text similarity searches. - Not implemented in GraphQL ## Tradeoffs & Decisions 1. **Fuzzy Search Performance**: Using `pg_trgm` for fuzzy search is more accurate but slower than simple text matching. We might want to add a similarity threshold parameter in the future to control the tradeoff between accuracy and performance. 2. **Operator Naming**: Chose `contains`/`containsAny`/`containsAll` to be consistent with existing filter operators, though they might be less intuitive than `search`/`searchAny`/`searchAll`. ## Demo https://github.com/user-attachments/assets/790fc3ed-a188-4b49-864f-996a37481d99 --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
137 lines
4.0 KiB
TypeScript
137 lines
4.0 KiB
TypeScript
import { ObjectLiteral } from 'typeorm';
|
|
|
|
import {
|
|
GraphqlQueryRunnerException,
|
|
GraphqlQueryRunnerExceptionCode,
|
|
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
|
|
|
type WhereConditionParts = {
|
|
sql: string;
|
|
params: ObjectLiteral;
|
|
};
|
|
|
|
export const computeWhereConditionParts = ({
|
|
operator,
|
|
objectNameSingular,
|
|
key,
|
|
value,
|
|
}: {
|
|
operator: string;
|
|
objectNameSingular: string;
|
|
key: string;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
value: any;
|
|
}): WhereConditionParts => {
|
|
const uuid = Math.random().toString(36).slice(2, 7);
|
|
|
|
switch (operator) {
|
|
case 'isEmptyArray':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" = '{}'`,
|
|
params: {},
|
|
};
|
|
case 'eq':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'neq':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'gt':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" > :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'gte':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" >= :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'lt':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" < :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'lte':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" <= :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'in':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" IN (:...${key}${uuid})`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'is':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`,
|
|
params: {},
|
|
};
|
|
case 'like':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: `${value}` },
|
|
};
|
|
case 'ilike':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: `${value}` },
|
|
};
|
|
case 'startsWith':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: `${value}` },
|
|
};
|
|
case 'endsWith':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
|
|
params: { [`${key}${uuid}`]: `${value}` },
|
|
};
|
|
case 'contains':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'search': {
|
|
const tsQuery = value
|
|
.split(/\s+/)
|
|
.map((term: string) => `${term}:*`)
|
|
.join(' & ');
|
|
|
|
return {
|
|
sql: `(
|
|
"${objectNameSingular}"."${key}" @@ to_tsquery('simple', :${key}${uuid}Ts) OR
|
|
"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}Like
|
|
)`,
|
|
params: {
|
|
[`${key}${uuid}Ts`]: tsQuery,
|
|
[`${key}${uuid}Like`]: `%${value}%`,
|
|
},
|
|
};
|
|
}
|
|
case 'notContains':
|
|
return {
|
|
sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'containsAny':
|
|
return {
|
|
sql: `"${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[]`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
case 'containsIlike':
|
|
return {
|
|
sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`,
|
|
params: { [`${key}${uuid}`]: value },
|
|
};
|
|
default:
|
|
throw new GraphqlQueryRunnerException(
|
|
`Operator "${operator}" is not supported`,
|
|
GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR,
|
|
);
|
|
}
|
|
};
|