fix(search): add support for searching by additional emails, phones, and secondary links (#17034)
## Summary - Add `additionalEmails` (EMAILS type) to tsvector search expression - Add `additionalPhones` (PHONES type) to tsvector search expression - Add `secondaryLinks` (LINKS type) to tsvector search expression This enables searching for people/companies by their secondary contact information, not just primary values. ## Test plan - [x] Unit tests for all three composite field types (16 tests passing) - [x] Integration tests for searching by secondary email, work email, partial domain - [x] Integration tests for searching by additional phone numbers - [x] Integration test for searching by secondary link URL - [x] Lint passes --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+112
-2
@@ -13,6 +13,7 @@ const nameFullNameField = {
|
||||
const jobTitleTextField = { name: 'jobTitle', type: FieldMetadataType.TEXT };
|
||||
const emailsEmailsField = { name: 'emails', type: FieldMetadataType.EMAILS };
|
||||
const phonesPhonesField = { name: 'phones', type: FieldMetadataType.PHONES };
|
||||
const linksLinksField = { name: 'domainName', type: FieldMetadataType.LINKS };
|
||||
|
||||
describe('getTsVectorColumnExpressionFromFields', () => {
|
||||
it('should generate correct expression for simple text field', () => {
|
||||
@@ -101,12 +102,121 @@ describe('getTsVectorColumnExpressionFromFields', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly index phone subfields', () => {
|
||||
it('should properly index phone subfields including additional phones', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain('phonesPrimaryPhoneNumber');
|
||||
expect(result).toContain('phonesPrimaryPhoneCallingCode');
|
||||
expect(result).not.toContain('phonesAdditionalPhones');
|
||||
|
||||
expect(result).toContain('phonesAdditionalPhones');
|
||||
expect(result).toContain(
|
||||
"COALESCE(TRANSLATE(regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip additional phone key names before indexing', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
"regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should include additional emails in search expression', () => {
|
||||
const fields = [emailsEmailsField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain('emailsPrimaryEmail');
|
||||
expect(result).toContain('emailsAdditionalEmails');
|
||||
expect(result).toContain(
|
||||
"COALESCE(public.unaccent_immutable(TRANSLATE(\"emailsAdditionalEmails\"::text, '[]\",', ' ')), '')",
|
||||
);
|
||||
expect(result).toContain(
|
||||
"COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(\"emailsAdditionalEmails\"::text, '@', ' '), '[]\",', ' ')), '')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should include secondary links in search expression for LINKS type', () => {
|
||||
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain('domainNamePrimaryLinkLabel');
|
||||
expect(result).toContain('domainNamePrimaryLinkUrl');
|
||||
expect(result).toContain('domainNameSecondaryLinks');
|
||||
expect(result).toContain(
|
||||
"COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip secondary link key names before indexing', () => {
|
||||
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
"regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g')",
|
||||
);
|
||||
});
|
||||
|
||||
describe('NULL/empty JSON column handling', () => {
|
||||
it('should wrap additionalEmails JSON column with COALESCE for NULL safety', () => {
|
||||
const fields = [emailsEmailsField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
'COALESCE(public.unaccent_immutable(TRANSLATE("emailsAdditionalEmails"::text',
|
||||
);
|
||||
expect(result).toMatch(
|
||||
/COALESCE\(public\.unaccent_immutable\(TRANSLATE\("emailsAdditionalEmails"::text.*\), ''\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap additionalPhones JSON column with COALESCE for NULL safety', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
'COALESCE(TRANSLATE(regexp_replace("phonesAdditionalPhones"::text',
|
||||
);
|
||||
expect(result).toMatch(
|
||||
/COALESCE\(TRANSLATE\(regexp_replace\("phonesAdditionalPhones"::text.*\), ''\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap secondaryLinks JSON column with COALESCE for NULL safety', () => {
|
||||
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
'COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace("domainNameSecondaryLinks"::text',
|
||||
);
|
||||
expect(result).toMatch(
|
||||
/COALESCE\(public\.unaccent_immutable\(TRANSLATE\(regexp_replace\("domainNameSecondaryLinks"::text.*\), ''\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use empty string fallback for all JSON array columns', () => {
|
||||
const fields = [
|
||||
emailsEmailsField,
|
||||
phonesPhonesField,
|
||||
linksLinksField,
|
||||
] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
const additionalEmailsCoalesce = result.includes(
|
||||
"COALESCE(public.unaccent_immutable(TRANSLATE(\"emailsAdditionalEmails\"::text, '[]\",', ' ')), '')",
|
||||
);
|
||||
const additionalPhonesCoalesce = result.includes(
|
||||
"COALESCE(TRANSLATE(regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
|
||||
);
|
||||
const secondaryLinksCoalesce = result.includes(
|
||||
"COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
|
||||
);
|
||||
|
||||
expect(additionalEmailsCoalesce).toBe(true);
|
||||
expect(additionalPhonesCoalesce).toBe(true);
|
||||
expect(secondaryLinksCoalesce).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-1
@@ -35,6 +35,7 @@ export const getTsVectorColumnExpressionFromFields = (
|
||||
);
|
||||
const concatenatedExpression = columnExpressions.join(" || ' ' || ");
|
||||
|
||||
// Note: changing this expression requires reindexing/backfilling existing searchVector values.
|
||||
return `to_tsvector('simple', ${concatenatedExpression})`;
|
||||
};
|
||||
|
||||
@@ -68,6 +69,7 @@ const getColumnExpressionsFromField = (
|
||||
if (fieldMetadataTypeAndName.type === FieldMetadataType.PHONES) {
|
||||
const phoneNumberColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneNumber"`;
|
||||
const callingCodeColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode"`;
|
||||
const additionalPhonesColumn = `"${fieldMetadataTypeAndName.name}AdditionalPhones"`;
|
||||
|
||||
const internationalFormats = [
|
||||
`COALESCE(${callingCodeColumn} || ${phoneNumberColumn}, '')`,
|
||||
@@ -75,7 +77,29 @@ const getColumnExpressionsFromField = (
|
||||
`COALESCE('0' || ${phoneNumberColumn}, '')`,
|
||||
];
|
||||
|
||||
return [...baseExpressions, ...internationalFormats];
|
||||
const additionalPhonesExpression = `COALESCE(TRANSLATE(regexp_replace(${additionalPhonesColumn}::text, '"(number|countryCode|callingCode)"\\s*:\\s*', '', 'g'), '[]{}",:', ' '), '')`;
|
||||
|
||||
return [
|
||||
...baseExpressions,
|
||||
...internationalFormats,
|
||||
additionalPhonesExpression,
|
||||
];
|
||||
}
|
||||
|
||||
if (fieldMetadataTypeAndName.type === FieldMetadataType.LINKS) {
|
||||
const secondaryLinksColumn = `"${fieldMetadataTypeAndName.name}SecondaryLinks"`;
|
||||
|
||||
const secondaryLinksExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(${secondaryLinksColumn}::text, '"(label|url)"\\s*:\\s*', '', 'g'), '[]{}",:', ' ')), '')`;
|
||||
|
||||
return [...baseExpressions, secondaryLinksExpression];
|
||||
}
|
||||
|
||||
if (fieldMetadataTypeAndName.type === FieldMetadataType.EMAILS) {
|
||||
const additionalEmailsColumn = `"${fieldMetadataTypeAndName.name}AdditionalEmails"`;
|
||||
|
||||
const additionalEmailsExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(${additionalEmailsColumn}::text, '[]",', ' ')), '') || ' ' || COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(${additionalEmailsColumn}::text, '@', ' '), '[]",', ' ')), '')`;
|
||||
|
||||
return [...baseExpressions, additionalEmailsExpression];
|
||||
}
|
||||
|
||||
return baseExpressions;
|
||||
|
||||
Reference in New Issue
Block a user