Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
1d977986f1 | |||
e325b42906 | |||
1a359d355a | |||
b5a9449d5e | |||
558f83a3d9 | |||
76ae454221 | |||
90cfc4644d | |||
0be279e5f5 | |||
9755522bba | |||
de8736e99e |
32
changelog.md
32
changelog.md
@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-04-18 - 5.9.1 - fix(search)
|
||||
Refactor search tests to use unified search API and update text index type casting
|
||||
|
||||
- Replaced all calls from searchWithLucene with search in test/search tests
|
||||
- Updated text index specification in the collection class to use proper type casting
|
||||
|
||||
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
||||
Improve text index creation and search fallback mechanisms in collections and document search methods
|
||||
|
||||
- Auto-create a compound text index on all searchable fields in SmartdataCollection with a one-time flag to prevent duplicate index creation.
|
||||
- Refine the search method in SmartDataDbDoc to support exact field matches and safe regex fallback for non-Lucene queries.
|
||||
|
||||
## 2025-04-17 - 5.8.4 - fix(core)
|
||||
Update commit metadata with no functional code changes
|
||||
|
||||
- Commit info and documentation refreshed
|
||||
- No code or test changes detected in the diff
|
||||
|
||||
## 2025-04-17 - 5.8.3 - fix(readme)
|
||||
Improve readme documentation on data models and connection management
|
||||
|
||||
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
||||
- Document that ObjectId and Buffer fields are stored as BSON types natively without extra decorators
|
||||
- Update connection management section to use 'db.close()' instead of 'db.disconnect()'
|
||||
- Revise license section to reference the MIT License without including additional legal details
|
||||
|
||||
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
||||
Ensure collection initialization before creating a cursor in getCursorExtended
|
||||
|
||||
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
|
||||
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
||||
|
||||
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
||||
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartdata",
|
||||
"version": "5.8.1",
|
||||
"version": "5.9.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",
|
||||
|
14
readme.md
14
readme.md
@ -66,7 +66,7 @@ await db.init();
|
||||
|
||||
### Defining Data Models
|
||||
|
||||
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, and `@svDb` to define your data models.
|
||||
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, `@svDb`, `@index`, and `@searchable` to define your data models. Fields of type `ObjectId` or `Buffer` decorated with `@svDb()` will be stored as BSON ObjectId and Binary, respectively; no separate `@oid()` or `@bin()` decorators are required.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
@ -74,8 +74,6 @@ import {
|
||||
Collection,
|
||||
unI,
|
||||
svDb,
|
||||
oid,
|
||||
bin,
|
||||
index,
|
||||
searchable,
|
||||
} from '@push.rocks/smartdata';
|
||||
@ -96,12 +94,10 @@ class User extends SmartDataDbDoc<User, User> {
|
||||
public email: string; // Mark 'email' to be saved in DB
|
||||
|
||||
@svDb()
|
||||
@oid() // Automatically handle as ObjectId type
|
||||
public organizationId: ObjectId; // Will be automatically converted to/from ObjectId
|
||||
public organizationId: ObjectId; // Stored as BSON ObjectId
|
||||
|
||||
@svDb()
|
||||
@bin() // Automatically handle as Binary data
|
||||
public profilePicture: Buffer; // Will be automatically converted to/from Binary
|
||||
public profilePicture: Buffer; // Stored as BSON Binary
|
||||
|
||||
@svDb({
|
||||
serialize: (data) => JSON.stringify(data), // Custom serialization
|
||||
@ -541,7 +537,7 @@ class Order extends SmartDataDbDoc<Order, Order> {
|
||||
### Connection Management
|
||||
|
||||
- Always call `db.init()` before using any database features
|
||||
- Use `db.disconnect()` when shutting down your application
|
||||
- Use `db.close()` when shutting down your application
|
||||
- Set appropriate connection pool sizes based on your application's needs
|
||||
|
||||
### Document Design
|
||||
@ -590,7 +586,7 @@ Please make sure to update tests as appropriate and follow our coding standards.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository is licensed under the MIT License. For details, see [MIT License](https://opensource.org/licenses/MIT).
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
@ -104,21 +104,21 @@ tap.test('should search products by basic search method', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should search products with searchWithLucene method', async () => {
|
||||
tap.test('should search products with search method', async () => {
|
||||
// Using the robust searchWithLucene method
|
||||
const wirelessResults = await Product.searchWithLucene('wireless');
|
||||
console.log(
|
||||
`Found ${wirelessResults.length} products matching 'wireless' using searchWithLucene`,
|
||||
);
|
||||
const wirelessResults = await Product.search('wireless');
|
||||
console.log(
|
||||
`Found ${wirelessResults.length} products matching 'wireless' using search`,
|
||||
);
|
||||
|
||||
expect(wirelessResults.length).toEqual(1);
|
||||
expect(wirelessResults[0].name).toEqual('AirPods');
|
||||
});
|
||||
|
||||
tap.test('should search products by category with searchWithLucene', async () => {
|
||||
tap.test('should search products by category with search', async () => {
|
||||
// Using field-specific search with searchWithLucene
|
||||
const kitchenResults = await Product.searchWithLucene('category:Kitchen');
|
||||
console.log(`Found ${kitchenResults.length} products in Kitchen category using searchWithLucene`);
|
||||
const kitchenResults = await Product.search('category:Kitchen');
|
||||
console.log(`Found ${kitchenResults.length} products in Kitchen category using search`);
|
||||
|
||||
expect(kitchenResults.length).toEqual(2);
|
||||
expect(kitchenResults[0].category).toEqual('Kitchen');
|
||||
@ -127,7 +127,7 @@ tap.test('should search products by category with searchWithLucene', async () =>
|
||||
|
||||
tap.test('should search products with partial word matches', async () => {
|
||||
// Testing partial word matches
|
||||
const proResults = await Product.searchWithLucene('Pro');
|
||||
const proResults = await Product.search('Pro');
|
||||
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
||||
|
||||
// Should match both "MacBook Pro" and "professionals" in description
|
||||
@ -136,7 +136,7 @@ tap.test('should search products with partial word matches', async () => {
|
||||
|
||||
tap.test('should search across multiple searchable fields', async () => {
|
||||
// Test searching across all searchable fields
|
||||
const bookResults = await Product.searchWithLucene('book');
|
||||
const bookResults = await Product.search('book');
|
||||
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
||||
|
||||
// Should match "MacBook" in name and "Books" in category
|
||||
@ -145,8 +145,8 @@ tap.test('should search across multiple searchable fields', async () => {
|
||||
|
||||
tap.test('should handle case insensitive searches', async () => {
|
||||
// Test case insensitivity
|
||||
const electronicsResults = await Product.searchWithLucene('electronics');
|
||||
const ElectronicsResults = await Product.searchWithLucene('Electronics');
|
||||
const electronicsResults = await Product.search('electronics');
|
||||
const ElectronicsResults = await Product.search('Electronics');
|
||||
|
||||
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
||||
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
||||
@ -166,14 +166,14 @@ tap.test('should demonstrate search fallback mechanisms', async () => {
|
||||
|
||||
// Use a simpler term that should be found in descriptions
|
||||
// Avoid using "OR" operator which requires a text index
|
||||
const results = await Product.searchWithLucene('high');
|
||||
const results = await Product.search('high');
|
||||
console.log(`Found ${results.length} products matching 'high'`);
|
||||
|
||||
// "High-speed blender" contains "high"
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Try another fallback example that won't need $text
|
||||
const powerfulResults = await Product.searchWithLucene('powerful');
|
||||
const powerfulResults = await Product.search('powerful');
|
||||
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
||||
|
||||
// "Powerful laptop for professionals" contains "powerful"
|
||||
@ -192,6 +192,35 @@ tap.test('should explain the advantages of the integrated approach', async () =>
|
||||
expect(true).toEqual(true);
|
||||
});
|
||||
|
||||
// Additional robustness tests
|
||||
tap.test('should search exact name using field:value', async () => {
|
||||
const nameResults = await Product.search('name:AirPods');
|
||||
expect(nameResults.length).toEqual(1);
|
||||
expect(nameResults[0].name).toEqual('AirPods');
|
||||
});
|
||||
|
||||
tap.test('should throw when searching non-searchable field', async () => {
|
||||
let error: Error;
|
||||
try {
|
||||
await Product.search('price:129');
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.message).toMatch(/not searchable/);
|
||||
});
|
||||
|
||||
tap.test('empty query should return all products', async () => {
|
||||
const allResults = await Product.search('');
|
||||
expect(allResults.length).toEqual(8);
|
||||
});
|
||||
|
||||
tap.test('should search multi-word term across fields', async () => {
|
||||
const termResults = await Product.search('iPhone 12');
|
||||
expect(termResults.length).toEqual(1);
|
||||
expect(termResults[0].name).toEqual('iPhone 12');
|
||||
});
|
||||
|
||||
tap.test('close database connection', async () => {
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
await testDb.close();
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartdata',
|
||||
version: '5.8.1',
|
||||
version: '5.9.1',
|
||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||
import { SmartDataDbDoc, type IIndexOptions, getSearchableFields } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||
|
||||
@ -128,6 +128,8 @@ export class SmartdataCollection<T> {
|
||||
public smartdataDb: SmartdataDb;
|
||||
public uniqueIndexes: string[] = [];
|
||||
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
||||
// flag to ensure text index is created only once
|
||||
private textIndexCreated: boolean = false;
|
||||
|
||||
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
|
||||
// tell the collection where it belongs
|
||||
@ -153,6 +155,16 @@ export class SmartdataCollection<T> {
|
||||
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
||||
}
|
||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||
// Auto-create a compound text index on all searchable fields
|
||||
const searchableFields = getSearchableFields(this.collectionName);
|
||||
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
||||
// Build a compound text index spec
|
||||
const indexSpec: Record<string, 'text'> = {};
|
||||
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
|
||||
// Cast to any to satisfy TypeScript IndexSpecification typing
|
||||
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
|
||||
this.textIndexCreated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,6 +61,10 @@ export function getSearchableFields(className: string): string[] {
|
||||
}
|
||||
return Array.from(searchableFieldsMap.get(className));
|
||||
}
|
||||
// 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
|
||||
@ -255,6 +259,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
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),
|
||||
);
|
||||
@ -325,57 +330,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documents using Lucene query syntax
|
||||
* @param luceneQuery Lucene query string
|
||||
* 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>,
|
||||
luceneQuery: string,
|
||||
query: string,
|
||||
): Promise<T[]> {
|
||||
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<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
luceneQuery: string,
|
||||
): Promise<T[]> {
|
||||
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);
|
||||
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}`);
|
||||
}
|
||||
// field:value exact match (case-sensitive for non-regex fields)
|
||||
const fv = query.match(/^(\w+):(.+)$/);
|
||||
if (fv) {
|
||||
const field = fv[1];
|
||||
const value = fv[2];
|
||||
if (!searchableFields.includes(field)) {
|
||||
throw new Error(`Field '${field}' is not searchable for class ${className}`);
|
||||
}
|
||||
return await (this as any).getInstances({ [field]: value });
|
||||
}
|
||||
// safe regex across all searchable fields (case-insensitive)
|
||||
const escaped = escapeForRegex(query);
|
||||
const orConditions = searchableFields.map((field) => ({
|
||||
[field]: { $regex: escaped, $options: 'i' },
|
||||
}));
|
||||
return await (this as any).getInstances({ $or: orConditions });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Search by text across all searchable fields (fallback method)
|
||||
* @param searchText The text to search for in all searchable fields
|
||||
|
Reference in New Issue
Block a user