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

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

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);
}