From 2a7a83de81d1e40cc088617861cfa8765cc50686 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:01:35 +0500 Subject: [PATCH] feat(search): Add unaccent support for accent-insensitive search. (#14464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✨ Add accent-insensitive search functionality ### 🎯 Overview Implements accent-insensitive search across all searchable fields in Twenty CRM. Users can now search for "jose" to find "José", "muller" to find "Müller", "cafe" to find "café", etc. ### 🔍 Problem Twenty's search functionality was accent-sensitive, requiring users to type exact accented characters to find records. This created a poor user experience, especially for international names and content. ### 💡 Solution Added PostgreSQL `unaccent` extension with a custom immutable wrapper function to enable accent-insensitive full-text search across all searchable field types. ### 📋 Changes Made **Modified Files:** - `packages/twenty-server/scripts/setup-db.ts` - `packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts` - `packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts` ### 🗄️ Database Setup (`setup-db.ts`) ```sql -- Added unaccent extension CREATE EXTENSION IF NOT EXISTS "unaccent"; -- Created immutable wrapper function CREATE OR REPLACE FUNCTION unaccent_immutable(text) RETURNS text AS $$ SELECT public.unaccent($1) $$ LANGUAGE sql IMMUTABLE; ``` ### 🔍 Search Vector Generation (`get-ts-vector-column-expression.util.ts`) Applied `public.unaccent_immutable()` to all searchable field types: - TEXT fields (job titles, names, etc.) - FULL_NAME fields (first/last names) - EMAILS fields (both email address and domain) - ADDRESS fields - LINKS fields - RICH_TEXT and RICH_TEXT_V2 fields ### 🔎 Query Processing (`compute-where-condition-parts.ts`) Enhanced search queries to use `public.unaccent_immutable()` for both: - Full-text search (`@@` operator with `to_tsquery`) - Pattern matching (`ILIKE` operator) ### 🧠 Technical Rationale: Why the Wrapper Function? **The Challenge:** PostgreSQL's built-in `unaccent()` is marked as **STABLE**, but `GENERATED ALWAYS AS` expressions (used for search vector columns) require **IMMUTABLE** functions. **The Solution:** Created an IMMUTABLE wrapper function that calls the underlying `unaccent()` function: - ✅ Satisfies PostgreSQL's immutability requirements for generated columns - ✅ Maintains the exact same functionality as the original `unaccent()` - ✅ Uses fully qualified `public.unaccent_immutable()` to ensure function resolution from workspace schemas **Alternative Approaches Considered:** - ❌ Modifying `search_path`: would affect workspace isolation - ❌ Computing unaccent at query time: would hurt performance - ❌ Using triggers: would complicate data consistency ### 🎯 Impact For **Person** records, accent-insensitive search now works on: - Name (first/last name): `"jose garcia"` finds `"José García"` - Email: `"jose@cafe.com"` finds `"josé@café.com"` - Job Title: `"manager"` finds `"Managér"` or `"Gerente de Café"` Applies to all searchable standard objects: - Companies, People, Opportunities, Notes, Tasks, etc. - Any custom fields of searchable types (TEXT, EMAILS, etc.) ### ✅ Testing - Database reset completes successfully - Workspace seeding works without errors - Search vectors generate with unaccent functionality - All searchable field types properly handle accented characters --------- Co-authored-by: Félix Malfait --- packages/twenty-server/scripts/setup-db.ts | 12 +++++++ .../utils/compute-where-condition-parts.ts | 4 +-- .../search/services/search.service.ts | 8 ++--- ...ts-vectors-column-expression.utils.spec.ts | 31 +++++++++++++------ .../get-ts-vector-column-expression.util.ts | 6 ++-- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/twenty-server/scripts/setup-db.ts b/packages/twenty-server/scripts/setup-db.ts index ca2c63e253..82fe1cce85 100644 --- a/packages/twenty-server/scripts/setup-db.ts +++ b/packages/twenty-server/scripts/setup-db.ts @@ -21,6 +21,18 @@ rawDataSource 'create extension "uuid-ossp"', ); + await performQuery( + 'CREATE EXTENSION IF NOT EXISTS "unaccent"', + 'create extension "unaccent"', + ); + + await performQuery( + `CREATE OR REPLACE FUNCTION unaccent_immutable(text) RETURNS text AS $$ + SELECT public.unaccent($1) + $$ LANGUAGE sql IMMUTABLE;`, + 'create immutable unaccent wrapper function', + ); + // We paused the work on FDW if (process.env.IS_FDW_ENABLED !== 'true') { return; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index 9d0a1c5624..79aa7bd40b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -101,8 +101,8 @@ export const computeWhereConditionParts = ({ return { sql: `( - "${objectNameSingular}"."${key}" @@ to_tsquery('simple', :${key}${uuid}Ts) OR - "${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}Like + "${objectNameSingular}"."${key}" @@ to_tsquery('simple', public.unaccent_immutable(:${key}${uuid}Ts)) OR + public.unaccent_immutable("${objectNameSingular}"."${key}"::text) ILIKE public.unaccent_immutable(:${key}${uuid}Like) )`, params: { [`${key}${uuid}Ts`]: tsQuery, diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index 8a4cac0a3e..91b1e7989a 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -177,9 +177,9 @@ export class SearchService { ...(imageIdentifierField ? [imageIdentifierField] : []), ].map((field) => `"${field}"`); - const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`; + const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery('simple', public.unaccent_immutable(:searchTerms)))`; - const tsRankExpr = `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`; + const tsRankExpr = `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery('simple', public.unaccent_immutable(:searchTermsOr)))`; const cursorWhereCondition = this.computeCursorWhereCondition({ after, @@ -197,10 +197,10 @@ export class SearchService { queryBuilder.andWhere( new Brackets((qb) => { qb.where( - `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`, + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', public.unaccent_immutable(:searchTerms))`, { searchTerms }, ).orWhere( - `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`, + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', public.unaccent_immutable(:searchTermsOr))`, { searchTermsOr }, ); }), diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts index 0d420f3245..72138eb5dc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts @@ -18,7 +18,9 @@ describe('getTsVectorColumnExpressionFromFields', () => { const fields = [nameTextField] as FieldTypeAndNameMetadata[]; const result = getTsVectorColumnExpressionFromFields(fields); - expect(result).toContain("to_tsvector('simple', COALESCE(\"name\", ''))"); + expect(result).toContain( + "to_tsvector('simple', COALESCE(public.unaccent_immutable(\"name\"), ''))", + ); }); it('should handle multiple fields', () => { @@ -28,13 +30,22 @@ describe('getTsVectorColumnExpressionFromFields', () => { emailsEmailsField, ] as FieldTypeAndNameMetadata[]; const result = getTsVectorColumnExpressionFromFields(fields); - const expected = ` - to_tsvector('simple', COALESCE("nameFirstName", '') || ' ' || COALESCE("nameLastName", '') || ' ' || COALESCE("jobTitle", '') || ' ' || - COALESCE("emailsPrimaryEmail", '') || ' ' || - COALESCE(SPLIT_PART("emailsPrimaryEmail", '@', 2), '')) - `.trim(); - expect(result.trim()).toBe(expected); + expect(result).toContain( + 'COALESCE(public.unaccent_immutable("nameFirstName"), \'\')', + ); + expect(result).toContain( + 'COALESCE(public.unaccent_immutable("nameLastName"), \'\')', + ); + expect(result).toContain( + 'COALESCE(public.unaccent_immutable("jobTitle"), \'\')', + ); + expect(result).toContain( + 'COALESCE(public.unaccent_immutable("emailsPrimaryEmail"), \'\')', + ); + expect(result).toContain( + "COALESCE(public.unaccent_immutable(SPLIT_PART(\"emailsPrimaryEmail\", '@', 2)), '')", + ); }); it('should handle rich text fields', () => { @@ -43,7 +54,9 @@ describe('getTsVectorColumnExpressionFromFields', () => { ] as FieldTypeAndNameMetadata[]; const result = getTsVectorColumnExpressionFromFields(fields); - expect(result).toBe("to_tsvector('simple', COALESCE(\"body\", ''))"); + expect(result).toBe( + "to_tsvector('simple', COALESCE(public.unaccent_immutable(\"body\"), ''))", + ); }); it('should handle rich text v2 fields', () => { @@ -53,7 +66,7 @@ describe('getTsVectorColumnExpressionFromFields', () => { const result = getTsVectorColumnExpressionFromFields(fields); expect(result).toBe( - "to_tsvector('simple', COALESCE(\"bodyV2Markdown\", ''))", + "to_tsvector('simple', COALESCE(public.unaccent_immutable(\"bodyV2Markdown\"), ''))", ); }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts index 2e85a35d71..509bd42c8d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util.ts @@ -82,10 +82,10 @@ const getColumnExpression = ( switch (fieldType) { case FieldMetadataType.EMAILS: return ` - COALESCE(${quotedColumnName}, '') || ' ' || - COALESCE(SPLIT_PART(${quotedColumnName}, '@', 2), '')`; + COALESCE(public.unaccent_immutable(${quotedColumnName}), '') || ' ' || + COALESCE(public.unaccent_immutable(SPLIT_PART(${quotedColumnName}, '@', 2)), '')`; default: - return `COALESCE(${quotedColumnName}, '')`; + return `COALESCE(public.unaccent_immutable(${quotedColumnName}), '')`; } };