From 0ddf48c460b67d9a2398664f9d2097ddfb7c4488 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Mon, 24 Nov 2025 13:30:40 -0800 Subject: [PATCH] feat(fts5): add more unit tests for search --- .../services/search/content_search.spec.ts | 70 +++++++++++++++++++ .../expressions/note_content_fulltext.ts | 4 -- apps/server/src/services/search/fts_search.ts | 8 --- .../search/services/build_comparator.ts | 9 ++- apps/server/src/test/becca_mocking.ts | 8 +-- 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/apps/server/src/services/search/content_search.spec.ts b/apps/server/src/services/search/content_search.spec.ts index 64ee325dd..d167ca12d 100644 --- a/apps/server/src/services/search/content_search.spec.ts +++ b/apps/server/src/services/search/content_search.spec.ts @@ -326,4 +326,74 @@ describe("Content Search", () => { expect(findNoteByTitle(searchResults, "The quick brown fox jumps")).toBeTruthy(); }); }); + + describe("Plain Text Search Matches Attribute Values", () => { + it("should find notes by searching for label value as plain text", () => { + // Note has a label with value "Tolkien", searching for "Tolkien" should find it + rootNote + .child(note("The Hobbit").label("author", "Tolkien")) + .child(note("Dune").label("author", "Herbert")) + .child(note("Random Note")); + + const searchContext = new SearchContext(); + const searchResults = searchService.findResultsWithQuery("Tolkien", searchContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "The Hobbit")).toBeTruthy(); + }); + + it("should find notes by searching for label name as plain text", () => { + // Note has a label named "important", searching for "important" should find it + rootNote + .child(note("Critical Task").label("important")) + .child(note("Regular Task")); + + const searchContext = new SearchContext(); + const searchResults = searchService.findResultsWithQuery("important", searchContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Critical Task")).toBeTruthy(); + }); + + it("should find notes by searching for relation name as plain text", () => { + const author = note("J.R.R. Tolkien"); + + rootNote + .child(note("The Hobbit").relation("writtenBy", author.note)) + .child(note("Random Book")) + .child(author); + + const searchContext = new SearchContext(); + const searchResults = searchService.findResultsWithQuery("writtenBy", searchContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "The Hobbit")).toBeTruthy(); + }); + + it("should find notes when label value contains the search term", () => { + rootNote + .child(note("Fantasy Book").label("genre", "Science Fiction")) + .child(note("History Book").label("genre", "Historical")); + + const searchContext = new SearchContext(); + const searchResults = searchService.findResultsWithQuery("Fiction", searchContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Fantasy Book")).toBeTruthy(); + }); + + it("should combine plain text attribute search with title search", () => { + rootNote + .child(note("Programming Guide").label("language", "JavaScript")) + .child(note("Programming Tutorial").label("language", "Python")) + .child(note("Cooking Guide").label("cuisine", "Italian")); + + const searchContext = new SearchContext(); + // Search for notes with "Guide" in title AND "JavaScript" in attributes + const searchResults = searchService.findResultsWithQuery("Guide JavaScript", searchContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Programming Guide")).toBeTruthy(); + }); + }); }); diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index 483f151ce..6bc57a075 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -135,8 +135,6 @@ class NoteContentFulltextExp extends Expression { } } - log.info(`[FTS5-CONTENT] Found ${ftsResults.length} notes matching content search`); - // If we need to search protected notes, use the separate method if (searchProtected) { const protectedResults = ftsSearchService.searchProtectedNotesSync( @@ -171,8 +169,6 @@ class NoteContentFulltextExp extends Expression { noteIdSet.size > 0 ? noteIdSet : undefined ); - log.info(`[FTS5-ATTRIBUTES] Found ${attributeNoteIds.size} notes matching attribute search`); - // Add notes with matching attributes for (const noteId of attributeNoteIds) { if (becca.notes[noteId]) { diff --git a/apps/server/src/services/search/fts_search.ts b/apps/server/src/services/search/fts_search.ts index f9c41948c..902953a2b 100644 --- a/apps/server/src/services/search/fts_search.ts +++ b/apps/server/src/services/search/fts_search.ts @@ -805,8 +805,6 @@ class FTSSearchService { value: string; }>(query, params); - log.info(`[FTS5-ATTRIBUTES-RAW] FTS5 query returned ${results.length} raw attribute matches`); - // Post-filter for exact word matches when operator is "=" if (operator === "=") { const matchingNoteIds = new Set(); @@ -817,21 +815,15 @@ class FTSSearchService { const nameMatch = result.name.toLowerCase() === phrase.toLowerCase(); const valueMatch = result.value ? this.containsExactPhrase(phrase, result.value) : false; - log.info(`[FTS5-ATTRIBUTES-FILTER] Checking attribute: name="${result.name}", value="${result.value}", phrase="${phrase}", nameMatch=${nameMatch}, valueMatch=${valueMatch}`); - if (nameMatch || valueMatch) { matchingNoteIds.add(result.noteId); } } - const filterTime = Date.now() - startTime; - log.info(`[FTS5-ATTRIBUTES-FILTERED] After post-filtering: ${matchingNoteIds.size} notes match (total time: ${filterTime}ms)`); return matchingNoteIds; } // For other operators, return all matching noteIds - const searchTime = Date.now() - startTime; const matchingNoteIds = new Set(results.map(r => r.noteId)); - log.info(`[FTS5-ATTRIBUTES-TIME] Attribute search completed in ${searchTime}ms, found ${matchingNoteIds.size} notes`); return matchingNoteIds; } catch (error: any) { diff --git a/apps/server/src/services/search/services/build_comparator.ts b/apps/server/src/services/search/services/build_comparator.ts index c090b458f..4dafb3235 100644 --- a/apps/server/src/services/search/services/build_comparator.ts +++ b/apps/server/src/services/search/services/build_comparator.ts @@ -16,7 +16,9 @@ const stringComparators: Record> = { "=": (comparedValue) => (val) => { // For the = operator, check if the value contains the exact word or phrase // This is case-insensitive - if (!val) return false; + // Handle empty/falsy values explicitly + if (!val && !comparedValue) return true; // Both empty means equal + if (!val || !comparedValue) return false; // One empty, one not - not equal const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); @@ -33,7 +35,10 @@ const stringComparators: Record> = { }, "!=": (comparedValue) => (val) => { // Negation of exact word/phrase match - if (!val) return true; + // Handle empty/falsy values explicitly + if (!val && !comparedValue) return false; // Both empty means equal, so != returns false + if (!val) return true; // val is empty but comparedValue is not, they're not equal + if (!comparedValue) return true; // val is not empty but comparedValue is, they're not equal const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); diff --git a/apps/server/src/test/becca_mocking.ts b/apps/server/src/test/becca_mocking.ts index 26b4c5922..34ec36c3c 100644 --- a/apps/server/src/test/becca_mocking.ts +++ b/apps/server/src/test/becca_mocking.ts @@ -25,7 +25,7 @@ export class NoteBuilder { isInheritable, name, value - }).save(); + }); return this; } @@ -37,7 +37,7 @@ export class NoteBuilder { type: "relation", name, value: targetNote.noteId - }).save(); + }); return this; } @@ -49,7 +49,7 @@ export class NoteBuilder { parentNoteId: this.note.noteId, prefix, notePosition: 10 - }).save(); + }); return this; } @@ -70,7 +70,7 @@ export function note(title: string, extraParams: Partial = {}) { extraParams ); - const note = new BNote(row).save(); + const note = new BNote(row); return new NoteBuilder(note); }