fix(doc): Refactor searchable fields API and improve collection registration.

This commit is contained in:
2025-04-21 16:35:29 +00:00
parent eef758cabb
commit d0e769622e
7 changed files with 53 additions and 87 deletions

View File

@@ -8,8 +8,7 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
export type TDocCreation = 'db' | 'new' | 'mixed';
// Set of searchable fields for each class
const searchableFieldsMap = new Map<string, Set<string>>();
export function globalSvDb() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
@@ -39,28 +38,15 @@ export function svDb() {
*/
export function searchable() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
console.log(`called searchable() on >${target.constructor.name}.${key}<`);
// Initialize the set for this class if it doesn't exist
const className = target.constructor.name;
if (!searchableFieldsMap.has(className)) {
searchableFieldsMap.set(className, new Set<string>());
// Attach to class constructor for direct access
const ctor = target.constructor as any;
if (!Array.isArray(ctor.searchableFields)) {
ctor.searchableFields = [];
}
// Add the property to the searchable fields set
searchableFieldsMap.get(className).add(key);
ctor.searchableFields.push(key);
};
}
/**
* Get searchable fields for a class
*/
export function getSearchableFields(className: string): string[] {
if (!searchableFieldsMap.has(className)) {
return [];
}
return Array.from(searchableFieldsMap.get(className));
}
// Escape user input for safe use in MongoDB regular expressions
function escapeForRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -318,16 +304,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
this: plugins.tsclass.typeFest.Class<T>,
luceneQuery: string,
): any {
const className = (this as any).className || this.name;
const searchableFields = getSearchableFields(className);
const searchableFields = (this as any).getSearchableFields();
if (searchableFields.length === 0) {
throw new Error(`No searchable fields defined for class ${className}`);
throw new Error(`No searchable fields defined for class ${this.name}`);
}
const adapter = new SmartdataLuceneAdapter(searchableFields);
return adapter.convert(luceneQuery);
}
/**
* List all searchable fields defined on this class
*/
public static getSearchableFields(): string[] {
const ctor = this as any;
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
}
/**
* Search documents by text or field:value syntax, with safe regex fallback
@@ -338,10 +328,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
this: plugins.tsclass.typeFest.Class<T>,
query: string,
): Promise<T[]> {
const className = (this as any).className || this.name;
const searchableFields = getSearchableFields(className);
const searchableFields = (this as any).getSearchableFields();
if (searchableFields.length === 0) {
throw new Error(`No searchable fields defined for class ${className}`);
throw new Error(`No searchable fields defined for class ${this.name}`);
}
// empty query -> return all
const q = query.trim();
@@ -349,12 +338,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
return await (this as any).getInstances({});
}
// simple exact field:value (no spaces, no wildcards, no quotes)
// simple exact field:value (no spaces, wildcards, quotes)
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
if (simpleExact) {
const field = simpleExact[1];
const value = simpleExact[2];
if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${className}`);
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
}
return await (this as any).getInstances({ [field]: value });
}
@@ -374,7 +364,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const field = wildcardField[1];
const pattern = wildcardField[2];
if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${className}`);
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
}
// escape regex special chars except * and ?, then convert wildcards
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
@@ -412,54 +402,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
}
/**
* Search by text across all searchable fields (fallback method)
* @param searchText The text to search for in all searchable fields
* @returns Array of matching documents
*/
private static async searchByTextAcrossFields<T>(
this: plugins.tsclass.typeFest.Class<T>,
searchText: string,
): Promise<T[]> {
try {
const className = (this as any).className || this.name;
const searchableFields = getSearchableFields(className);
// Fallback to direct filter if we have searchable fields
if (searchableFields.length > 0) {
// Create a simple $or query with regex for each field
const orConditions = searchableFields.map((field) => ({
[field]: { $regex: searchText, $options: 'i' },
}));
const filter = { $or: orConditions };
try {
// Try with MongoDB filter first
return await (this as any).getInstances(filter);
} catch (error) {
console.warn('MongoDB filter failed, falling back to in-memory search');
}
}
// Last resort: get all and filter in memory
const allDocs = await (this as any).getInstances({});
const lowerSearchText = searchText.toLowerCase();
return allDocs.filter((doc: any) => {
for (const field of searchableFields) {
const value = doc[field];
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
return true;
}
}
return false;
});
} catch (error) {
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
return [];
}
}
// INSTANCE
// INSTANCE