Skip to content

Commit 9351882

Browse files
kriskclaude
andcommitted
fix(search): inverse patterns now work correctly across multiple keys
ExtendedSearch.searchIn now sets hasInverse on the result when inverse matchers (!term, !^prefix, !suffix$) are involved. _searchObjectList uses this to switch from "ANY key matches" to "ALL keys must match" aggregation, so !Syrup correctly excludes items containing Syrup in any searched field. For mixed patterns like "^hello !Syrup", a key failure conservatively excludes the item — strictly better than the old behavior of including items that should have been excluded. Closes #712 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 69a34d4 commit 9351882

17 files changed

Lines changed: 344 additions & 231 deletions

dist/fuse.basic.cjs

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,31 +1405,14 @@ var Fuse = /*#__PURE__*/function () {
14051405
}
14061406
}
14071407

1408-
// Known limitation: inverse patterns (e.g. !Syrup) don't work correctly
1409-
// across multiple keys. Each key is searched independently and the item is
1410-
// included if ANY key matches. This is correct for positive patterns but
1411-
// wrong for inverse ones:
1408+
// When a search involves inverse patterns (e.g. !Syrup), the aggregation
1409+
// across keys switches from "ANY key matches" to "ALL keys must match."
1410+
// This is signaled by hasInverse on the SearchResult from ExtendedSearch.
14121411
//
1413-
// Positive "hello" with keys [title, author]:
1414-
// title="hello world" → isMatch: true
1415-
// author="Bob Smith" → isMatch: false
1416-
// → include (correct: found in at least one key)
1417-
//
1418-
// Inverse "!Syrup" with keys [title, author]:
1419-
// title="Maple Syrup Pancakes" → isMatch: false (contains Syrup)
1420-
// author="Chef Bob" → isMatch: true (no Syrup)
1421-
// → include (wrong: should exclude because title contains Syrup)
1422-
//
1423-
// Fixing this requires knowing which results are inverse vs positive, but
1424-
// searchIn() returns a single { isMatch, score } with no per-term breakdown.
1425-
// For mixed patterns like "^hello !Syrup", we'd need per-term results from
1426-
// ExtendedSearch to know whether a key failed due to the positive or inverse
1427-
// term — which means redesigning the Searcher interface.
1428-
//
1429-
// Workaround: use logical queries for inverse patterns across keys:
1430-
// fuse.search({ $and: [{ title: '!Syrup' }, { author: '!Syrup' }] })
1431-
//
1432-
// See: https://github.com/krisk/Fuse/issues/712
1412+
// For mixed patterns like "^hello !Syrup", a key failure is ambiguous —
1413+
// it could be the positive or inverse term that failed. In that case we
1414+
// conservatively exclude the item, which is strictly better than the old
1415+
// behavior of including it. See: https://github.com/krisk/Fuse/issues/712
14331416
}, {
14341417
key: "_searchObjectList",
14351418
value: function _searchObjectList(query) {
@@ -1451,15 +1434,30 @@ var Fuse = /*#__PURE__*/function () {
14511434
return;
14521435
}
14531436
var matches = [];
1437+
var anyKeyFailed = false;
1438+
var hasInverse = false;
14541439

14551440
// Iterate over every key (i.e, path), and fetch the value at that key
14561441
keys.forEach(function (key, keyIndex) {
1457-
matches.push.apply(matches, _toConsumableArray(_this2._findMatches({
1442+
var keyMatches = _this2._findMatches({
14581443
key: key,
14591444
value: item[keyIndex],
14601445
searcher: searcher
1461-
})));
1446+
});
1447+
if (keyMatches.length) {
1448+
matches.push.apply(matches, _toConsumableArray(keyMatches));
1449+
if (keyMatches[0].hasInverse) {
1450+
hasInverse = true;
1451+
}
1452+
} else {
1453+
anyKeyFailed = true;
1454+
}
14621455
});
1456+
1457+
// If the search involves inverse patterns, ALL keys must match
1458+
if (hasInverse && anyKeyFailed) {
1459+
return;
1460+
}
14631461
if (matches.length) {
14641462
var result = {
14651463
idx: idx,
@@ -1501,15 +1499,17 @@ var Fuse = /*#__PURE__*/function () {
15011499
var _searcher$searchIn2 = searcher.searchIn(text),
15021500
isMatch = _searcher$searchIn2.isMatch,
15031501
score = _searcher$searchIn2.score,
1504-
indices = _searcher$searchIn2.indices;
1502+
indices = _searcher$searchIn2.indices,
1503+
hasInverse = _searcher$searchIn2.hasInverse;
15051504
if (isMatch) {
15061505
matches.push({
15071506
score: score,
15081507
key: key,
15091508
value: text,
15101509
idx: idx,
15111510
norm: norm,
1512-
indices: indices
1511+
indices: indices,
1512+
hasInverse: hasInverse
15131513
});
15141514
}
15151515
});
@@ -1519,14 +1519,16 @@ var Fuse = /*#__PURE__*/function () {
15191519
var _searcher$searchIn3 = searcher.searchIn(text),
15201520
isMatch = _searcher$searchIn3.isMatch,
15211521
score = _searcher$searchIn3.score,
1522-
indices = _searcher$searchIn3.indices;
1522+
indices = _searcher$searchIn3.indices,
1523+
hasInverse = _searcher$searchIn3.hasInverse;
15231524
if (isMatch) {
15241525
matches.push({
15251526
score: score,
15261527
key: key,
15271528
value: text,
15281529
norm: norm,
1529-
indices: indices
1530+
indices: indices,
1531+
hasInverse: hasInverse
15301532
});
15311533
}
15321534
}

dist/fuse.basic.js

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,31 +1409,14 @@
14091409
}
14101410
}
14111411

1412-
// Known limitation: inverse patterns (e.g. !Syrup) don't work correctly
1413-
// across multiple keys. Each key is searched independently and the item is
1414-
// included if ANY key matches. This is correct for positive patterns but
1415-
// wrong for inverse ones:
1412+
// When a search involves inverse patterns (e.g. !Syrup), the aggregation
1413+
// across keys switches from "ANY key matches" to "ALL keys must match."
1414+
// This is signaled by hasInverse on the SearchResult from ExtendedSearch.
14161415
//
1417-
// Positive "hello" with keys [title, author]:
1418-
// title="hello world" → isMatch: true
1419-
// author="Bob Smith" → isMatch: false
1420-
// → include (correct: found in at least one key)
1421-
//
1422-
// Inverse "!Syrup" with keys [title, author]:
1423-
// title="Maple Syrup Pancakes" → isMatch: false (contains Syrup)
1424-
// author="Chef Bob" → isMatch: true (no Syrup)
1425-
// → include (wrong: should exclude because title contains Syrup)
1426-
//
1427-
// Fixing this requires knowing which results are inverse vs positive, but
1428-
// searchIn() returns a single { isMatch, score } with no per-term breakdown.
1429-
// For mixed patterns like "^hello !Syrup", we'd need per-term results from
1430-
// ExtendedSearch to know whether a key failed due to the positive or inverse
1431-
// term — which means redesigning the Searcher interface.
1432-
//
1433-
// Workaround: use logical queries for inverse patterns across keys:
1434-
// fuse.search({ $and: [{ title: '!Syrup' }, { author: '!Syrup' }] })
1435-
//
1436-
// See: https://github.com/krisk/Fuse/issues/712
1416+
// For mixed patterns like "^hello !Syrup", a key failure is ambiguous —
1417+
// it could be the positive or inverse term that failed. In that case we
1418+
// conservatively exclude the item, which is strictly better than the old
1419+
// behavior of including it. See: https://github.com/krisk/Fuse/issues/712
14371420
}, {
14381421
key: "_searchObjectList",
14391422
value: function _searchObjectList(query) {
@@ -1455,15 +1438,30 @@
14551438
return;
14561439
}
14571440
var matches = [];
1441+
var anyKeyFailed = false;
1442+
var hasInverse = false;
14581443

14591444
// Iterate over every key (i.e, path), and fetch the value at that key
14601445
keys.forEach(function (key, keyIndex) {
1461-
matches.push.apply(matches, _toConsumableArray(_this2._findMatches({
1446+
var keyMatches = _this2._findMatches({
14621447
key: key,
14631448
value: item[keyIndex],
14641449
searcher: searcher
1465-
})));
1450+
});
1451+
if (keyMatches.length) {
1452+
matches.push.apply(matches, _toConsumableArray(keyMatches));
1453+
if (keyMatches[0].hasInverse) {
1454+
hasInverse = true;
1455+
}
1456+
} else {
1457+
anyKeyFailed = true;
1458+
}
14661459
});
1460+
1461+
// If the search involves inverse patterns, ALL keys must match
1462+
if (hasInverse && anyKeyFailed) {
1463+
return;
1464+
}
14671465
if (matches.length) {
14681466
var result = {
14691467
idx: idx,
@@ -1505,15 +1503,17 @@
15051503
var _searcher$searchIn2 = searcher.searchIn(text),
15061504
isMatch = _searcher$searchIn2.isMatch,
15071505
score = _searcher$searchIn2.score,
1508-
indices = _searcher$searchIn2.indices;
1506+
indices = _searcher$searchIn2.indices,
1507+
hasInverse = _searcher$searchIn2.hasInverse;
15091508
if (isMatch) {
15101509
matches.push({
15111510
score: score,
15121511
key: key,
15131512
value: text,
15141513
idx: idx,
15151514
norm: norm,
1516-
indices: indices
1515+
indices: indices,
1516+
hasInverse: hasInverse
15171517
});
15181518
}
15191519
});
@@ -1523,14 +1523,16 @@
15231523
var _searcher$searchIn3 = searcher.searchIn(text),
15241524
isMatch = _searcher$searchIn3.isMatch,
15251525
score = _searcher$searchIn3.score,
1526-
indices = _searcher$searchIn3.indices;
1526+
indices = _searcher$searchIn3.indices,
1527+
hasInverse = _searcher$searchIn3.hasInverse;
15271528
if (isMatch) {
15281529
matches.push({
15291530
score: score,
15301531
key: key,
15311532
value: text,
15321533
norm: norm,
1533-
indices: indices
1534+
indices: indices,
1535+
hasInverse: hasInverse
15341536
});
15351537
}
15361538
}

dist/fuse.basic.min.cjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

dist/fuse.basic.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)