feat(doc/search): Enhance search functionality with filter and validate options for advanced query control
This commit is contained in:
parent
498f586ddb
commit
436311ab06
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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();
|
||||
|
@ -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.'
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user