Compare commits

..

6 Commits

Author SHA1 Message Date
2b8b0e5bdd 5.14.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Successful in 3m3s
Default (tags) / release (push) Failing after 51s
Default (tags) / metadata (push) Successful in 58s
2025-04-23 17:28:49 +00:00
3ae2a7fcf5 fix(db operations): Update transaction API to consistently pass optional session parameters across database operations 2025-04-23 17:28:49 +00:00
0806d3749b 5.14.0
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Successful in 3m7s
Default (tags) / release (push) Failing after 50s
Default (tags) / metadata (push) Successful in 57s
2025-04-23 09:03:15 +00:00
f5d5e20a97 feat(doc): Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments. 2025-04-23 09:03:15 +00:00
db2767010d 5.13.1
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Successful in 3m2s
Default (tags) / release (push) Failing after 50s
Default (tags) / metadata (push) Successful in 59s
2025-04-22 20:42:11 +00:00
e2dc094afd fix(search): Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens. 2025-04-22 20:42:11 +00:00
7 changed files with 178 additions and 89 deletions

View File

@ -1,5 +1,28 @@
# Changelog
## 2025-04-23 - 5.14.1 - fix(db operations)
Update transaction API to consistently pass optional session parameters across database operations
- Revised transaction support in readme to use startSession without await and showcased session usage in getInstance and save calls
- Updated methods in classes.collection.ts to accept an optional session parameter for findOne, getCursor, findAll, insert, update, delete, and getCount
- Enhanced SmartDataDbDoc save and delete methods to propagate session parameters
- Improved overall consistency of transactional APIs across the library
## 2025-04-23 - 5.14.0 - feat(doc)
Implement support for beforeSave, afterSave, beforeDelete, and afterDelete lifecycle hooks in document save and delete operations to allow custom logic execution during these critical moments.
- Calls beforeSave hook if defined before performing insert or update.
- Calls afterSave hook after a document is saved.
- Calls beforeDelete hook before deletion and afterDelete hook afterward.
- Ensures _updatedAt timestamp is refreshed during save operations.
## 2025-04-22 - 5.13.1 - fix(search)
Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens.
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
- Support both free term and field:value tokens with wildcards inside quotes
- Ensure errors are thrown for non-searchable fields in field-specific queries
## 2025-04-22 - 5.13.0 - feat(search)
Improve search query handling and update documentation

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartdata",
"version": "5.13.0",
"version": "5.14.1",
"private": false,
"description": "An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.",
"main": "dist_ts/index.js",

View File

@ -409,19 +409,23 @@ class Product extends SmartDataDbDoc<Product, Product> {
### Transaction Support
Use MongoDB transactions for atomic operations:
Use MongoDB transactions for atomic operations. SmartData now exposes `startSession()` and accepts an optional session in all fetch and write APIs:
```typescript
const session = await db.startSession();
// start a client session (no await)
const session = db.startSession();
try {
// wrap operations in a transaction
await session.withTransaction(async () => {
const user = await User.getInstance({ id: 'user-id' }, { session });
// pass session as second arg to getInstance
const user = await User.getInstance({ id: 'user-id' }, session);
user.balance -= 100;
// pass session in save opts
await user.save({ session });
const recipient = await User.getInstance({ id: 'recipient-id' }, { session });
const recipient = await User.getInstance({ id: 'recipient-id' }, session);
recipient.balance += 100;
await user.save({ session });
await recipient.save({ session });
});
} finally {
await session.endSession();
@ -518,6 +522,11 @@ class Order extends SmartDataDbDoc<Order, Order> {
throw new Error('Order cannot be deleted');
}
}
// Called after deleting the document
async afterDelete() {
// Cleanup or audit actions
await auditLogDeletion(this.id);
}
}
```

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartdata',
version: '5.13.0',
version: '5.14.1',
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
}

View File

