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'; /** * Search options for `.search()`: * - filter: additional MongoDB query to AND-merge * - validate: post-fetch validator, return true to keep a doc */ export interface SearchOptions { filter?: Record; validate?: (doc: T) => Promise | boolean; } export type TDocCreation = 'db' | 'new' | 'mixed'; 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) => { // Attach to class constructor for direct access const ctor = target.constructor as any; if (!Array.isArray(ctor.searchableFields)) { ctor.searchableFields = []; } ctor.searchableFields.push(key); }; } // Escape user input for safe use in MongoDB regular expressions function escapeForRegex(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * 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 searchableFields = (this as any).getSearchableFields(); if (searchableFields.length === 0) { 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 : []; } /** * Execute a query with optional hard filter and post-fetch validation */ private static async execQuery( this: plugins.tsclass.typeFest.Class, baseFilter: Record, opts?: SearchOptions ): Promise { 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( this: plugins.tsclass.typeFest.Class, query: string, opts?: SearchOptions, ): Promise { const searchableFields = (this as any).getSearchableFields(); if (searchableFields.length === 0) { throw new Error(`No searchable fields defined for class ${this.name}`); } // empty query -> return all const q = query.trim(); if (!q) { // 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) 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 ${this.name}`); } // 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] || ''; 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).execQuery({ $or: orConds }, opts); } // wildcard field:value (supports * and ?) -> direct regex on that field const wildcardField = q.match(/^(\w+):(.+[*?].*)$/); if (wildcardField) { const field = wildcardField[1]; // Support quoted wildcard patterns: strip surrounding quotes let pattern = wildcardField[2]; if ((pattern.startsWith('"') && pattern.endsWith('"')) || (pattern.startsWith("'") && pattern.endsWith("'"))) { pattern = pattern.slice(1, -1); } if (!searchableFields.includes(field)) { 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'); const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); 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('?'))) { // build wildcard regex pattern: escape all except * and ? then convert 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).execQuery({ $or: orConds }, opts); } // implicit AND: combine free terms and field:value terms (with or without wildcards) const parts = q.split(/\s+/); const hasColon = parts.some((t) => t.includes(':')); if ( parts.length > 1 && hasColon && !q.includes(' AND ') && !q.includes(' OR ') && !q.includes(' NOT ') && !q.includes('(') && !q.includes(')') && !q.includes('[') && !q.includes(']') && !q.includes('"') && !q.includes("'") ) { const andConds = parts.map((term) => { const m = term.match(/^(\w+):(.+)$/); if (m) { const field = m[1]; const value = m[2]; if (!searchableFields.includes(field)) { throw new Error(`Field '${field}' is not searchable for class ${this.name}`); } if (value.includes('*') || value.includes('?')) { // wildcard field search const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1'); const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); return { [field]: { $regex: pattern, $options: 'i' } }; } // exact field:value return { [field]: value }; } // free term -> regex across all searchable fields const esc = escapeForRegex(term); return { $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) }; }); return await (this as any).execQuery({ $and: andConds }, opts); } // free term and quoted field phrase (exact or wildcard), e.g. 'term field:"phrase"' or 'term field:"ph*se"' const freeWithQuotedField = q.match(/^(\S+)\s+(\w+):"(.+)"$/); if (freeWithQuotedField) { const freeTerm = freeWithQuotedField[1]; const field = freeWithQuotedField[2]; let phrase = freeWithQuotedField[3]; if (!searchableFields.includes(field)) { throw new Error(`Field '${field}' is not searchable for class ${this.name}`); } // free term condition across all searchable fields const freeEsc = escapeForRegex(freeTerm); const freeCond = { $or: searchableFields.map((f) => ({ [f]: { $regex: freeEsc, $options: 'i' } })) }; // field condition: exact match or wildcard pattern let fieldCond; if (phrase.includes('*') || phrase.includes('?')) { const escaped = phrase.replace(/([.+^${}()|[\\]\\])/g, '\\$1'); const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); fieldCond = { [field]: { $regex: pattern, $options: 'i' } }; } else { fieldCond = { [field]: phrase }; } return await (this as any).execQuery({ $and: [freeCond, fieldCond] }, 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).execQuery(filter, opts); } // multi-term unquoted -> AND of regex across fields for each term const terms = q.split(/\s+/); if (terms.length > 1) { const andConds = terms.map((term) => { const esc = escapeForRegex(term); const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })); return { $or: ors }; }); 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).execQuery({ $or: orConds }, opts); } // INSTANCE // 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; } }