feat: Add TS vector field filters support (#12376)

# 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>
This commit is contained in:
Abdul Rahman
2025-05-30 19:24:50 +05:30
committed by GitHub
parent b7473371b3
commit c7139cbc84
13 changed files with 76 additions and 28 deletions

View File

@ -64,12 +64,12 @@ export class GraphqlQueryFilterFieldParser {
); );
} }
const { sql, params } = computeWhereConditionParts( const { sql, params } = computeWhereConditionParts({
operator, operator,
objectNameSingular, objectNameSingular,
key, key,
value, value,
); });
if (isFirst) { if (isFirst) {
queryBuilder.where(sql, params); queryBuilder.where(sql, params);
@ -124,12 +124,12 @@ export class GraphqlQueryFilterFieldParser {
); );
} }
const { sql, params } = computeWhereConditionParts( const { sql, params } = computeWhereConditionParts({
operator, operator,
objectNameSingular, objectNameSingular,
fullFieldName, key: fullFieldName,
value, value,
); });
if (isFirst && index === 0) { if (isFirst && index === 0) {
queryBuilder.where(sql, params); queryBuilder.where(sql, params);

View File

@ -10,13 +10,18 @@ type WhereConditionParts = {
params: ObjectLiteral; params: ObjectLiteral;
}; };
export const computeWhereConditionParts = ( export const computeWhereConditionParts = ({
operator: string, operator,
objectNameSingular: string, objectNameSingular,
key: string, key,
value,
}: {
operator: string;
objectNameSingular: string;
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any, value: any;
): WhereConditionParts => { }): WhereConditionParts => {
const uuid = Math.random().toString(36).slice(2, 7); const uuid = Math.random().toString(36).slice(2, 7);
switch (operator) { switch (operator) {
@ -90,6 +95,23 @@ export const computeWhereConditionParts = (
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`, sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
params: { [`${key}${uuid}`]: value }, 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': case 'notContains':
return { return {
sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`, sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`,
@ -105,7 +127,6 @@ export const computeWhereConditionParts = (
sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`, sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`,
params: { [`${key}${uuid}`]: value }, params: { [`${key}${uuid}`]: value },
}; };
default: default:
throw new GraphqlQueryRunnerException( throw new GraphqlQueryRunnerException(
`Operator "${operator}" is not supported`, `Operator "${operator}" is not supported`,

View File

@ -0,0 +1,8 @@
import { GraphQLInputObjectType, GraphQLString } from 'graphql';
export const TSVectorFilterType = new GraphQLInputObjectType({
name: 'TSVectorFilter',
fields: {
search: { type: GraphQLString },
},
});

View File

@ -5,13 +5,17 @@ import { DateScalarType } from './date.scalar';
import { PositionScalarType } from './position.scalar'; import { PositionScalarType } from './position.scalar';
import { RawJSONScalar } from './raw-json.scalar'; import { RawJSONScalar } from './raw-json.scalar';
import { TimeScalarType } from './time.scalar'; import { TimeScalarType } from './time.scalar';
import { TSVectorScalarType } from './ts-vector.scalar';
import { UUIDScalarType } from './uuid.scalar'; import { UUIDScalarType } from './uuid.scalar';
export * from './big-float.scalar'; export * from './big-float.scalar';
export * from './big-int.scalar'; export * from './big-int.scalar';
export * from './cursor.scalar'; export * from './cursor.scalar';
export * from './date.scalar'; export * from './date.scalar';
export * from './position.scalar';
export * from './raw-json.scalar';
export * from './time.scalar'; export * from './time.scalar';
export * from './ts-vector.scalar';
export * from './uuid.scalar'; export * from './uuid.scalar';
export const scalars = [ export const scalars = [
@ -23,4 +27,5 @@ export const scalars = [
CursorScalarType, CursorScalarType,
PositionScalarType, PositionScalarType,
RawJSONScalar, RawJSONScalar,
TSVectorScalarType,
]; ];

View File

@ -0,0 +1,19 @@
import { GraphQLScalarType } from 'graphql';
export const TSVectorScalarType = new GraphQLScalarType({
name: 'TSVector',
description: 'A custom scalar type for PostgreSQL tsvector fields',
serialize(value: string): string {
return value;
},
parseValue(value: string): string {
return value;
},
parseLiteral(ast): string | null {
if (ast.kind === 'StringValue') {
return ast.value;
}
return null;
},
});

View File

@ -30,9 +30,11 @@ import {
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type'; import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type'; import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type'; import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/ts-vector-filter.input-type';
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type'; import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
import { import {
BigFloatScalarType, BigFloatScalarType,
TSVectorScalarType,
UUIDScalarType, UUIDScalarType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PositionScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/position.scalar'; import { PositionScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/position.scalar';
@ -83,7 +85,7 @@ export class TypeMapperService {
StringArrayScalarType as unknown as GraphQLScalarType, StringArrayScalarType as unknown as GraphQLScalarType,
], ],
[FieldMetadataType.RICH_TEXT, GraphQLString], [FieldMetadataType.RICH_TEXT, GraphQLString],
[FieldMetadataType.TS_VECTOR, GraphQLString], [FieldMetadataType.TS_VECTOR, TSVectorScalarType],
]); ]);
return typeScalarMapping.get(fieldMetadataType); return typeScalarMapping.get(fieldMetadataType);
@ -122,7 +124,7 @@ export class TypeMapperService {
[FieldMetadataType.ARRAY, ArrayFilterType], [FieldMetadataType.ARRAY, ArrayFilterType],
[FieldMetadataType.MULTI_SELECT, MultiSelectFilterType], [FieldMetadataType.MULTI_SELECT, MultiSelectFilterType],
[FieldMetadataType.SELECT, SelectFilterType], [FieldMetadataType.SELECT, SelectFilterType],
[FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType [FieldMetadataType.TS_VECTOR, TSVectorFilterType],
]); ]);
return typeFilterMapping.get(fieldMetadataType); return typeFilterMapping.get(fieldMetadataType);

View File

@ -158,6 +158,5 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -285,6 +285,5 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -160,6 +160,5 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -240,6 +240,5 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -304,6 +304,5 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -215,6 +215,5 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }

View File

@ -358,6 +358,5 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN }) @WorkspaceFieldIndex({ indexType: IndexType.GIN })
// eslint-disable-next-line @typescript-eslint/no-explicit-any searchVector: string;
searchVector: any;
} }