fix(doc): Refactor searchable fields API and improve collection registration.
This commit is contained in:
		| @@ -1,5 +1,13 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-04-21 - 5.11.1 - fix(doc) | ||||
| Refactor searchable fields API and improve collection registration. | ||||
|  | ||||
| - Removed the standalone getSearchableFields utility in favor of a static method on document classes. | ||||
| - Updated tests to use the new static method (e.g., Product.getSearchableFields()). | ||||
| - Ensured the Collection decorator attaches a docCtor property to correctly register searchable fields. | ||||
| - Added try/catch in test cleanup to gracefully handle dropDatabase errors. | ||||
|  | ||||
| ## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter) | ||||
| Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities. | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { tap, expect } from '@push.rocks/tapbundle'; | ||||
| import * as smartmongo from '@push.rocks/smartmongo'; | ||||
| import * as smartdata from '../ts/index.js'; | ||||
| import { searchable, getSearchableFields } from '../ts/classes.doc.js'; | ||||
| import { searchable } from '../ts/classes.doc.js'; | ||||
| import { smartunique } from '../ts/plugins.js'; | ||||
|  | ||||
| // Set up database connection | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { smartunique } from '../ts/plugins.js'; | ||||
|  | ||||
| // Import the smartdata library | ||||
| import * as smartdata from '../ts/index.js'; | ||||
| import { searchable, getSearchableFields } from '../ts/classes.doc.js'; | ||||
| import { searchable } from '../ts/classes.doc.js'; | ||||
|  | ||||
| // Set up database connection | ||||
| let smartmongoInstance: smartmongo.SmartMongo; | ||||
| @@ -72,7 +72,7 @@ tap.test('should create test products with searchable fields', async () => { | ||||
|  | ||||
| tap.test('should retrieve searchable fields for a class', async () => { | ||||
|   // Use the getSearchableFields function to verify our searchable fields | ||||
|   const searchableFields = getSearchableFields('Product'); | ||||
|   const searchableFields = Product.getSearchableFields(); | ||||
|   console.log('Searchable fields:', searchableFields); | ||||
|  | ||||
|   expect(searchableFields.length).toEqual(3); | ||||
|   | ||||
| @@ -64,7 +64,11 @@ tap.test('should watch a collection', async (toolsArg) => { | ||||
| // close the database connection | ||||
| // ======================================= | ||||
| tap.test('close', async () => { | ||||
|   await testDb.mongoDb.dropDatabase(); | ||||
|   try { | ||||
|     await testDb.mongoDb.dropDatabase(); | ||||
|   } catch (err) { | ||||
|     console.warn('dropDatabase error ignored in cleanup:', err.message || err); | ||||
|   } | ||||
|   await testDb.close(); | ||||
|   if (smartmongoInstance) { | ||||
|     await smartmongoInstance.stop(); | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartdata', | ||||
|   version: '5.11.0', | ||||
|   version: '5.11.1', | ||||
|   description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.' | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import * as plugins from './plugins.js'; | ||||
| import { SmartdataDb } from './classes.db.js'; | ||||
| import { SmartdataDbCursor } from './classes.cursor.js'; | ||||
| import { SmartDataDbDoc, type IIndexOptions, getSearchableFields } from './classes.doc.js'; | ||||
| import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js'; | ||||
| import { SmartdataDbWatcher } from './classes.watcher.js'; | ||||
| import { CollectionFactory } from './classes.collectionfactory.js'; | ||||
|  | ||||
| @@ -32,13 +32,22 @@ export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) { | ||||
|         if (!(dbArg instanceof SmartdataDb)) { | ||||
|           dbArg = dbArg(); | ||||
|         } | ||||
|         return collectionFactory.getCollection(constructor.name, dbArg); | ||||
|         const coll = collectionFactory.getCollection(constructor.name, dbArg); | ||||
|         // Attach document constructor for searchableFields lookup | ||||
|         if (!(coll as any).docCtor) { | ||||
|           (coll as any).docCtor = decoratedClass; | ||||
|         } | ||||
|         return coll; | ||||
|       } | ||||
|       public get collection() { | ||||
|         if (!(dbArg instanceof SmartdataDb)) { | ||||
|           dbArg = dbArg(); | ||||
|         } | ||||
|         return collectionFactory.getCollection(constructor.name, dbArg); | ||||
|         const coll = collectionFactory.getCollection(constructor.name, dbArg); | ||||
|         if (!(coll as any).docCtor) { | ||||
|           (coll as any).docCtor = decoratedClass; | ||||
|         } | ||||
|         return coll; | ||||
|       } | ||||
|     }; | ||||
|     return decoratedClass; | ||||
| @@ -156,7 +165,9 @@ export class SmartdataCollection<T> { | ||||
|       } | ||||
|       this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName); | ||||
|       // Auto-create a compound text index on all searchable fields | ||||
|       const searchableFields = getSearchableFields(this.collectionName); | ||||
|       // Use document constructor's searchableFields registered via decorator | ||||
|       const docCtor = (this as any).docCtor; | ||||
|       const searchableFields: string[] = docCtor?.searchableFields || []; | ||||
|       if (searchableFields.length > 0 && !this.textIndexCreated) { | ||||
|         // Build a compound text index spec | ||||
|         const indexSpec: Record<string, 'text'> = {}; | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user