@ -222,29 +222,34 @@ export class SmartdataCollection<T> {
/**
* finds an object in the DbCollection
*/
public async findOne(filterObject: any): Promise<any> {
public async findOne(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObject);
const result = await cursor.next();
cursor.close();
return result;
// Use MongoDB driver's findOne with optional session
return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
}
public async getCursor(
filterObjectArg: any,
dbDocArg: typeof SmartDataDbDoc,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<SmartdataDbCursor<any>> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObjectArg);
const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
return new SmartdataDbCursor(cursor, dbDocArg);
}
/**
* finds an object in the DbCollection
*/
public async findAll(filterObject: any): Promise<any[]> {
public async findAll(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any[]> {
await this.init();
const cursor = this.mongoDbCollection.find(filterObject);
const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
const result = await cursor.toArray();
cursor.close();
return result;
@ -276,7 +281,10 @@ export class SmartdataCollection<T> {
/**
* create an object in the database
*/
public async insert(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async insert(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.checkDoc(dbDocArg);
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
@ -287,14 +295,17 @@ export class SmartdataCollection<T> {
}
const saveableObject = await dbDocArg.createSavableObject();
const result = await this.mongoDbCollection.insertOne(saveableObject);
const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
return result;
}
/**
* inserts object into the DbCollection
*/
public async update(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async update(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.checkDoc(dbDocArg);
const identifiableObject = await dbDocArg.createIdentifiableObject();
@ -309,21 +320,27 @@ export class SmartdataCollection<T> {
const result = await this.mongoDbCollection.updateOne(
identifiableObject,
{ $set: updateableObject },
{ upsert: true },
{ upsert: true, session: opts?.session },
);
return result;
}
public async delete(dbDocArg: T & SmartDataDbDoc<T, unknown>): Promise<any> {
public async delete(
dbDocArg: T & SmartDataDbDoc<T, unknown>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<any> {
await this.init();
await this.checkDoc(dbDocArg);
const identifiableObject = await dbDocArg.createIdentifiableObject();
await this.mongoDbCollection.deleteOne(identifiableObject);
await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
}
public async getCount(filterObject: any) {
public async getCount(
filterObject: any,
opts?: { session?: plugins.mongodb.ClientSession }
) {
await this.init();
return this.mongoDbCollection.countDocuments(filterObject);
return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
}
/**

View File

@ -63,6 +63,12 @@ export class SmartdataDb {
this.status = 'disconnected';
logger.log('info', `disconnected from database ${this.smartdataOptions.mongoDbName}`);
}
/**
* Start a MongoDB client session for transactions
*/
public startSession(): plugins.mongodb.ClientSession {
return this.mongoDbClient.startSession();
}
// handle table to class distribution

View File

@ -11,8 +11,18 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
* - validate: post-fetch validator, return true to keep a doc
*/
export interface SearchOptions<T> {
/**
* Additional MongoDB filter to ANDmerge into the query
*/
filter?: Record<string, any>;
/**
* Postfetch 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';
@ -193,8 +203,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
public static async getInstances<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<T[]> {
const foundDocs = await (this as any).collection.findAll(convertFilterForMongoDb(filterArg));
// 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);
@ -212,8 +227,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
public static async getInstance<T>(
this: plugins.tsclass.typeFest.Class<T>,
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
opts?: { session?: plugins.mongodb.ClientSession }
): Promise<T> {
const foundDoc = await (this as any).collection.findOne(convertFilterForMongoDb(filterArg));
// 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;
@ -236,14 +256,19 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
* 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;
}
@ -339,7 +364,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
if (opts?.filter) {
mongoFilter = { $and: [mongoFilter, opts.filter] };
}
let docs: T[] = await (this as any).getInstances(mongoFilter);
// 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) {
@ -419,62 +446,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
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(':'));
// 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 (
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("'")
rawTokens.length > 1 &&
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
!/\[|\]/.test(q)
) {
const andConds = parts.map((term) => {
const m = term.match(/^(\w+):(.+)$/);
if (m) {
const field = m[1];
const value = m[2];
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('?')) {
// wildcard field search
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
return { [field]: { $regex: pattern, $options: 'i' } };
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' } })) });
}
// 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|\(|\)/;
@ -556,35 +573,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
constructor() {}
/**
* saves this instance but not any connected items
* may lead to data inconsistencies, but is faster
* saves this instance (optionally within a transaction)
*/
public async save() {
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);
dbResult = await this.collection.update(self, { session: opts?.session });
break;
case 'new':
dbResult = await this.collection.insert(self);
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
* deletes a document from the database (optionally within a transaction)
*/
public async delete() {
await this.collection.delete(this);
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;
}
/**