feat(doc/search): Enhance search functionality with filter and validate options for advanced query control

This commit is contained in:
Philipp Kunz 2025-04-22 19:13:17 +00:00
parent 498f586ddb
commit 436311ab06
5 changed files with 72 additions and 11 deletions

View File

@ -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

View File

@ -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:

View File

@ -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();

View File

@ -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.'
}

View File

@ -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<T> {
filter?: Record<string, any>;
validate?: (doc: T) => Promise<boolean> | boolean;
}
export type TDocCreation = 'db' | 'new' | 'mixed';
@ -318,15 +327,40 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const ctor = this as any;
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
}
/**
* Execute a query with optional hard filter and post-fetch validation
*/
private static async execQuery<T>(
this: plugins.tsclass.typeFest.Class<T>,
baseFilter: Record<string, any>,
opts?: SearchOptions<T>
): Promise<T[]> {
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<T>(
this: plugins.tsclass.typeFest.Class<T>,
query: string,
opts?: SearchOptions<T>,
): Promise<T[]> {
const searchableFields = (this as any).getSearchableFields();
if (searchableFields.length === 0) {
@ -335,7 +369,8 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
// empty query -> 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<T extends TImplements, TImplements, TManager extends
if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
}
return await (this as any).getInstances({ [field]: value });
// simple field:value search
return await (this as any).execQuery({ [field]: value }, opts);
}
// quoted phrase across all searchable fields: exact match of phrase
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
if (quoted) {
const phrase = quoted[1] || quoted[2] || '';
// build regex that matches the exact phrase (allowing flexible whitespace)
const parts = phrase.split(/\s+/).map((t) => 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<T extends TImplements, TImplements, TManager extends
// escape regex special chars except * and ?, then convert wildcards
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
return await (this as any).getInstances({ [field]: { $regex: regexPattern, $options: 'i' } });
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
}
// wildcard plain term across all fields (supports * and ?)
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
@ -377,7 +412,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
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);
}
// implicit AND for mixed simple term + field:value queries (no explicit operators)
const parts = q.split(/\s+/);
@ -405,13 +440,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
return { $or: ors };
}
});
return await (this as any).getInstances({ $and: andConds });
return await (this as any).execQuery({ $and: andConds }, opts);
}
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
if (luceneSyntax.test(q)) {
const filter = (this as any).createSearchFilter(q);
return await (this as any).getInstances(filter);
return await (this as any).execQuery(filter, opts);
}
// multi-term unquoted -> AND of regex across fields for each term
const terms = q.split(/\s+/);
@ -421,12 +456,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const ors = searchableFields.map((f) => ({ [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);
}