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