Skip to content

Commit 87320e7

Browse files
committed
feat(search): make searchTerm in various places diacritic insensitive
1 parent 106c339 commit 87320e7

File tree

10 files changed

+102
-113
lines changed

10 files changed

+102
-113
lines changed

server/graphql/v2/interface/Account.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ const accountFieldsDefinition = () => ({
627627
const searchTermConditions = buildSearchConditions(searchTerm, {
628628
idFields: ['id'],
629629
slugFields: ['$fromCollective.slug$'],
630-
textFields: ['$fromCollective.name$', 'title', 'html'],
630+
textFields: ['$fromCollective.name$', 'Update.title', 'Update.html'],
631631
publicIdFields: [
632632
{ field: 'publicId', prefix: EntityShortIdPrefix.Update },
633633
{ field: '$fromCollective.publicId$', prefix: EntityShortIdPrefix.Collective },

server/graphql/v2/interface/IsMemberOf.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const IsMemberOfFields = {
147147
const searchTermConditions = buildSearchConditions(args.searchTerm, {
148148
idFields: ['id', '$collective.id$'],
149149
slugFields: ['$collective.slug$'],
150-
textFields: ['$collective.name$', '$collective.description$', 'description', 'role'],
150+
textFields: ['$collective.name$', '$collective.description$', 'Member.description', 'Member.role'],
151151
stringArrayFields: ['$collective.tags$'],
152152
stringArrayTransformFn: str => str.toLowerCase(), // collective tags are stored lowercase
153153
castStringArraysToVarchar: true,

server/graphql/v2/object/Host.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { getPolicy } from '../../../lib/policies';
3333
import SQLQueries from '../../../lib/queries';
3434
import sequelize from '../../../lib/sequelize';
3535
import { buildKyselySearchConditions, buildSearchConditions } from '../../../lib/sql-search';
36+
import { removeDiacritics } from '../../../lib/string-utils';
3637
import { getHostReportNodesFromQueryResult } from '../../../lib/transaction-reports';
3738
import { ifStr, parseToBoolean } from '../../../lib/utils';
3839
import models, { Collective, ConnectedAccount, Op, TransactionsImportRow } from '../../../models';
@@ -1005,7 +1006,10 @@ export const GraphQLHost = new GraphQLObjectType({
10051006
const hasExpenseToDate = !isNil(args.withExpensesDateTo);
10061007
const hasExpensePeriodFilter = hasExpenseFromDate || hasExpenseToDate;
10071008
const hasSearchTerm = !isNil(args.searchTerm) && args.searchTerm.length !== 0;
1008-
const searchTerm = `%${args.searchTerm}%`;
1009+
const searchTerm =
1010+
hasSearchTerm && typeof args.searchTerm === 'string'
1011+
? `%${removeDiacritics(args.searchTerm)}%`
1012+
: `%${args.searchTerm}%`;
10091013

10101014
const baseQuery = `
10111015
SELECT
@@ -1079,8 +1083,8 @@ export const GraphQLHost = new GraphQLObjectType({
10791083
${ifStr(
10801084
hasSearchTerm,
10811085
`AND (
1082-
vc.name ILIKE :searchTerm
1083-
OR vc.data#>>'{last4}' ILIKE :searchTerm
1086+
unaccent(vc.name) ILIKE :searchTerm
1087+
OR unaccent(vc.data#>>'{last4}') ILIKE :searchTerm
10841088
)`,
10851089
)}
10861090
`;
@@ -2338,7 +2342,7 @@ export const GraphQLHost = new GraphQLObjectType({
23382342
if (args.searchTerm) {
23392343
where.push({
23402344
[Op.or]: buildSearchConditions(args.searchTerm, {
2341-
textFields: ['description', 'sourceId'],
2345+
textFields: ['TransactionsImportRow.description', 'TransactionsImportRow.sourceId'],
23422346
publicIdFields: [{ field: 'publicId', prefix: EntityShortIdPrefix.TransactionsImportRow }],
23432347
}),
23442348
});

server/graphql/v2/query/collection/CommunityQuery.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isNil } from 'lodash';
66
import { QueryTypes, Sequelize } from 'sequelize';
77

88
import { parseSearchTerm, sanitizeSearchTermForILike } from '../../../../lib/sql-search';
9+
import { removeDiacritics } from '../../../../lib/string-utils';
910
import { ifStr } from '../../../../lib/utils';
1011
import { Collective, sequelize } from '../../../../models';
1112
import { allowContextPermission, PERMISSION_TYPE } from '../../../common/context-permissions';
@@ -59,10 +60,10 @@ const buildSearchConditions = (
5960
replacements: { searchTerm: parsed.term },
6061
};
6162
} else {
62-
const sanitizedTerm = sanitizeSearchTermForILike(parsed.term);
63+
const sanitizedTerm = sanitizeSearchTermForILike(removeDiacritics(parsed.term));
6364
return {
6465
joinClause: `LEFT JOIN "Users" u ON u."CollectiveId" = fc.id AND u."deletedAt" IS NULL`,
65-
whereClause: `AND (fc."name" ILIKE :searchTermPattern OR fc.slug ILIKE :searchTermPattern OR u."email" ILIKE :searchTermPattern)`,
66+
whereClause: `AND (unaccent(fc."name") ILIKE :searchTermPattern OR unaccent(fc.slug) ILIKE :searchTermPattern OR unaccent(u."email") ILIKE :searchTermPattern)`,
6667
replacements: { searchTermPattern: `%${sanitizedTerm}%` },
6768
};
6869
}

server/graphql/v2/query/collection/ExpensesCollectionQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ export const ExpensesCollectionQueryResolver = async (
482482
idFields: ['id'],
483483
dataFields: ['data.transactionId', 'data.transfer.id', 'data.transaction_id', 'data.batchGroup.id', 'reference'],
484484
slugFields: ['$fromCollective.slug$', '$collective.slug$', '$User.collective.slug$'],
485-
textFields: ['$fromCollective.name$', '$collective.name$', '$User.collective.name$', 'description'],
485+
textFields: ['$fromCollective.name$', '$collective.name$', '$User.collective.name$', 'Expense.description'],
486486
emailFields: isHostAdmin ? ['$User.email$'] : [],
487487
amountFields: ['amount'],
488488
stringArrayFields: ['tags'],

server/graphql/v2/query/collection/OrdersCollectionQuery.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TransactionKind } from '../../../../constants/transaction-kind';
1717
import { DatabaseWithViews, getKysely, kyselyToSequelizeModels } from '../../../../lib/kysely';
1818
import { EntityShortIdPrefix, isEntityPublicId } from '../../../../lib/permalink/entity-map';
1919
import { buildSearchConditions } from '../../../../lib/sql-search';
20+
import { removeDiacritics } from '../../../../lib/string-utils';
2021
import models, { Collective, ManualPaymentProvider, Op, PaymentMethod, Tier, User } from '../../../../models';
2122
import { checkScope } from '../../../common/scope-check';
2223
import { Forbidden, NotFound, Unauthorized } from '../../../errors';
@@ -521,7 +522,7 @@ export const OrdersCollectionResolver = async (args: OrdersCollectionArgsType, r
521522
.select('Orders.id')
522523
.where('Orders.deletedAt', 'is', null)
523524
.$if(!!args.searchTerm, qb => {
524-
return qb.where(({ or, eb }) => {
525+
return qb.where(({ fn, or, eb }) => {
525526
if (isFinite(Number(args.searchTerm)) && isInteger(Number(args.searchTerm))) {
526527
ors.push(eb('Orders.id', '=', Number(args.searchTerm)));
527528
}
@@ -535,9 +536,17 @@ export const OrdersCollectionResolver = async (args: OrdersCollectionArgsType, r
535536
ors.push(eb('Orders.FromCollectiveId', '=', collectiveBySearchTerm.id));
536537
}
537538

538-
ors.push(eb('Orders.description', 'ilike', `%${args.searchTerm}%`));
539+
const searchTermNoDiacrits =
540+
typeof args.searchTerm === 'string' ? removeDiacritics(args.searchTerm) : args.searchTerm;
541+
ors.push(eb(fn<string>('unaccent', ['Orders.description']), 'ilike', `%${searchTermNoDiacrits}%`));
539542
ors.push(eb(sql`"Orders".data->>'ponumber'`, 'ilike', `%${args.searchTerm}%`));
540-
ors.push(eb(sql`"Orders".data#>>'{fromAccountInfo,name}'`, 'ilike', `%${args.searchTerm}%`));
543+
ors.push(
544+
eb(
545+
fn<string>('unaccent', [sql`"Orders".data#>>'{fromAccountInfo,name}'`]),
546+
'ilike',
547+
`%${searchTermNoDiacrits}%`,
548+
),
549+
);
541550
ors.push(eb(sql`"Orders".data#>>'{fromAccountInfo,email}'`, 'ilike', `%${args.searchTerm}%`));
542551
ors.push(
543552
eb('Orders.tags', '&&', sql<string[]>`ARRAY[${args.searchTerm.toLocaleLowerCase()}]::varchar[]`),
@@ -915,8 +924,8 @@ export const OrdersCollectionResolver = async (args: OrdersCollectionArgsType, r
915924
const { limit = 10, offset = 0, searchTerm } = subArgs;
916925

917926
const searchConditions = buildSearchConditions(searchTerm, {
918-
slugFields: ['slug'],
919-
textFields: ['name'],
927+
slugFields: ['Collective.slug'],
928+
textFields: ['Collective.name'],
920929
publicIdFields: [{ field: 'publicId', prefix: EntityShortIdPrefix.Collective }],
921930
});
922931

server/graphql/v2/query/collection/TransactionsCollectionQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ export const TransactionsCollectionResolver = async (
423423
const searchTermConditions = buildSearchConditions(args.searchTerm, {
424424
idFields: ['id', 'ExpenseId', 'OrderId'],
425425
slugFields: ['$fromCollective.slug$', '$collective.slug$'],
426-
textFields: ['$fromCollective.name$', '$collective.name$', 'description'],
426+
textFields: ['$fromCollective.name$', '$collective.name$', 'Transaction.description'],
427427
amountFields: ['amount'],
428428
publicIdFields: [
429429
{ field: 'publicId', prefix: EntityShortIdPrefix.Transaction },

server/lib/sql-search.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,21 @@ export const buildSearchConditions = (
629629
}
630630
// Conditions for text fields
631631
const strTerm = parsedTerm.term.toString(); // Some terms are returned as numbers
632-
const iLikeQuery = `%${sanitizeSearchTermForILike(strTerm)}%`;
632+
const iLikeQuery = `%${sanitizeSearchTermForILike(removeDiacritics(strTerm))}%`;
633633
const allTextFields = [...(slugFields || []), ...(textFields || [])];
634-
allTextFields.forEach(field => conditions.push({ [field]: { [Op.iLike]: iLikeQuery } }));
634+
allTextFields.forEach(field => {
635+
let fieldToQuery = field;
636+
// For slug fields that may be defined for an associated table,
637+
// strip the $ so that the field name can work with sequelize.col
638+
if (fieldToQuery.startsWith('$') && fieldToQuery.endsWith('$')) {
639+
fieldToQuery = fieldToQuery.slice(1, fieldToQuery.length - 1);
640+
}
641+
conditions.push(
642+
sequelize.where(sequelize.fn('unaccent', sequelize.col(fieldToQuery)), {
643+
[Op.iLike]: iLikeQuery,
644+
}),
645+
);
646+
});
635647

636648
// Conditions for string array (usually tags lists)
637649
if (stringArrayFields?.length) {
@@ -719,10 +731,12 @@ export const buildKyselySearchConditions =
719731

720732
// Conditions for text fields
721733
const strTerm = parsedTerm.term.toString(); // Some terms are returned as numbers
722-
const iLikeQuery = `%${sanitizeSearchTermForILike(strTerm)}%`;
734+
const iLikeQuery = `%${sanitizeSearchTermForILike(removeDiacritics(strTerm))}%`;
723735
const allTextFields = [...(slugFields || []), ...(textFields || [])];
724736

725-
q = q.where(({ eb, or }) => or(allTextFields.map(field => eb(field, 'ilike', iLikeQuery))));
737+
q = q.where(({ fn, eb, or }) =>
738+
or(allTextFields.map(field => eb(fn<string>('unaccent', [field]), 'ilike', iLikeQuery))),
739+
);
726740
return q;
727741
};
728742

test/server/graphql/v2/collection/OrdersCollectionQuery.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ describe('server/graphql/v2/collection/OrdersCollectionQuery', () => {
937937
collective = await fakeCollective();
938938

939939
// Create users with specific names for search testing
940-
user1 = await fakeUser(null, { name: 'Alice Anderson' });
940+
user1 = await fakeUser(null, { name: 'Alicé Anderson' });
941941
user2 = await fakeUser(null, { name: 'Bob Builder' });
942942

943943
// Create orders by different users
@@ -960,7 +960,7 @@ describe('server/graphql/v2/collection/OrdersCollectionQuery', () => {
960960
const result = await graphqlQueryV2(ordersQuery, {
961961
account: { legacyId: collective.id },
962962
filter: 'INCOMING',
963-
searchTerm: 'Alice',
963+
searchTerm: 'Alice', // should be diacritic insensitive
964964
});
965965

966966
expect(result.errors).to.not.exist;

0 commit comments

Comments
 (0)