Skip to content

Commit c7139cb

Browse files
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>
1 parent b747337 commit c7139cb

File tree

13 files changed

+76
-28
lines changed

13 files changed

+76
-28
lines changed

packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ export class GraphqlQueryFilterFieldParser {
6464
);
6565
}
6666

67-
const { sql, params } = computeWhereConditionParts(
67+
const { sql, params } = computeWhereConditionParts({
6868
operator,
6969
objectNameSingular,
7070
key,
7171
value,
72-
);
72+
});
7373

7474
if (isFirst) {
7575
queryBuilder.where(sql, params);
@@ -124,12 +124,12 @@ export class GraphqlQueryFilterFieldParser {
124124
);
125125
}
126126

127-
const { sql, params } = computeWhereConditionParts(
127+
const { sql, params } = computeWhereConditionParts({
128128
operator,
129129
objectNameSingular,
130-
fullFieldName,
130+
key: fullFieldName,
131131
value,
132-
);
132+
});
133133

134134
if (isFirst && index === 0) {
135135
queryBuilder.where(sql, params);

packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ type WhereConditionParts = {
1010
params: ObjectLiteral;
1111
};
1212

13-
export const computeWhereConditionParts = (
14-
operator: string,
15-
objectNameSingular: string,
16-
key: string,
13+
export const computeWhereConditionParts = ({
14+
operator,
15+
objectNameSingular,
16+
key,
17+
value,
18+
}: {
19+
operator: string;
20+
objectNameSingular: string;
21+
key: string;
1722
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18-
value: any,
19-
): WhereConditionParts => {
23+
value: any;
24+
}): WhereConditionParts => {
2025
const uuid = Math.random().toString(36).slice(2, 7);
2126

2227
switch (operator) {
@@ -90,6 +95,23 @@ export const computeWhereConditionParts = (
9095
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
9196
params: { [`${key}${uuid}`]: value },
9297
};
98+
case 'search': {
99+
const tsQuery = value
100+
.split(/\s+/)
101+
.map((term: string) => `${term}:*`)
102+
.join(' & ');
103+
104+
return {
105+
sql: `(
106+
"${objectNameSingular}"."${key}" @@ to_tsquery('simple', :${key}${uuid}Ts) OR
107+
"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}Like
108+
)`,
109+
params: {
110+
[`${key}${uuid}Ts`]: tsQuery,
111+
[`${key}${uuid}Like`]: `%${value}%`,
112+
},
113+
};
114+
}
93115
case 'notContains':
94116
return {
95117
sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`,
@@ -105,7 +127,6 @@ export const computeWhereConditionParts = (
105127
sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`,
106128
params: { [`${key}${uuid}`]: value },
107129
};
108-
109130
default:
110131
throw new GraphqlQueryRunnerException(
111132
`Operator "${operator}" is not supported`,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { GraphQLInputObjectType, GraphQLString } from 'graphql';
2+
3+
export const TSVectorFilterType = new GraphQLInputObjectType({
4+
name: 'TSVectorFilter',
5+
fields: {
6+
search: { type: GraphQLString },
7+
},
8+
});

packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import { DateScalarType } from './date.scalar';
55
import { PositionScalarType } from './position.scalar';
66
import { RawJSONScalar } from './raw-json.scalar';
77
import { TimeScalarType } from './time.scalar';
8+
import { TSVectorScalarType } from './ts-vector.scalar';
89
import { UUIDScalarType } from './uuid.scalar';
910

1011
export * from './big-float.scalar';
1112
export * from './big-int.scalar';
1213
export * from './cursor.scalar';
1314
export * from './date.scalar';
15+
export * from './position.scalar';
16+
export * from './raw-json.scalar';
1417
export * from './time.scalar';
18+
export * from './ts-vector.scalar';
1519
export * from './uuid.scalar';
1620

1721
export const scalars = [
@@ -23,4 +27,5 @@ export const scalars = [
2327
CursorScalarType,
2428
PositionScalarType,
2529
RawJSONScalar,
30+
TSVectorScalarType,
2631
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { GraphQLScalarType } from 'graphql';
2+
3+
export const TSVectorScalarType = new GraphQLScalarType({
4+
name: 'TSVector',
5+
description: 'A custom scalar type for PostgreSQL tsvector fields',
6+
serialize(value: string): string {
7+
return value;
8+
},
9+
parseValue(value: string): string {
10+
return value;
11+
},
12+
parseLiteral(ast): string | null {
13+
if (ast.kind === 'StringValue') {
14+
return ast.value;
15+
}
16+
17+
return null;
18+
},
19+
});

packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import {
3030
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
3131
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
3232
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
33+
import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/ts-vector-filter.input-type';
3334
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
3435
import {
3536
BigFloatScalarType,
37+
TSVectorScalarType,
3638
UUIDScalarType,
3739
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
3840
import { PositionScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/position.scalar';
@@ -83,7 +85,7 @@ export class TypeMapperService {
8385
StringArrayScalarType as unknown as GraphQLScalarType,
8486
],
8587
[FieldMetadataType.RICH_TEXT, GraphQLString],
86-
[FieldMetadataType.TS_VECTOR, GraphQLString],
88+
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
8789
]);
8890

8991
return typeScalarMapping.get(fieldMetadataType);
@@ -122,7 +124,7 @@ export class TypeMapperService {
122124
[FieldMetadataType.ARRAY, ArrayFilterType],
123125
[FieldMetadataType.MULTI_SELECT, MultiSelectFilterType],
124126
[FieldMetadataType.SELECT, SelectFilterType],
125-
[FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType
127+
[FieldMetadataType.TS_VECTOR, TSVectorFilterType],
126128
]);
127129

128130
return typeFilterMapping.get(fieldMetadataType);

packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,5 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
158158
@WorkspaceIsNullable()
159159
@WorkspaceIsSystem()
160160
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
161-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
162-
searchVector: any;
161+
searchVector: string;
163162
}

packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,5 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
285285
@WorkspaceIsNullable()
286286
@WorkspaceIsSystem()
287287
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
288-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
289-
searchVector: any;
288+
searchVector: string;
290289
}

packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,5 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
160160
@WorkspaceIsNullable()
161161
@WorkspaceIsSystem()
162162
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
163-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
164-
searchVector: any;
163+
searchVector: string;
165164
}

packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,5 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
240240
@WorkspaceIsNullable()
241241
@WorkspaceIsSystem()
242242
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
243-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
244-
searchVector: any;
243+
searchVector: string;
245244
}

packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,5 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
304304
@WorkspaceIsNullable()
305305
@WorkspaceIsSystem()
306306
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
307-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
308-
searchVector: any;
307+
searchVector: string;
309308
}

packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,5 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
215215
@WorkspaceIsNullable()
216216
@WorkspaceIsSystem()
217217
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
218-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
219-
searchVector: any;
218+
searchVector: string;
220219
}

packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,5 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
358358
@WorkspaceIsNullable()
359359
@WorkspaceIsSystem()
360360
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
361-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
362-
searchVector: any;
361+
searchVector: string;
363362
}

0 commit comments

Comments
 (0)