Skip to content

Commit 4047989

Browse files
andris9claude
andcommitted
fix(api-search): ZMS-94: keep $text queries plannable inside OR branches
MongoDB resolves $text through the compound `fulltext` index, which is prefixed with `user`, so a $text clause nested in an $or needs its own equality on `user`, and every sibling branch of that $or has to be index backed. Neither held, so any search mixing a header keyword with a fulltext term under OR returned a 500. applyTextIndexPrefix stamps the user id onto every branch of every $or that wraps a $text clause. The filter already requires `user` at the top level, so the repeated equality does not change what it matches. Both filter builders call it, so the or.* API params are covered as well, not only the q syntax. MongoDB also accepts a single $text expression per query, and the builder emitted two whenever text terms landed in different OR branches. Terms that cannot claim the one slot now fall back to the regex matching quoted phrases already use, keeping AND semantics for a merged AND term and keeping negated terms excluding rather than widening the match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0179oqSvLfdTeNBTsSPVmp4m
1 parent 332ef14 commit 4047989

3 files changed

Lines changed: 311 additions & 39 deletions

File tree

lib/prepare-search-filter.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,51 @@ const toMongoAndTextSearch = value =>
1212
.map(term => `"${term.replace(/(["\\])/g, '\\$1')}"`)
1313
.join(' ');
1414

15+
// MongoDB rejects $text inside $not and $nor, so an $and or an $or branch list are the
16+
// only places a nested $text clause can show up.
17+
const TEXT_QUERY_BRANCHES = ['$and', '$or'];
18+
19+
// MongoDB resolves a $text expression through the compound `fulltext` index, which is
20+
// prefixed with `user`, so the branch holding $text must also match on `user`. On top of
21+
// that MongoDB only plans an $or holding a $text clause when every branch of that $or is
22+
// index backed. Stamping the user id on each branch of every $or that wraps the $text
23+
// clause satisfies both rules. The filter always requires `user` at the top level, so the
24+
// repeated equality never changes what the filter matches.
25+
// Returns true when the subtree holds a $text clause.
26+
const applyTextIndexPrefix = (filter, user) => {
27+
if (!filter || typeof filter !== 'object') {
28+
return false;
29+
}
30+
31+
if (Array.isArray(filter)) {
32+
let branchWithTextQuery = false;
33+
for (let branch of filter) {
34+
if (applyTextIndexPrefix(branch, user)) {
35+
branchWithTextQuery = true;
36+
}
37+
}
38+
return branchWithTextQuery;
39+
}
40+
41+
let hasTextQuery = filter.$text !== undefined;
42+
43+
for (let key of TEXT_QUERY_BRANCHES) {
44+
if (filter[key] === undefined || !applyTextIndexPrefix(filter[key], user)) {
45+
continue;
46+
}
47+
48+
hasTextQuery = true;
49+
50+
if (key === '$or') {
51+
for (let branch of filter.$or) {
52+
branch.user = user;
53+
}
54+
}
55+
}
56+
57+
return hasTextQuery;
58+
};
59+
1560
const SEARCHABLE_MAILBOX_SPECIAL_USE = ['\\Junk', '\\Trash'];
1661
const SEARCHABLE_MAILBOX_IN_THRESHOLD = 200;
1762

@@ -356,7 +401,9 @@ const prepareSearchFilter = async (db, user, payload) => {
356401
filter.$or = orQuery;
357402
}
358403

404+
applyTextIndexPrefix(filter, user);
405+
359406
return { filter, query };
360407
};
361408

362-
module.exports = { uidRangeStringToQuery, prepareSearchFilter, toMongoAndTextSearch, getSearchableMailboxQuery };
409+
module.exports = { uidRangeStringToQuery, prepareSearchFilter, toMongoAndTextSearch, getSearchableMailboxQuery, applyTextIndexPrefix };

lib/search-query.js

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const SearchString = require('search-string').default;
44
const parser = require('logic-query-parser');
55
const { escapeRegexStr } = require('./tools');
6-
const { uidRangeStringToQuery, toMongoAndTextSearch, getSearchableMailboxQuery } = require('./prepare-search-filter');
6+
const { uidRangeStringToQuery, toMongoAndTextSearch, getSearchableMailboxQuery, applyTextIndexPrefix } = require('./prepare-search-filter');
77
const { ObjectId } = require('mongodb');
88

