Match multi-level search condition against any ancestor level for descendant results
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled

A field that exists at more than one level (e.g., Title, on both
top-level items and attachments) targeting a descendant result level
only matched the closest ancestor, so searching annotations by Title
found nothing, since it checked the parent attachment's title rather
than the top-level item's. Map down from each ancestor level and union
them, testing the predicate once so its bound parameters aren't
duplicated.

Addresses #5978
This commit is contained in:
Dan Stillman 2026-06-26 16:52:37 -04:00
parent 340ba92513
commit 02b19d0cde
2 changed files with 70 additions and 0 deletions

View file

@ -2311,6 +2311,41 @@ Zotero.Search.mapPredicate = function (sql, fromLevel, toLevel, negated = false)
return s;
};
// A multi-level field (e.g. title, which exists on both the top-level item and an attachment)
// targeting a descendant should match if *any* of those ancestor levels has the value -- an
// annotation matches when its parent attachment OR its top-level item has the title (and a
// snapshot's URL stays matchable at the attachment level). Map down from each such ancestor
// and union them, testing the predicate once (a single `anc IN (...)`) so its bound parameters
// aren't duplicated. A negation keeps the single-level behavior below.
let ancestorLevels = fromLevels.filter(
l => l != 'any' && Zotero.Search._isAncestorLevel(l, toLevel));
if (!negated && ancestorLevels.length > 1) {
// (toLevel itemID, ancestor itemID) pairs, walking from toLevel up to `anc`. A standalone
// attachment/note has no parent, so it stands in as its own top-level item (COALESCE).
let pairsTo = (anc) => {
let levels = [];
let l = toLevel;
while (l != anc) {
levels.push(l);
l = Zotero.Search._levelParent[l];
}
let aliases = levels.map((_, i) => 't' + i);
let fromSQL = `${Zotero.Search._levelChildTable[levels[0]]} ${aliases[0]}`;
for (let i = 1; i < levels.length; i++) {
fromSQL += ` JOIN ${Zotero.Search._levelChildTable[levels[i]]} ${aliases[i]}`
+ ` ON ${aliases[i]}.itemID = ${aliases[i - 1]}.parentItemID`;
}
let last = aliases[aliases.length - 1];
let lastLevel = levels[levels.length - 1];
let ancExpr = Zotero.Search._levelCanBeStandalone[lastLevel]
? `COALESCE(${last}.parentItemID, ${last}.itemID)`
: `${last}.parentItemID`;
return `SELECT ${aliases[0]}.itemID AS itemID, ${ancExpr} AS anc FROM ${fromSQL}`;
};
let union = ancestorLevels.map(pairsTo).join(' UNION ALL ');
return `itemID IN (SELECT itemID FROM (${union}) WHERE anc IN (${matches}))`;
}
let mapped = Zotero.Search._isAncestorLevel(toLevel, from)
? mapUp(matches, from, toLevel)
: mapDown(matches, from, toLevel);

View file

@ -356,6 +356,41 @@ describe("Zotero.Search", function () {
await other.eraseTx();
});
it("should match an annotation by its top-level item's title", async function () {
// Title exists on both items and attachments, so targeting annotations must
// reach the top-level item's title, not just the parent attachment's
var title = 'zanntitle' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title });
var attachment = await importPDFAttachment(item);
var annotation = await createAnnotation('highlight', attachment);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('title', 'contains', title);
assert.sameMembers(await s.search(), [annotation.id]);
await item.eraseTx();
});
it("should match an annotation by its parent attachment's title", async function () {
// The other half of the union: an attachment's own title still matches
var title = 'zatttitle' + Zotero.Utilities.randomString();
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
attachment.setField('title', title);
await attachment.saveTx();
var annotation = await createAnnotation('highlight', attachment);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('title', 'contains', title);
assert.sameMembers(await s.search(), [annotation.id]);
await item.eraseTx();
});
it("should match annotations by type and color", async function () {
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);