import * as plugins from './plugins.js'; import { SmartdataDb } from './classes.db.js'; 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'; export type TDocCreation = 'db' | 'new' | 'mixed'; // Set of searchable fields for each class const searchableFieldsMap = new Map>(); export function globalSvDb() { return (target: SmartDataDbDoc, key: string) => { console.log(`called svDb() on >${target.constructor.name}.${key}<`); if (!target.globalSaveableProperties) { target.globalSaveableProperties = []; } target.globalSaveableProperties.push(key); }; } /** * saveable - saveable decorator to be used on class properties */ export function svDb() { return (target: SmartDataDbDoc, key: string) => { console.log(`called svDb() on >${target.constructor.name}.${key}<`); if (!target.saveableProperties) { target.saveableProperties = []; } target.saveableProperties.push(key); }; } /** * searchable - marks a property as searchable with Lucene query syntax */ export function searchable() { return (target: SmartDataDbDoc, 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()); } // Add the property to the searchable fields set searchableFieldsMap.get(className).add(key); }; } /** * Get searchable fields for a class */ export function getSearchableFields(className: string): string[] { if (!searchableFieldsMap.has(className)) { return []; } return Array.from(searchableFieldsMap.get(className)); } /** * unique index - decorator to mark a unique index */ export function unI() { return (target: SmartDataDbDoc, key: string) => { console.log(`called unI on >>${target.constructor.name}.${key}<<`); // mark the index as unique if (!target.uniqueIndexes) { target.uniqueIndexes = []; } target.uniqueIndexes.push(key); // and also save it if (!target.saveableProperties) { target.saveableProperties = []; } target.saveableProperties.push(key); }; } /** * Options for MongoDB indexes */ export interface IIndexOptions { background?: boolean; unique?: boolean; sparse?: boolean; expireAfterSeconds?: number; [key: string]: any; } /** * index - decorator to mark a field for regular indexing */ export function index(options?: IIndexOptions) { return (target: SmartDataDbDoc, key: string) => { console.log(`called index() on >${target.constructor.name}.${key}<`); // Initialize regular indexes array if it doesn't exist if (!target.regularIndexes) { target.regularIndexes = []; } // Add this field to regularIndexes with its options target.regularIndexes.push({ field: key, options: options || {} }); // Also ensure it's marked as saveable if (!target.saveableProperties) { target.saveableProperties = []; } if (!target.saveableProperties.includes(key)) { target.saveableProperties.push(key); } }; } export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => { // Special case: detect MongoDB operators and pass them through directly const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex']; for (const key of Object.keys(filterArg)) { if (topLevelOperators.includes(key)) { return filterArg; // Return the filter as-is for MongoDB operators } } // Original conversion logic for non-MongoDB query objects const convertedFilter: { [key: string]: any } = {}; const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => { if (Array.isArray(filterArg2)) { // Directly assign arrays (they might be using operators like $in or $all) convertFilterArgument(keyPathArg2, filterArg2[0]); } else if (typeof filterArg2 === 'object' && filterArg2 !== null) { for (const key of Object.keys(filterArg2)) { if (key.startsWith('$')) { convertedFilter[keyPathArg2] = filterArg2; return; } else if (key.includes('.')) { throw new Error('keys cannot contain dots'); } } for (const key of Object.keys(filterArg2)) { convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]); } } else { convertedFilter[keyPathArg2] = filterArg2; } }; for (const key of Object.keys(filterArg)) { convertFilterArgument(key, filterArg[key]); } return convertedFilter; }; export class SmartDataDbDoc { /** * the collection object an Doc belongs to */ public static collection: SmartdataCollection; public collection: SmartdataCollection; public static defaultManager; public static manager; public manager: TManager; // STATIC public static createInstanceFromMongoDbNativeDoc( this: plugins.tsclass.typeFest.Class, mongoDbNativeDocArg: any, ): T { const newInstance = new this(); (newInstance as any).creationStatus = 'db'; for (const key of Object.keys(mongoDbNativeDocArg)) { newInstance[key] = mongoDbNativeDocArg[key]; } return newInstance; } /** * gets all instances as array * @param this * @param filterArg * @returns */ public static async getInstances( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, ): Promise { const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg)); const returnArray = []; for (const foundDoc of foundDocs) { const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc); returnArray.push(newInstance); } return returnArray; } /** * gets the first matching instance * @param this * @param filterArg * @returns */ public static async getInstance( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, ): Promise { const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg)); if (foundDoc) { const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc); return newInstance; } else { return null; } } /** * get a unique id prefixed with the class name */ public static async getNewId( this: plugins.tsclass.typeFest.Class, lengthArg: number = 20, ) { return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`; } /** * get cursor * @returns */ public static async getCursor( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, ) { const collection: SmartdataCollection = (this as any).collection; const cursor: SmartdataDbCursor = await collection.getCursor( convertFilterForMongoDb(filterArg), this as any as typeof SmartDataDbDoc, ); return cursor; } public static async getCursorExtended( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, modifierFunction = (cursorArg: plugins.mongodb.FindCursor>) => cursorArg, ): Promise> { const collection: SmartdataCollection = (this as any).collection; await collection.init(); let cursor: plugins.mongodb.FindCursor = collection.mongoDbCollection.find( convertFilterForMongoDb(filterArg), ); cursor = modifierFunction(cursor); return new SmartdataDbCursor(cursor, this as any as typeof SmartDataDbDoc); } /** * watch the collection * @param this * @param filterArg * @param forEachFunction */ public static async watch( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, ) { const collection: SmartdataCollection = (this as any).collection; const watcher: SmartdataDbWatcher = await collection.watch( convertFilterForMongoDb(filterArg), this as any, ); return watcher; } /** * run a function for all instances * @returns */ public static async forEach( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep, forEachFunction: (itemArg: T) => Promise, ) { const cursor: SmartdataDbCursor = await (this as any).getCursor(filterArg); await cursor.forEach(forEachFunction); } /** * returns a count of the documents in the collection */ public static async getCount( this: plugins.tsclass.typeFest.Class, filterArg: plugins.tsclass.typeFest.PartialDeep = {} as any, ) { const collection: SmartdataCollection = (this as any).collection; return await collection.getCount(filterArg); } /** * Create a MongoDB filter from a Lucene query string * @param luceneQuery Lucene query string * @returns MongoDB query object */ public static createSearchFilter( this: plugins.tsclass.typeFest.Class, luceneQuery: string, ): any { const className = (this as any).className || this.name; const searchableFields = getSearchableFields(className); if (searchableFields.length === 0) { throw new Error(`No searchable fields defined for class ${className}`); } const adapter = new SmartdataLuceneAdapter(searchableFields); return adapter.convert(luceneQuery); } /** * Search documents using Lucene query syntax * @param luceneQuery Lucene query string * @returns Array of matching documents */ public static async search( this: plugins.tsclass.typeFest.Class, luceneQuery: string, ): Promise { const filter = (this as any).createSearchFilter(luceneQuery); return await (this as any).getInstances(filter); } /** * Search documents using Lucene query syntax with robust error handling * @param luceneQuery The Lucene query string to search with * @returns Array of matching documents */ public static async searchWithLucene( this: plugins.tsclass.typeFest.Class, luceneQuery: string, ): Promise { try { const className = (this as any).className || this.name; const searchableFields = getSearchableFields(className); if (searchableFields.length === 0) { console.warn( `No searchable fields defined for class ${className}, falling back to simple search`, ); return (this as any).searchByTextAcrossFields(luceneQuery); } // Simple term search optimization if ( !luceneQuery.includes(':') && !luceneQuery.includes(' AND ') && !luceneQuery.includes(' OR ') && !luceneQuery.includes(' NOT ') ) { return (this as any).searchByTextAcrossFields(luceneQuery); } // Try to use the Lucene-to-MongoDB conversion const filter = (this as any).createSearchFilter(luceneQuery); return await (this as any).getInstances(filter); } catch (error) { console.error(`Error in searchWithLucene: ${error.message}`); return (this as any).searchByTextAcrossFields(luceneQuery); } } /** * 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( this: plugins.tsclass.typeFest.Class, searchText: string, ): Promise { 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 /** * how the Doc in memory was created, may prove useful later. */ public creationStatus: TDocCreation = 'new'; /** * updated from db in any case where doc comes from db */ @globalSvDb() _createdAt: string = new Date().toISOString(); /** * will be updated everytime the doc is saved */ @globalSvDb() _updatedAt: string = new Date().toISOString(); /** * an array of saveable properties of ALL doc */ public globalSaveableProperties: string[]; /** * unique indexes */ public uniqueIndexes: string[]; /** * regular indexes with their options */ public regularIndexes: Array<{field: string, options: IIndexOptions}> = []; /** * an array of saveable properties of a specific doc */ public saveableProperties: string[]; /** * name */ public name: string; /** * primary id in the database */ public dbDocUniqueId: string; /** * class constructor */ constructor() {} /** * saves this instance but not any connected items * may lead to data inconsistencies, but is faster */ public async save() { // tslint:disable-next-line: no-this-assignment const self: any = this; let dbResult: any; this._updatedAt = new Date().toISOString(); switch (this.creationStatus) { case 'db': dbResult = await this.collection.update(self); break; case 'new': dbResult = await this.collection.insert(self); this.creationStatus = 'db'; break; default: console.error('neither new nor in db?'); } return dbResult; } /** * deletes a document from the database */ public async delete() { await this.collection.delete(this); } /** * also store any referenced objects to DB * better for data consistency */ public saveDeep(savedMapArg: plugins.lik.ObjectMap> = null) { if (!savedMapArg) { savedMapArg = new plugins.lik.ObjectMap>(); } savedMapArg.add(this); this.save(); for (const propertyKey of Object.keys(this)) { const property: any = this[propertyKey]; if (property instanceof SmartDataDbDoc && !savedMapArg.checkForObject(property)) { property.saveDeep(savedMapArg); } } } /** * updates an object from db */ public async updateFromDb() { const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject()); for (const key of Object.keys(mongoDbNativeDoc)) { this[key] = mongoDbNativeDoc[key]; } } /** * creates a saveable object so the instance can be persisted as json in the database */ public async createSavableObject(): Promise { const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties]; for (const propertyNameString of saveableProperties) { saveableObject[propertyNameString] = this[propertyNameString]; } return saveableObject as TImplements; } /** * creates an identifiable object for operations that require filtering */ public async createIdentifiableObject() { const identifiableObject: any = {}; // is not exposed to outside, so any is ok here for (const propertyNameString of this.uniqueIndexes) { identifiableObject[propertyNameString] = this[propertyNameString]; } return identifiableObject; } }