708 lines
23 KiB
TypeScript
708 lines
23 KiB
TypeScript
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<T> {
|
||
/**
|
||
* Additional MongoDB filter to AND‐merge into the query
|
||
*/
|
||
filter?: Record<string, any>;
|
||
/**
|
||
* Post‐fetch validator; return true to keep each doc
|
||
*/
|
||
validate?: (doc: T) => Promise<boolean> | boolean;
|
||
/**
|
||
* Optional MongoDB session for transactional operations
|
||
*/
|
||
session?: plugins.mongodb.ClientSession;
|
||
}
|
||
|
||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||
|
||
|
||
|
||
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);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Options for custom serialization/deserialization of a field.
|
||
*/
|
||
export interface SvDbOptions {
|
||
/** Function to serialize the field value before saving to DB */
|
||
serialize?: (value: any) => any;
|
||
/** Function to deserialize the field value after reading from DB */
|
||
deserialize?: (value: any) => any;
|
||
}
|
||
|
||
/**
|
||
* saveable - saveable decorator to be used on class properties
|
||
*/
|
||
export function svDb(options?: SvDbOptions) {
|
||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||
if (!target.saveableProperties) {
|
||
target.saveableProperties = [];
|
||
}
|
||
target.saveableProperties.push(key);
|
||
// attach custom serializer/deserializer options to the class constructor
|
||
const ctor = target.constructor as any;
|
||
if (!ctor._svDbOptions) {
|
||
ctor._svDbOptions = {};
|
||
}
|
||
if (options) {
|
||
ctor._svDbOptions[key] = options;
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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() {
|
||
return (target: SmartDataDbDoc<unknown, unknown>, 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<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);
|
||
}
|
||
};
|
||
}
|
||
|
||
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<T extends TImplements, TImplements, TManager extends IManager = any> {
|
||
/**
|
||
* the collection object an Doc belongs to
|
||
*/
|
||
public static collection: SmartdataCollection<any>;
|
||
public collection: SmartdataCollection<any>;
|
||
public static defaultManager;
|
||
public static manager;
|
||
public manager: TManager;
|
||
|
||
// STATIC
|
||
public static createInstanceFromMongoDbNativeDoc<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
mongoDbNativeDocArg: any,
|
||
): T {
|
||
const newInstance = new this();
|
||
(newInstance as any).creationStatus = 'db';
|
||
for (const key of Object.keys(mongoDbNativeDocArg)) {
|
||
const rawValue = mongoDbNativeDocArg[key];
|
||
const optionsMap = (this as any)._svDbOptions || {};
|
||
const opts = optionsMap[key];
|
||
newInstance[key] = opts && typeof opts.deserialize === 'function'
|
||
? opts.deserialize(rawValue)
|
||
: rawValue;
|
||
}
|
||
return newInstance;
|
||
}
|
||
|
||
/**
|
||
* gets all instances as array
|
||
* @param this
|
||
* @param filterArg
|
||
* @returns
|
||
*/
|
||
public static async getInstances<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||
opts?: { session?: plugins.mongodb.ClientSession }
|
||
): Promise<T[]> {
|
||
// Pass session through to findAll for transactional queries
|
||
const foundDocs = await (this as any).collection.findAll(
|
||
convertFilterForMongoDb(filterArg),
|
||
{ session: opts?.session },
|
||
);
|
||
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<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||
opts?: { session?: plugins.mongodb.ClientSession }
|
||
): Promise<T> {
|
||
// Retrieve one document, with optional session for transactions
|
||
const foundDoc = await (this as any).collection.findOne(
|
||
convertFilterForMongoDb(filterArg),
|
||
{ session: opts?.session },
|
||
);
|
||
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<T = any>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
lengthArg: number = 20,
|
||
) {
|
||
return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`;
|
||
}
|
||
|
||
/**
|
||
* get cursor
|
||
* @returns
|
||
*/
|
||
/**
|
||
* Get a cursor for streaming results, with optional session
|
||
*/
|
||
public static async getCursor<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||
opts?: { session?: plugins.mongodb.ClientSession }
|
||
) {
|
||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||
const cursor: SmartdataDbCursor<T> = await collection.getCursor(
|
||
convertFilterForMongoDb(filterArg),
|
||
this as any as typeof SmartDataDbDoc,
|
||
{ session: opts?.session },
|
||
);
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* watch the collection
|
||
* @param this
|
||
* @param filterArg
|
||
* @param forEachFunction
|
||
*/
|
||
public static async watch<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||
) {
|
||
const collection: SmartdataCollection<T> = (this as any).collection;
|
||
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
||
convertFilterForMongoDb(filterArg),
|
||
this as any,
|
||
);
|
||
return watcher;
|
||
}
|
||
|
||
/**
|
||
* 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>,
|
||
) {
|
||
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
||
await cursor.forEach(forEachFunction);
|
||
}
|
||
|
||
/**
|
||
* 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 : [];
|
||
}
|
||
/**
|
||
* Execute a query with optional hard filter and post-fetch validation
|
||
*/
|
||
private static async execQuery<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
baseFilter: Record<string, any>,
|
||
opts?: SearchOptions<T>
|
||
): Promise<T[]> {
|
||
let mongoFilter = baseFilter || {};
|
||
if (opts?.filter) {
|
||
mongoFilter = { $and: [mongoFilter, opts.filter] };
|
||
}
|
||
// Fetch with optional session for transactions
|
||
// Fetch within optional session
|
||
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
|
||
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<T>(
|
||
this: plugins.tsclass.typeFest.Class<T>,
|
||
query: string,
|
||
opts?: SearchOptions<T>,
|
||
): 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) {
|
||
// 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 for multiple tokens: free terms, quoted phrases, and field:values
|
||
{
|
||
// Split query into tokens, preserving quoted substrings
|
||
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
||
// Only apply when more than one token and no boolean operators or grouping
|
||
if (
|
||
rawTokens.length > 1 &&
|
||
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
|
||
!/\[|\]/.test(q)
|
||
) {
|
||
const andConds: any[] = [];
|
||
for (let token of rawTokens) {
|
||
// field:value token
|
||
const fv = token.match(/^(\w+):(.+)$/);
|
||
if (fv) {
|
||
const field = fv[1];
|
||
let value = fv[2];
|
||
if (!searchableFields.includes(field)) {
|
||
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||
}
|
||
// Strip surrounding quotes if present
|
||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
// Wildcard search?
|
||
if (value.includes('*') || value.includes('?')) {
|
||
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
|
||
} else {
|
||
andConds.push({ [field]: value });
|
||
}
|
||
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||
// Quoted free phrase across all fields
|
||
const phrase = token.slice(1, -1);
|
||
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||
const pattern = parts.join('\\s+');
|
||
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
|
||
} else {
|
||
// Free term across all fields
|
||
const esc = escapeForRegex(token);
|
||
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
|
||
}
|
||
}
|
||
return await (this as any).execQuery({ $and: andConds }, 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 (optionally within a transaction)
|
||
*/
|
||
public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||
// allow hook before saving
|
||
if (typeof (this as any).beforeSave === 'function') {
|
||
await (this as any).beforeSave();
|
||
}
|
||
// tslint:disable-next-line: no-this-assignment
|
||
const self: any = this;
|
||
let dbResult: any;
|
||
// update timestamp
|
||
this._updatedAt = new Date().toISOString();
|
||
// perform insert or update
|
||
switch (this.creationStatus) {
|
||
case 'db':
|
||
dbResult = await this.collection.update(self, { session: opts?.session });
|
||
break;
|
||
case 'new':
|
||
dbResult = await this.collection.insert(self, { session: opts?.session });
|
||
this.creationStatus = 'db';
|
||
break;
|
||
default:
|
||
console.error('neither new nor in db?');
|
||
}
|
||
// allow hook after saving
|
||
if (typeof (this as any).afterSave === 'function') {
|
||
await (this as any).afterSave();
|
||
}
|
||
return dbResult;
|
||
}
|
||
|
||
/**
|
||
* deletes a document from the database (optionally within a transaction)
|
||
*/
|
||
public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
|
||
// allow hook before deleting
|
||
if (typeof (this as any).beforeDelete === 'function') {
|
||
await (this as any).beforeDelete();
|
||
}
|
||
// perform deletion
|
||
const result = await this.collection.delete(this, { session: opts?.session });
|
||
// allow hook after delete
|
||
if (typeof (this as any).afterDelete === 'function') {
|
||
await (this as any).afterDelete();
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* also store any referenced objects to DB
|
||
* better for data consistency
|
||
*/
|
||
public saveDeep(savedMapArg: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>> = null) {
|
||
if (!savedMapArg) {
|
||
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
|
||
}
|
||
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)) {
|
||
const rawValue = mongoDbNativeDoc[key];
|
||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||
const opts = optionsMap[key];
|
||
this[key] = opts && typeof opts.deserialize === 'function'
|
||
? opts.deserialize(rawValue)
|
||
: rawValue;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* creates a saveable object so the instance can be persisted as json in the database
|
||
*/
|
||
public async createSavableObject(): Promise<TImplements> {
|
||
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
||
const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
|
||
// apply custom serialization if configured
|
||
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
||
for (const propertyNameString of saveableProperties) {
|
||
const rawValue = (this as any)[propertyNameString];
|
||
const opts = optionsMap[propertyNameString];
|
||
(saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
|
||
? opts.serialize(rawValue)
|
||
: rawValue;
|
||
}
|
||
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;
|
||
}
|
||
} |