99
const getBooleanValue = value => {
@@ -43,17 +43,15 @@ const getDateValue = value => {
4343
return isNaN(date.getTime()) ? false : date;
4444
};
4545

46-
const createMongoTextQuery = (user, searchValue) => ({
47-
// Keep the compound text index prefix in the same logical branch as $text.
48-
// MongoDB does not propagate the top-level predicate into a nested $or
49-
// branch when selecting the text index.
50-
user,
46+
const createMongoTextQuery = searchValue => ({
5147
$text: {
5248
$search: searchValue
5349
}
5450
});
5551

56-
const formatTextSearchValue = (value, opts = {}) => (opts.useAndSearch === true && opts.mode !== 'or' ? toMongoAndTextSearch(value) : value);
52+
const isAndSearch = (opts = {}) => opts.useAndSearch === true && opts.mode !== 'or';
53+
54+
const formatTextSearchValue = (value, opts = {}) => (isAndSearch(opts) ? toMongoAndTextSearch(value) : value);
5755

5856
const createPhraseRegexClauses = value => {
5957
const regex = escapeRegexStr(value).replace(/\s+/g, '\\s+');
@@ -290,22 +288,47 @@ const getMongoDBQuery = async (db, user, queryStr, opts = {}) => {
290288
const searchValue = getTextSearchValue(entry);
291289
return {
292290
...entry,
293-
query: createMongoTextQuery(user, searchValue)
291+
query: createMongoTextQuery(searchValue)
294292
};
295293
};
296294
const isTextEntry = entry => !!entry?.isTextQuery;
297-
const unwrapQueryEntry = entry => (isTextEntry(entry) ? entry.query : entry);
298295
const createTextRegexQuery = entry => createPhraseQuery(entry.textValue, entry.negated);
299-
const createRawTextRegexQuery = entry => ({ $or: entry.entries.map(createTextRegexQuery) });
296+
const createRawTextRegexQuery = entry => {
297+
const included = entry.entries.filter(item => !item.negated).map(createTextRegexQuery);
298+
const excluded = entry.entries.filter(item => item.negated).map(createTextRegexQuery);
299+
// $text applies negated terms as exclusions, so those stay ANDed even when the
300+
// remaining terms are ORed
301+
const clauses = (entry.andSearch || included.length < 2 ? included : [{ $or: included }]).concat(excluded);
302+
303+
return clauses.length === 1 ? clauses[0] : { $and: clauses };
304+
};
305+
// MongoDB allows a single $text expression per query. The first text term the walk
306+
// reaches claims it, the ones that can not be merged into it, eg. because they sit in
307+
// a different OR branch, fall back to the same regex matching quoted phrases use.
308+
let textQueryUsed = false;
309+
const unwrapQueryEntry = entry => {
310+
if (!isTextEntry(entry)) {
311+
return entry;
312+
}
313+
314+
if (textQueryUsed) {
315+
return entry.rawSearch ? createRawTextRegexQuery(entry) : createTextRegexQuery(entry);
316+
}
317+
318+
textQueryUsed = true;
319+
return entry.query;
320+
};
300321
const mergeTextEntries = (entries, queryOpts = opts) => {
301322
const searchValue = entries.map(entry => getTextSearchValue(entry, queryOpts)).join(' ');
302323
return {
303324
isTextQuery: true,
304325
textValue: searchValue,
305326
negated: false,
306327
rawSearch: true,
328+
// remember how the merged terms are combined, the regex fallback has to match it
329+
andSearch: isAndSearch(queryOpts),
307330
entries,
308-
query: createMongoTextQuery(user, searchValue)
331+
query: createMongoTextQuery(searchValue)
309332
};
310333
};
311334

@@ -352,10 +375,7 @@ const getMongoDBQuery = async (db, user, queryStr, opts = {}) => {
352375
let directTextTerms = textTerms.filter(entry => !entry.rawSearch);
353376

354377
if (rawTextTerms.length) {
355-
branch.$and = [unwrapQueryEntry(rawTextTerms.shift())]
356-
.concat(rawTextTerms.map(createRawTextRegexQuery))
357-
.concat(directTextTerms.map(createTextRegexQuery))
358-
.concat(nonTextTerms);
378+
branch.$and = rawTextTerms.concat(directTextTerms).map(unwrapQueryEntry).concat(nonTextTerms);
359379
} else {
360380
branch.$and = [unwrapQueryEntry(mergeTextEntries(textTerms))].concat(nonTextTerms);
361381
}
@@ -745,6 +765,8 @@ const getMongoDBQuery = async (db, user, queryStr, opts = {}) => {
745765
extras.searchable = true;
746766
}
747767

768+
applyTextIndexPrefix(filter, user);
769+
748770
return { user: null, ...filter, ...extras };
749771
}
750772

0 commit comments

Comments
 (0)