diff --git a/changelog.md b/changelog.md index 8d6cd23..d614263 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-04-22 - 5.12.0 - feat(doc/search) +Enhance search functionality with filter and validate options for advanced query control + +- Added 'filter' option to merge additional MongoDB query constraints in search +- Introduced 'validate' hook to post-process and filter fetched documents +- Refactored underlying execQuery function to support additional search options +- Updated tests to cover new search scenarios and fallback mechanisms + ## 2025-04-22 - 5.11.4 - fix(search) Implement implicit AND logic for mixed simple term and field:value queries in search diff --git a/readme.md b/readme.md index 381a318..185c928 100644 --- a/readme.md +++ b/readme.md @@ -225,6 +225,10 @@ await Product.search('TypeScript Aufgabe'); // 7: Empty query returns all documents await Product.search(''); +// 8: Scoped search with additional filter (e.g. multi-tenant isolation) +await Product.search('book', { filter: { ownerId: currentUserId } }); +// 9: Post-search validation hook to drop unwanted results (e.g. price check) +await Product.search('', { validate: (p) => p.price < 100 }); ``` The search functionality includes: diff --git a/test/test.search.ts b/test/test.search.ts index 936a371..125dbee 100644 --- a/test/test.search.ts +++ b/test/test.search.ts @@ -276,6 +276,20 @@ tap.test('should support wildcard plain term with question mark pattern', async expect(names).toEqual(['Galaxy S21', 'iPhone 12']); }); +// Filter and Validation tests +tap.test('should apply filter option to restrict results', async () => { + // search term 'book' across all fields but restrict to Books category + const bookFiltered = await Product.search('book', { filter: { category: 'Books' } }); + expect(bookFiltered.length).toEqual(2); + bookFiltered.forEach((p) => expect(p.category).toEqual('Books')); +}); +tap.test('should apply validate hook to post-filter results', async () => { + // return only products with price > 500 + const expensive = await Product.search('', { validate: (p) => p.price > 500 }); + expect(expensive.length).toBeGreaterThan(0); + expensive.forEach((p) => expect(p.price).toBeGreaterThan(500)); +}); + // Close database connection tap.test('close database connection', async () => { await testDb.mongoDb.dropDatabase(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 1431345..29adb24 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartdata', - version: '5.11.4', + version: '5.12.0', description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.' } diff --git a/ts/classes.doc.ts b/ts/classes.doc.ts index 178ade6..abcffbf 100644 --- a/ts/classes.doc.ts +++ b/ts/classes.doc.ts @@ -5,6 +5,15 @@ import { SmartdataDbCursor } from './classes.cursor.js'; import { type IManager, SmartdataCollection } from './classes.collection.js'; import { SmartdataDbWatcher } from './classes.watcher.js'; import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js'; +/** + * Search options for `.search()`: + * - filter: additional MongoDB query to AND-merge + * - validate: post-fetch validator, return true to keep a doc + */ +export interface SearchOptions { + filter?: Record; + validate?: (doc: T) => Promise | boolean; +} export type TDocCreation = 'db' | 'new' | 'mixed'; @@ -318,15 +327,40 @@ export class SmartDataDbDoc( + this: plugins.tsclass.typeFest.Class, + baseFilter: Record, + opts?: SearchOptions + ): Promise { + let mongoFilter = baseFilter || {}; + if (opts?.filter) { + mongoFilter = { $and: [mongoFilter, opts.filter] }; + } + let docs: T[] = await (this as any).getInstances(mongoFilter); + if (opts?.validate) { + const out: T[] = []; + for (const d of docs) { + if (await opts.validate(d)) out.push(d); + } + docs = out; + } + return docs; + } /** * Search documents by text or field:value syntax, with safe regex fallback + * Supports additional filtering and post-fetch validation via opts * @param query A search term or field:value expression + * @param opts Optional filter and validate hooks * @returns Array of matching documents */ public static async search( this: plugins.tsclass.typeFest.Class, query: string, + opts?: SearchOptions, ): Promise { const searchableFields = (this as any).getSearchableFields(); if (searchableFields.length === 0) { @@ -335,7 +369,8 @@ export class SmartDataDbDoc return all const q = query.trim(); if (!q) { - return await (this as any).getInstances({}); + // empty query: fetch all, apply opts + return await (this as any).execQuery({}, opts); } // simple exact field:value (no spaces, no wildcards, no quotes) // simple exact field:value (no spaces, wildcards, quotes) @@ -346,17 +381,17 @@ export class SmartDataDbDoc escapeForRegex(t)); const pattern = parts.join('\\s+'); const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })); - return await (this as any).getInstances({ $or: orConds }); + return await (this as any).execQuery({ $or: orConds }, opts); } // wildcard field:value (supports * and ?) -> direct regex on that field const wildcardField = q.match(/^(\w+):(.+[*?].*)$/); @@ -369,7 +404,7 @@ export class SmartDataDbDoc ({ [f]: { $regex: pattern, $options: 'i' } })); - return await (this as any).getInstances({ $or: orConds }); + return await (this as any).execQuery({ $or: orConds }, opts); } // implicit AND for mixed simple term + field:value queries (no explicit operators) const parts = q.split(/\s+/); @@ -405,13 +440,13 @@ export class SmartDataDbDoc AND of regex across fields for each term const terms = q.split(/\s+/); @@ -421,12 +456,12 @@ export class SmartDataDbDoc ({ [f]: { $regex: esc, $options: 'i' } })); return { $or: ors }; }); - return await (this as any).getInstances({ $and: andConds }); + return await (this as any).execQuery({ $and: andConds }, opts); } // single term -> regex across all searchable fields const esc = escapeForRegex(q); const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })); - return await (this as any).getInstances({ $or: orConds }); + return await (this as any).execQuery({ $or: orConds }, opts); }