smartdata/ts/classes.doc.ts

571 lines
18 KiB
TypeScript
Raw Normal View History

import * as plugins from './plugins.js';
2016-09-13 22:53:21 +02:00
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';
2016-09-13 22:53:21 +02:00
export type TDocCreation = 'db' | 'new' | 'mixed';
2016-09-13 22:53:21 +02:00
export function globalSvDb() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
if (!target.globalSaveableProperties) {
target.globalSaveableProperties = [];
}
target.globalSaveableProperties.push(key);
};
}
2016-11-18 00:42:25 +01:00
/**
2016-11-18 00:59:57 +01:00
* saveable - saveable decorator to be used on class properties
2016-11-18 00:42:25 +01:00
*/
2016-11-18 13:56:15 +01:00
export function svDb() {
2020-02-19 18:30:34 +00:00
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
2020-09-25 20:42:38 +00:00
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
if (!target.saveableProperties) {
target.saveableProperties = [];
}
target.saveableProperties.push(key);
};
2016-11-18 00:42:25 +01:00
}
/**
* searchable - marks a property as searchable with Lucene query syntax
*/
export function searchable() {
return (target: SmartDataDbDoc<unknown, unknown>, 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() {
2020-02-19 18:30:34 +00:00
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
2020-09-25 20:42:38 +00:00
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);
};
2019-01-07 02:41:38 +01:00
}
/**
* 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<unknown, unknown>, 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);
}
};
}
2021-11-12 16:23:26 +01:00
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
2021-11-12 16:23:26 +01:00
const convertedFilter: { [key: string]: any } = {};
2021-11-12 16:23:26 +01:00
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) {
2021-11-12 16:23:26 +01:00
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');
2021-11-12 16:23:26 +01:00
}
}
for (const key of Object.keys(filterArg2)) {
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
}
} else {
convertedFilter[keyPathArg2] = filterArg2;
}
};
2021-11-12 16:23:26 +01:00
for (const key of Object.keys(filterArg)) {
convertFilterArgument(key, filterArg[key]);
}
return convertedFilter;
};
2024-06-18 20:12:14 +02:00
export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends IManager = any> {
2017-02-25 11:37:05 +01:00
/**
* the collection object an Doc belongs to
*/
2020-09-10 10:12:17 +00:00
public static collection: SmartdataCollection<any>;
public collection: SmartdataCollection<any>;
2021-09-17 22:34:15 +02:00
public static defaultManager;
2021-06-09 15:38:14 +02:00
public static manager;
2021-06-09 14:10:08 +02:00
public manager: TManager;
2016-11-18 00:42:25 +01:00
2022-05-17 00:33:44 +02:00
// STATIC
2021-11-12 16:23:26 +01:00
public static createInstanceFromMongoDbNativeDoc<T>(
this: plugins.tsclass.typeFest.Class<T>,
mongoDbNativeDocArg: any,
2021-11-12 16:23:26 +01:00
): 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
*/
2020-08-18 15:10:44 +00:00
public static async getInstances<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
2020-08-18 15:10:44 +00:00
): Promise<T[]> {
2021-11-12 16:23:26 +01:00
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg));
const returnArray = [];
2021-11-12 16:23:26 +01:00
for (const foundDoc of foundDocs) {
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
returnArray.push(newInstance);
}
return returnArray;
}
2021-11-12 16:23:26 +01:00
/**
* gets the first matching instance
* @param this
* @param filterArg
* @returns
*/
2020-08-18 15:10:44 +00:00
public static async getInstance<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
2020-08-18 15:10:44 +00:00
): Promise<T> {
2021-11-12 16:23:26 +01:00
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;
}
2017-02-25 11:37:05 +01:00
}
2016-11-18 00:42:25 +01:00
2024-03-26 13:21:36 +01:00
/**
* get a unique id prefixed with the class name
*/
public static async getNewId<T = any>(
this: plugins.tsclass.typeFest.Class<T>,
lengthArg: number = 20,
) {
2024-03-27 17:30:14 +01:00
return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`;
2024-03-26 13:21:36 +01:00
}
2021-11-12 16:23:26 +01:00
/**
* get cursor
* @returns
*/
public static async getCursor<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
2021-11-12 16:23:26 +01:00
) {
2022-05-17 23:54:26 +02:00
const collection: SmartdataCollection<T> = (this as any).collection;
const cursor: SmartdataDbCursor<T> = await collection.getCursor(
convertFilterForMongoDb(filterArg),
this as any as typeof SmartDataDbDoc,
2021-11-12 19:02:29 +01:00
);
2021-11-12 16:23:26 +01:00
return cursor;
}
public static async getCursorExtended<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
modifierFunction = (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => cursorArg,
): Promise<SmartdataDbCursor<T>> {
const collection: SmartdataCollection<T> = (this as any).collection;
await collection.init();
let cursor: plugins.mongodb.FindCursor<any> = collection.mongoDbCollection.find(
convertFilterForMongoDb(filterArg),
);
cursor = modifierFunction(cursor);
return new SmartdataDbCursor<T>(cursor, this as any as typeof SmartDataDbDoc);
}
2022-05-17 00:33:44 +02:00
/**
* watch the collection
2022-11-01 18:23:57 +01:00
* @param this
* @param filterArg
* @param forEachFunction
2022-05-17 00:33:44 +02:00
*/
2022-11-01 18:23:57 +01:00
public static async watch<T>(
2022-05-17 00:33:44 +02:00
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
2022-05-17 00:33:44 +02:00
) {
const collection: SmartdataCollection<T> = (this as any).collection;
const watcher: SmartdataDbWatcher<T> = await collection.watch(
2022-05-17 23:54:26 +02:00
convertFilterForMongoDb(filterArg),
this as any,
2022-05-17 00:33:44 +02:00
);
return watcher;
}
2021-11-12 16:23:26 +01:00
/**
* run a function for all instances
* @returns
*/
public static async forEach<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
forEachFunction: (itemArg: T) => Promise<any>,
2021-11-12 16:23:26 +01:00
) {
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
2021-11-12 19:02:29 +01:00
await cursor.forEach(forEachFunction);
2021-11-12 16:23:26 +01:00
}
/**
* returns a count of the documents in the collection
*/
public static async getCount<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any,
) {
const collection: SmartdataCollection<T> = (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<T>(
this: plugins.tsclass.typeFest.Class<T>,
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 : [];
}
/**
* Search documents by text or field:value syntax, with safe regex fallback
* @param query A search term or field:value expression
* @returns Array of matching documents
*/
public static async search<T>(
this: plugins.tsclass.typeFest.Class<T>,
query: string,
): Promise<T[]> {
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) {
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 ${this.name}`);
}
return await (this as any).getInstances({ [field]: value });
}
// 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 });
}
// wildcard field:value (supports * and ?) -> direct regex on that field
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
if (wildcardField) {
const field = wildcardField[1];
const pattern = wildcardField[2];
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).getInstances({ [field]: { $regex: regexPattern, $options: 'i' } });
}
// 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).getInstances({ $or: orConds });
}
// implicit AND for mixed simple term + field:value queries (no explicit operators)
const parts = q.split(/\s+/);
const hasColon = parts.some((t) => t.includes(':'));
// implicit AND for mixed simple term + field:value queries (no explicit operators or range syntax)
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("'") &&
!q.includes('*') && !q.includes('?')
) {
const andConds = parts.map((term) => {
const m = term.match(/^(\\w+):([^"'\\*\\?\\s]+)$/);
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}`);
}
return { [field]: value };
} else {
const esc = escapeForRegex(term);
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return { $or: ors };
}
});
return await (this as any).getInstances({ $and: andConds });
}
// 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);
}
// 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).getInstances({ $and: andConds });
}
// 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 });
}
// INSTANCE
2022-05-17 00:33:44 +02:00
// INSTANCE
/**
* how the Doc in memory was created, may prove useful later.
*/
public creationStatus: TDocCreation = 'new';
2024-04-14 01:24:21 +02:00
/**
* updated from db in any case where doc comes from db
*/
@globalSvDb()
_createdAt: string = new Date().toISOString();
2024-04-14 01:24:21 +02:00
/**
* will be updated everytime the doc is saved
*/
@globalSvDb()
_updatedAt: string = new Date().toISOString();
2024-04-14 01:24:21 +02:00
/**
* an array of saveable properties of ALL doc
*/
public globalSaveableProperties: string[];
2022-05-17 00:33:44 +02:00
/**
* unique indexes
*/
public uniqueIndexes: string[];
/**
* regular indexes with their options
*/
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
2022-05-17 00:33:44 +02:00
/**
* an array of saveable properties of a specific doc
2022-05-17 00:33:44 +02:00
*/
public saveableProperties: string[];
/**
* name
*/
public name: string;
/**
* primary id in the database
*/
public dbDocUniqueId: string;
/**
* class constructor
*/
constructor() {}
2017-02-25 11:37:05 +01:00
/**
* saves this instance but not any connected items
* may lead to data inconsistencies, but is faster
*/
2019-09-02 16:42:29 +02:00
public async save() {
2019-01-08 14:37:17 +01:00
// tslint:disable-next-line: no-this-assignment
2019-09-02 16:42:29 +02:00
const self: any = this;
2020-09-25 21:05:21 +00:00
let dbResult: any;
2024-04-14 01:24:21 +02:00
this._updatedAt = new Date().toISOString();
2024-04-14 01:24:21 +02:00
switch (this.creationStatus) {
case 'db':
2020-09-25 21:05:21 +00:00
dbResult = await this.collection.update(self);
break;
case 'new':
2020-09-25 21:05:21 +00:00
dbResult = await this.collection.insert(self);
this.creationStatus = 'db';
break;
default:
console.error('neither new nor in db?');
2017-02-25 11:37:05 +01:00
}
2020-09-25 21:05:21 +00:00
return dbResult;
2017-02-25 11:37:05 +01:00
}
2019-01-08 18:45:30 +01:00
/**
* deletes a document from the database
*/
2019-09-02 16:58:19 +02:00
public async delete() {
2020-09-10 10:12:17 +00:00
await this.collection.delete(this);
2019-09-02 16:58:19 +02:00
}
2019-01-08 18:45:30 +01:00
2017-02-25 11:37:05 +01:00
/**
* also store any referenced objects to DB
* better for data consistency
*/
2023-07-21 20:08:18 +02:00
public saveDeep(savedMapArg: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>> = null) {
2017-02-25 11:37:05 +01:00
if (!savedMapArg) {
2023-07-21 20:08:18 +02:00
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
2017-02-25 11:37:05 +01:00
}
savedMapArg.add(this);
this.save();
2019-09-02 16:42:29 +02:00
for (const propertyKey of Object.keys(this)) {
const property: any = this[propertyKey];
2018-07-10 00:02:04 +02:00
if (property instanceof SmartDataDbDoc && !savedMapArg.checkForObject(property)) {
property.saveDeep(savedMapArg);
2017-02-25 11:37:05 +01:00
}
2016-09-13 22:53:21 +02:00
}
2017-02-25 11:37:05 +01:00
}
2023-02-06 11:43:11 +01:00
/**
* updates an object from db
*/
public async updateFromDb() {
2023-08-15 01:01:16 +02:00
const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
2023-02-06 11:43:11 +01:00
for (const key of Object.keys(mongoDbNativeDoc)) {
this[key] = mongoDbNativeDoc[key];
}
}
2019-01-08 18:45:30 +01:00
/**
* creates a saveable object so the instance can be persisted as json in the database
*/
2020-02-19 18:30:34 +00:00
public async createSavableObject(): Promise<TImplements> {
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
2024-05-31 18:47:48 +02:00
for (const propertyNameString of saveableProperties) {
saveableObject[propertyNameString] = this[propertyNameString];
}
2020-02-19 18:30:34 +00:00
return saveableObject as TImplements;
}
2019-01-08 18:45:30 +01:00
/**
* 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;
}
}