Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
4fac974fc9 | |||
cad2decf59 | |||
0f61bdc455 | |||
408b2cce4a | |||
7a08700451 | |||
ebaf3e685c | |||
c8d51a30d8 | |||
d957e911de | |||
fee936c75f | |||
ac867401de | |||
c066464526 | |||
0105aa2a18 | |||
4c2477c269 | |||
ea0d2bb251 | |||
b3e30a8711 | |||
64621dd38f | |||
117c257a27 | |||
b30522c505 |
@ -119,6 +119,6 @@ jobs:
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @gitzone/tsdoc
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
|
195
changelog.md
Normal file
195
changelog.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-04-06 - 5.5.0 - feat(search)
|
||||
Enhance search functionality with robust Lucene query transformation and reliable fallback mechanisms
|
||||
|
||||
- Improve Lucene adapter to properly structure $or queries for term, phrase, wildcard, and fuzzy search
|
||||
- Implement and document a robust searchWithLucene method with fallback to in-memory filtering
|
||||
- Update readme and tests with extensive examples for @searchable fields and Lucene-based queries
|
||||
|
||||
## 2025-04-06 - 5.4.0 - feat(core)
|
||||
Refactor file structure and update dependency versions
|
||||
|
||||
- Renamed files and modules from 'smartdata.classes.*' to 'classes.*' and adjusted corresponding import paths.
|
||||
- Updated dependency versions: '@push.rocks/smartmongo' to ^2.0.11, '@tsclass/tsclass' to ^8.2.0, and 'mongodb' to ^6.15.0.
|
||||
- Renamed dev dependency packages from '@gitzone/...' to '@git.zone/...' and updated '@push.rocks/tapbundle' and '@types/node'.
|
||||
- Fixed YAML workflow command: replaced 'pnpm install -g @gitzone/tsdoc' with 'pnpm install -g @git.zone/tsdoc'.
|
||||
- Added package manager configuration and pnpm-workspace.yaml for built dependencies.
|
||||
|
||||
## 2025-03-10 - 5.3.0 - feat(docs)
|
||||
Enhance documentation with updated installation instructions and comprehensive usage examples covering advanced features such as deep queries, automatic indexing, and distributed coordination.
|
||||
|
||||
- Added pnpm installation command
|
||||
- Updated User model example to include ObjectId, Binary, and custom serialization
|
||||
- Expanded CRUD operations examples with cursor methods and deep query support
|
||||
- Enhanced sections on EasyStore, real-time data watching with RxJS integration, and managed collections
|
||||
- Included detailed examples for transactions, deep object queries, and document lifecycle hooks
|
||||
|
||||
## 2025-02-03 - 5.2.12 - fix(documentation)
|
||||
Remove license badge from README
|
||||
|
||||
- Removed the license badge from the README file, ensuring compliance with branding guidelines.
|
||||
|
||||
## 2025-02-03 - 5.2.11 - fix(documentation)
|
||||
Updated project documentation for accuracy and added advanced feature details
|
||||
|
||||
- Added details for EasyStore, Distributed Coordination, and Real-time Data Watching features.
|
||||
- Updated database connection setup instructions to include user authentication.
|
||||
- Re-organized advanced usage section to showcase additional features separately.
|
||||
|
||||
## 2024-09-05 - 5.2.10 - fix(smartdata.classes.doc)
|
||||
Fix issue with array handling in convertFilterForMongoDb function
|
||||
|
||||
- Corrected the logic to properly handle array filters in the convertFilterForMongoDb function to avoid incorrect assignments.
|
||||
|
||||
## 2024-09-05 - 5.2.9 - fix(smartdata.classes.doc)
|
||||
Fixed issue with convertFilterForMongoDb to handle array operators.
|
||||
|
||||
- Updated the convertFilterForMongoDb function in smartdata.classes.doc.ts to properly handle array operators like $in and $all.
|
||||
|
||||
## 2024-09-05 - 5.2.8 - fix(smartdata.classes.doc)
|
||||
Fix key handling in convertFilterForMongoDb function
|
||||
|
||||
- Fixed an issue in convertFilterForMongoDb that allowed keys with dots which could cause errors.
|
||||
|
||||
## 2024-09-05 - 5.2.7 - fix(core)
|
||||
Fixed issue with handling filter keys containing dots in smartdata.classes.doc.ts
|
||||
|
||||
- Fixed an error in the convertFilterForMongoDb function which previously threw an error when keys contained dots.
|
||||
|
||||
## 2024-06-18 - 5.2.6 - Chore
|
||||
Maintenance Release
|
||||
|
||||
- Release version 5.2.6
|
||||
|
||||
## 2024-05-31 - 5.2.2 - Bug Fixes
|
||||
Fixes and Maintenance
|
||||
|
||||
- Fixed issue where `_createdAt` and `_updatedAt` registered saveableProperties for all document types
|
||||
|
||||
## 2024-04-15 - 5.1.2 - New Feature
|
||||
Enhancements and Bug Fixes
|
||||
|
||||
- Added static `.getCount({})` method to `SmartDataDbDoc`
|
||||
- Changed fields `_createdAt` and `_updatedAt` to ISO format
|
||||
|
||||
## 2024-04-14 - 5.0.43 - New Feature
|
||||
New Feature Addition
|
||||
|
||||
- Added default `_createdAt` and `_updatedAt` fields, fixes #1
|
||||
|
||||
## 2024-03-30 - 5.0.41 - Bug Fixes
|
||||
Improvements and Fixes
|
||||
|
||||
- Improved `tsconfig.json` for ES Module use
|
||||
|
||||
## 2023-07-10 - 5.0.20 - Chore
|
||||
Organizational Changes
|
||||
|
||||
- Switched to new org scheme
|
||||
|
||||
## 2023-07-21 - 5.0.21 to 5.0.26 - Fixes
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2023-07-21 - 5.0.20 - Chore
|
||||
Organizational Changes
|
||||
|
||||
- Switch to the new org scheme
|
||||
|
||||
## 2023-06-25 - 5.0.14 to 5.0.19 - Fixes
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2022-05-17 - 5.0.0 - Major Update
|
||||
Breaking Changes
|
||||
|
||||
- Switched to ESM
|
||||
|
||||
## 2022-05-18 - 5.0.2 - Bug Fixes
|
||||
Bug Fixes
|
||||
|
||||
- The `watcher.changeSubject` now emits the correct type into observer functions
|
||||
|
||||
## 2022-05-17 - 5.0.1 - Chore
|
||||
Testing Improvements
|
||||
|
||||
- Tests now use `@pushrocks/smartmongo` backed by `wiredTiger`
|
||||
|
||||
## 2022-05-17 to 2022-11-08 - 5.0.8 to 5.0.10
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-11-12 - 4.0.17 to 4.0.20
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-09-17 - 4.0.10 to 4.0.16
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-06-09 - 4.0.1 to 4.0.9
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2021-06-06 - 4.0.0 - Major Update
|
||||
Major Release
|
||||
|
||||
- Maintenance and core updates
|
||||
|
||||
## 2021-05-17 - 3.1.56 - Chore
|
||||
Maintenance Release
|
||||
|
||||
- Release version 3.1.56
|
||||
|
||||
## 2020-09-09 - 3.1.44 to 3.1.52
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2020-06-12 - 3.1.26 to 3.1.28
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2020-02-18 - 3.1.23 to 3.1.25
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2019-09-11 - 3.1.20 to 3.1.22
|
||||
Multiple Fix Releases
|
||||
|
||||
- Various core updates and bug fixes
|
||||
|
||||
## 2018-07-10 - 3.0.5 - New Feature
|
||||
Added Feature
|
||||
|
||||
- Added custom unique indexes to `SmartdataDoc`
|
||||
|
||||
## 2018-07-08 - 3.0.1 - Chore
|
||||
Dependencies Update
|
||||
|
||||
- Updated mongodb dependencies
|
||||
|
||||
## 2018-07-08 - 3.0.0 - Major Update
|
||||
Refactor and Cleanup
|
||||
|
||||
- Cleaned project structure
|
||||
|
||||
## 2018-01-16 - 2.0.7 - Breaking Change
|
||||
Big Changes
|
||||
|
||||
- Switched to `@pushrocks` scope and moved from `rethinkdb` to `mongodb`
|
||||
|
||||
## 2018-01-12 - 2.0.0 - Major Release
|
||||
Core Updates
|
||||
|
||||
- Updated CI configurations
|
||||
|
21
package.json
21
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartdata",
|
||||
"version": "5.2.6",
|
||||
"version": "5.5.0",
|
||||
"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",
|
||||
@ -25,23 +25,23 @@
|
||||
"@push.rocks/lik": "^6.0.14",
|
||||
"@push.rocks/smartdelay": "^3.0.1",
|
||||
"@push.rocks/smartlog": "^3.0.2",
|
||||
"@push.rocks/smartmongo": "^2.0.10",
|
||||
"@push.rocks/smartmongo": "^2.0.11",
|
||||
"@push.rocks/smartpromise": "^4.0.2",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@push.rocks/smarttime": "^4.0.6",
|
||||
"@push.rocks/smartunique": "^3.0.8",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@tsclass/tsclass": "^4.0.52",
|
||||
"mongodb": "^6.5.0"
|
||||
"@tsclass/tsclass": "^8.2.0",
|
||||
"mongodb": "^6.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.66",
|
||||
"@gitzone/tsrun": "^1.2.44",
|
||||
"@gitzone/tstest": "^1.0.77",
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.77",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/tapbundle": "^5.0.22",
|
||||
"@types/node": "^20.11.30"
|
||||
"@push.rocks/tapbundle": "^5.6.2",
|
||||
"@types/node": "^22.14.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@ -67,5 +67,6 @@
|
||||
"collections",
|
||||
"custom data types",
|
||||
"ODM"
|
||||
]
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
13559
pnpm-lock.yaml
generated
13559
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
481
readme.md
481
readme.md
@ -1,5 +1,30 @@
|
||||
# @push.rocks/smartdata
|
||||
do more with data
|
||||
|
||||
[](https://www.npmjs.com/package/@push.rocks/smartdata)
|
||||
|
||||
A powerful TypeScript-first MongoDB wrapper that provides advanced features for distributed systems, real-time data synchronization, and easy data management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Type-Safe MongoDB Integration**: Full TypeScript support with decorators for schema definition
|
||||
- **Document Management**: Type-safe CRUD operations with automatic timestamp tracking
|
||||
- **EasyStore**: Simple key-value storage with automatic persistence and sharing between instances
|
||||
- **Distributed Coordination**: Built-in support for leader election and distributed task management
|
||||
- **Real-time Data Sync**: Watchers for real-time data changes with RxJS integration
|
||||
- **Connection Management**: Automatic connection handling with connection pooling
|
||||
- **Collection Management**: Type-safe collection operations with automatic indexing
|
||||
- **Deep Query Type Safety**: Fully type-safe queries for nested object properties with `DeepQuery<T>`
|
||||
- **MongoDB Compatibility**: Support for all MongoDB query operators and advanced features
|
||||
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
|
||||
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
|
||||
- **Serialization Hooks**: Custom serialization and deserialization of document properties
|
||||
- **Powerful Search Capabilities**: Lucene-like query syntax with field-specific search, advanced operators, and fallback mechanisms
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 16.x
|
||||
- MongoDB >= 4.4
|
||||
- TypeScript >= 4.x (for development)
|
||||
|
||||
## Install
|
||||
To install `@push.rocks/smartdata`, use npm:
|
||||
@ -8,26 +33,31 @@ To install `@push.rocks/smartdata`, use npm:
|
||||
npm install @push.rocks/smartdata --save
|
||||
```
|
||||
|
||||
This will add `@push.rocks/smartdata` to your project's dependencies.
|
||||
Or with pnpm:
|
||||
|
||||
```bash
|
||||
pnpm add @push.rocks/smartdata
|
||||
```
|
||||
|
||||
## Usage
|
||||
`@push.rocks/smartdata` enables efficient data handling and operation management with a focus on using MongoDB. It leverages TypeScript for strong typing and ESM syntax for modern JavaScript usage. Below are various scenarios demonstrating how to utilize this package effectively in a project.
|
||||
`@push.rocks/smartdata` enables efficient data handling and operation management with a focus on using MongoDB. It leverages TypeScript for strong typing and ESM syntax for modern JavaScript usage.
|
||||
|
||||
### Setting Up and Connecting to the Database
|
||||
Before interacting with the database, you need to set up and establish a connection. This is done by creating an instance of `SmartdataDb` and calling its `init` method with your MongoDB connection details.
|
||||
Before interacting with the database, you need to set up and establish a connection. The `SmartdataDb` class handles connection pooling and automatic reconnection.
|
||||
|
||||
```typescript
|
||||
import { SmartdataDb } from '@push.rocks/smartdata';
|
||||
|
||||
// Create a new instance of SmartdataDb with MongoDB connection details
|
||||
const db = new SmartdataDb({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
mongoDbUrl: 'mongodb://<USERNAME>:<PASSWORD>@localhost:27017/<DBNAME>',
|
||||
mongoDbName: 'your-database-name',
|
||||
mongoDbUser: 'your-username',
|
||||
mongoDbPass: 'your-password',
|
||||
});
|
||||
|
||||
// Initialize and connect to the database
|
||||
// This sets up a connection pool with max 100 connections
|
||||
await db.init();
|
||||
```
|
||||
|
||||
@ -35,7 +65,8 @@ await db.init();
|
||||
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.
|
||||
|
||||
```typescript
|
||||
import { SmartDataDbDoc, Collection, unI, svDb } from '@push.rocks/smartdata';
|
||||
import { SmartDataDbDoc, Collection, unI, svDb, oid, bin, index, searchable } from '@push.rocks/smartdata';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
@Collection(() => db) // Associate this model with the database instance
|
||||
class User extends SmartDataDbDoc<User, User> {
|
||||
@ -43,11 +74,28 @@ class User extends SmartDataDbDoc<User, User> {
|
||||
public id: string = 'unique-user-id'; // Mark 'id' as a unique index
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark 'username' as searchable
|
||||
public username: string; // Mark 'username' to be saved in DB
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark 'email' as searchable
|
||||
@index() // Create a regular index for this field
|
||||
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
|
||||
|
||||
@svDb()
|
||||
@bin() // Automatically handle as Binary data
|
||||
public profilePicture: Buffer; // Will be automatically converted to/from Binary
|
||||
|
||||
@svDb({
|
||||
serialize: (data) => JSON.stringify(data), // Custom serialization
|
||||
deserialize: (data) => JSON.parse(data) // Custom deserialization
|
||||
})
|
||||
public preferences: Record<string, any>;
|
||||
|
||||
constructor(username: string, email: string) {
|
||||
super();
|
||||
this.username = username;
|
||||
@ -56,7 +104,7 @@ class User extends SmartDataDbDoc<User, User> {
|
||||
}
|
||||
```
|
||||
|
||||
### Performing CRUD Operations
|
||||
### CRUD Operations
|
||||
`@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances.
|
||||
|
||||
#### Create
|
||||
@ -72,6 +120,32 @@ const user = await User.getInstance({ username: 'myUsername' });
|
||||
|
||||
// Fetch multiple users that match criteria
|
||||
const users = await User.getInstances({ email: 'myEmail@example.com' });
|
||||
|
||||
// Using a cursor for large collections
|
||||
const cursor = await User.getCursor({ active: true });
|
||||
|
||||
// Process documents one at a time (memory efficient)
|
||||
await cursor.forEach(async (user, index) => {
|
||||
// Process each user with its position
|
||||
console.log(`Processing user ${index}: ${user.username}`);
|
||||
});
|
||||
|
||||
// Chain cursor methods like in the MongoDB native driver
|
||||
const paginatedCursor = await User.getCursor({ active: true })
|
||||
.limit(10) // Limit results
|
||||
.skip(20) // Skip first 20 results
|
||||
.sort({ createdAt: -1 }); // Sort by creation date descending
|
||||
|
||||
// Convert cursor to array (when you know the result set is small)
|
||||
const userArray = await paginatedCursor.toArray();
|
||||
|
||||
// Other cursor operations
|
||||
const nextUser = await cursor.next(); // Get the next document
|
||||
const hasMoreUsers = await cursor.hasNext(); // Check if more documents exist
|
||||
const count = await cursor.count(); // Get the count of documents in the cursor
|
||||
|
||||
// Always close cursors when done with them
|
||||
await cursor.close();
|
||||
```
|
||||
|
||||
#### Update
|
||||
@ -79,6 +153,15 @@ const users = await User.getInstances({ email: 'myEmail@example.com' });
|
||||
// Assuming 'user' is an instance of User
|
||||
user.email = 'newEmail@example.com';
|
||||
await user.save(); // Update the user in the database
|
||||
|
||||
// Upsert operations (insert if not exists, update if exists)
|
||||
const upsertedUser = await User.upsert(
|
||||
{ id: 'user-123' }, // Query to find the user
|
||||
{ // Fields to update or insert
|
||||
username: 'newUsername',
|
||||
email: 'newEmail@example.com'
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
#### Delete
|
||||
@ -87,13 +170,385 @@ await user.save(); // Update the user in the database
|
||||
await user.delete(); // Delete the user from the database
|
||||
```
|
||||
|
||||
### Advanced Usage
|
||||
`@push.rocks/smartdata` also supports advanced features like watching for real-time changes in the database, handling distributed data coordination, and more. These features utilize MongoDB's capabilities to provide real-time data syncing and distributed systems coordination.
|
||||
## Advanced Features
|
||||
|
||||
### Conclusion
|
||||
With its focus on TypeScript, modern JavaScript syntax, and leveraging MongoDB's features, `@push.rocks/smartdata` offers a powerful toolkit for data handling and operations management in Node.js applications. Its design for ease of use, coupled with advanced features, makes it a versatile choice for developers looking to build efficient and scalable data-driven applications.
|
||||
### Search Functionality
|
||||
SmartData provides powerful search capabilities with a Lucene-like query syntax and robust fallback mechanisms:
|
||||
|
||||
For more details on usage and additional features, refer to the [official documentation](https://gitlab.com/push.rocks/smartdata#README) and explore the various classes and methods provided by `@push.rocks/smartdata`.
|
||||
```typescript
|
||||
// Define a model with searchable fields
|
||||
@Collection(() => db)
|
||||
class Product extends SmartDataDbDoc<Product, Product> {
|
||||
@unI()
|
||||
public id: string = 'product-id';
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark this field as searchable
|
||||
public name: string;
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark this field as searchable
|
||||
public description: string;
|
||||
|
||||
@svDb()
|
||||
@searchable() // Mark this field as searchable
|
||||
public category: string;
|
||||
|
||||
@svDb()
|
||||
public price: number;
|
||||
}
|
||||
|
||||
// Get all fields marked as searchable for a class
|
||||
const searchableFields = getSearchableFields('Product'); // ['name', 'description', 'category']
|
||||
|
||||
// Basic search across all searchable fields
|
||||
const iPhoneProducts = await Product.searchWithLucene('iPhone');
|
||||
|
||||
// Field-specific search
|
||||
const electronicsProducts = await Product.searchWithLucene('category:Electronics');
|
||||
|
||||
// Search with wildcards
|
||||
const macProducts = await Product.searchWithLucene('Mac*');
|
||||
|
||||
// Search in specific fields with partial words
|
||||
const laptopResults = await Product.searchWithLucene('description:laptop');
|
||||
|
||||
// Search is case-insensitive
|
||||
const results1 = await Product.searchWithLucene('electronics');
|
||||
const results2 = await Product.searchWithLucene('Electronics');
|
||||
// results1 and results2 will contain the same documents
|
||||
|
||||
// Using boolean operators (requires text index in MongoDB)
|
||||
const wirelessOrLaptop = await Product.searchWithLucene('wireless OR laptop');
|
||||
|
||||
// Negative searches
|
||||
const electronicsNotSamsung = await Product.searchWithLucene('Electronics NOT Samsung');
|
||||
|
||||
// Phrase searches
|
||||
const exactPhrase = await Product.searchWithLucene('"high-speed blender"');
|
||||
|
||||
// Grouping with parentheses
|
||||
const complexQuery = await Product.searchWithLucene('(wireless OR bluetooth) AND Electronics');
|
||||
```
|
||||
|
||||
The search functionality includes:
|
||||
- `@searchable()` decorator for marking fields as searchable
|
||||
- `getSearchableFields()` to retrieve all searchable fields for a class
|
||||
- `search()` method for basic search (requires MongoDB text index)
|
||||
- `searchWithLucene()` method with robust fallback mechanisms
|
||||
- Support for field-specific searches, wildcards, and boolean operators
|
||||
- Automatic fallback to regex-based search if MongoDB text search fails
|
||||
|
||||
### EasyStore
|
||||
EasyStore provides a simple key-value storage system with automatic persistence:
|
||||
|
||||
```typescript
|
||||
// Create an EasyStore instance with a specific type
|
||||
interface ConfigStore {
|
||||
apiKey: string;
|
||||
settings: {
|
||||
theme: string;
|
||||
notifications: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Create a type-safe EasyStore
|
||||
const store = await db.createEasyStore<ConfigStore>('app-config');
|
||||
|
||||
// Write and read data with full type safety
|
||||
await store.writeKey('apiKey', 'secret-api-key-123');
|
||||
await store.writeKey('settings', { theme: 'dark', notifications: true });
|
||||
|
||||
const apiKey = await store.readKey('apiKey'); // Type: string
|
||||
const settings = await store.readKey('settings'); // Type: { theme: string, notifications: boolean }
|
||||
|
||||
// Check if a key exists
|
||||
const hasKey = await store.hasKey('apiKey'); // true
|
||||
|
||||
// Delete a key
|
||||
await store.deleteKey('apiKey');
|
||||
```
|
||||
|
||||
### Distributed Coordination
|
||||
Built-in support for distributed systems with leader election:
|
||||
|
||||
```typescript
|
||||
// Create a distributed coordinator
|
||||
const coordinator = new SmartdataDistributedCoordinator(db);
|
||||
|
||||
// Start coordination
|
||||
await coordinator.start();
|
||||
|
||||
// Handle leadership changes
|
||||
coordinator.on('leadershipChange', (isLeader) => {
|
||||
if (isLeader) {
|
||||
// This instance is now the leader
|
||||
// Run leader-specific tasks
|
||||
startPeriodicJobs();
|
||||
} else {
|
||||
// This instance is no longer the leader
|
||||
stopPeriodicJobs();
|
||||
}
|
||||
});
|
||||
|
||||
// Access leadership status anytime
|
||||
if (coordinator.isLeader) {
|
||||
// Run leader-only operations
|
||||
}
|
||||
|
||||
// Execute a task only on the leader
|
||||
await coordinator.executeIfLeader(async () => {
|
||||
// This code only runs on the leader instance
|
||||
await runImportantTask();
|
||||
});
|
||||
|
||||
// Stop coordination when shutting down
|
||||
await coordinator.stop();
|
||||
```
|
||||
|
||||
### Real-time Data Watching
|
||||
Watch for changes in your collections with RxJS integration using MongoDB Change Streams:
|
||||
|
||||
```typescript
|
||||
// Create a watcher for a specific collection with a query filter
|
||||
const watcher = await User.watch({
|
||||
active: true // Only watch for changes to active users
|
||||
}, {
|
||||
fullDocument: true, // Include the full document in change notifications
|
||||
bufferTimeMs: 100 // Buffer changes for 100ms to reduce notification frequency
|
||||
});
|
||||
|
||||
// Subscribe to changes using RxJS
|
||||
watcher.changeSubject.subscribe((change) => {
|
||||
console.log('Change operation:', change.operationType); // 'insert', 'update', 'delete', etc.
|
||||
console.log('Document changed:', change.docInstance); // The full document instance
|
||||
|
||||
// Handle different types of changes
|
||||
if (change.operationType === 'insert') {
|
||||
console.log('New user created:', change.docInstance.username);
|
||||
} else if (change.operationType === 'update') {
|
||||
console.log('User updated:', change.docInstance.username);
|
||||
} else if (change.operationType === 'delete') {
|
||||
console.log('User deleted');
|
||||
}
|
||||
});
|
||||
|
||||
// Manual observation with event emitter pattern
|
||||
watcher.on('change', (change) => {
|
||||
console.log('Document changed:', change);
|
||||
});
|
||||
|
||||
// Stop watching when no longer needed
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
### Managed Collections
|
||||
For more complex data models that require additional context:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class ManagedDoc extends SmartDataDbDoc<ManagedDoc, ManagedDoc> {
|
||||
@unI()
|
||||
public id: string = 'unique-id';
|
||||
|
||||
@svDb()
|
||||
public data: string;
|
||||
|
||||
@managed()
|
||||
public manager: YourCustomManager;
|
||||
|
||||
// The manager can provide additional functionality
|
||||
async specialOperation() {
|
||||
return this.manager.doSomethingSpecial(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Indexing
|
||||
Define indexes directly in your model class:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class Product extends SmartDataDbDoc<Product, Product> {
|
||||
@unI() // Unique index
|
||||
public id: string = 'product-id';
|
||||
|
||||
@svDb()
|
||||
@index() // Regular index for faster queries
|
||||
public category: string;
|
||||
|
||||
@svDb()
|
||||
@index({ sparse: true }) // Sparse index with options
|
||||
public optionalField?: string;
|
||||
|
||||
// Compound indexes can be defined in the collection decorator
|
||||
@Collection(() => db, {
|
||||
indexMap: {
|
||||
compoundIndex: {
|
||||
fields: { category: 1, name: 1 },
|
||||
options: { background: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Support
|
||||
Use MongoDB transactions for atomic operations:
|
||||
|
||||
```typescript
|
||||
const session = await db.startSession();
|
||||
try {
|
||||
await session.withTransaction(async () => {
|
||||
const user = await User.getInstance({ id: 'user-id' }, { session });
|
||||
user.balance -= 100;
|
||||
await user.save({ session });
|
||||
|
||||
const recipient = await User.getInstance({ id: 'recipient-id' }, { session });
|
||||
recipient.balance += 100;
|
||||
await user.save({ session });
|
||||
});
|
||||
} finally {
|
||||
await session.endSession();
|
||||
}
|
||||
```
|
||||
|
||||
### Deep Object Queries
|
||||
SmartData provides fully type-safe deep property queries with the `DeepQuery` type:
|
||||
|
||||
```typescript
|
||||
// If your document has nested objects
|
||||
class UserProfile extends SmartDataDbDoc<UserProfile, UserProfile> {
|
||||
@unI()
|
||||
public id: string = 'profile-id';
|
||||
|
||||
@svDb()
|
||||
public user: {
|
||||
details: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: {
|
||||
city: string;
|
||||
country: string;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Type-safe string literals for dot notation
|
||||
const usersInUSA = await UserProfile.getInstances({
|
||||
'user.details.address.country': 'USA'
|
||||
});
|
||||
|
||||
// Fully typed deep queries with the DeepQuery type
|
||||
import { DeepQuery } from '@push.rocks/smartdata';
|
||||
|
||||
const typedQuery: DeepQuery<UserProfile> = {
|
||||
id: 'profile-id',
|
||||
'user.details.firstName': 'John',
|
||||
'user.details.address.country': 'USA'
|
||||
};
|
||||
|
||||
// TypeScript will error if paths are incorrect
|
||||
const results = await UserProfile.getInstances(typedQuery);
|
||||
|
||||
// MongoDB query operators are supported
|
||||
const operatorQuery: DeepQuery<UserProfile> = {
|
||||
'user.details.address.country': 'USA',
|
||||
'user.details.address.city': { $in: ['New York', 'Los Angeles'] }
|
||||
};
|
||||
|
||||
const filteredResults = await UserProfile.getInstances(operatorQuery);
|
||||
```
|
||||
|
||||
### Document Lifecycle Hooks
|
||||
Implement custom logic at different stages of a document's lifecycle:
|
||||
|
||||
```typescript
|
||||
@Collection(() => db)
|
||||
class Order extends SmartDataDbDoc<Order, Order> {
|
||||
@unI()
|
||||
public id: string = 'order-id';
|
||||
|
||||
@svDb()
|
||||
public total: number;
|
||||
|
||||
@svDb()
|
||||
public items: string[];
|
||||
|
||||
// Called before saving the document
|
||||
async beforeSave() {
|
||||
// Calculate total based on items
|
||||
this.total = await calculateTotal(this.items);
|
||||
|
||||
// Validate the document
|
||||
if (this.items.length === 0) {
|
||||
throw new Error('Order must have at least one item');
|
||||
}
|
||||
}
|
||||
|
||||
// Called after the document is saved
|
||||
async afterSave() {
|
||||
// Notify other systems about the saved order
|
||||
await notifyExternalSystems(this);
|
||||
}
|
||||
|
||||
// Called before deleting the document
|
||||
async beforeDelete() {
|
||||
// Check if order can be deleted
|
||||
const canDelete = await checkOrderDeletable(this.id);
|
||||
if (!canDelete) {
|
||||
throw new Error('Order cannot be deleted');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Connection Management
|
||||
- Always call `db.init()` before using any database features
|
||||
- Use `db.disconnect()` when shutting down your application
|
||||
- Set appropriate connection pool sizes based on your application's needs
|
||||
|
||||
### Document Design
|
||||
- Use appropriate decorators (`@svDb`, `@unI`, `@index`, `@searchable`) to optimize database operations
|
||||
- Implement type-safe models by properly extending `SmartDataDbDoc`
|
||||
- Consider using interfaces to define document structures separately from implementation
|
||||
- Mark fields that need to be searched with the `@searchable()` decorator
|
||||
|
||||
### Search Optimization
|
||||
- Create MongoDB text indexes for collections that need advanced search operations
|
||||
- Use `searchWithLucene()` for robust searches with fallback mechanisms
|
||||
- Prefer field-specific searches when possible for better performance
|
||||
- Use simple term queries instead of boolean operators if you don't have text indexes
|
||||
|
||||
### Performance Optimization
|
||||
- Use cursors for large datasets instead of loading all documents into memory
|
||||
- Create appropriate indexes for frequent query patterns
|
||||
- Use projections to limit the fields returned when you don't need the entire document
|
||||
|
||||
### Distributed Systems
|
||||
- Implement proper error handling for leader election events
|
||||
- Ensure all instances have synchronized clocks when using time-based coordination
|
||||
- Use the distributed coordinator's task management features for coordinated operations
|
||||
|
||||
### Type Safety
|
||||
- Take advantage of the `DeepQuery<T>` type for fully type-safe queries
|
||||
- Define proper types for your document models to enhance IDE auto-completion
|
||||
- Use generic type parameters to specify exact document types when working with collections
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to @push.rocks/smartdata! Here's how you can help:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please make sure to update tests as appropriate and follow our coding standards.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@ -112,4 +567,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
@ -3,7 +3,7 @@ import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import type * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
|
||||
import * as smartdata from '../ts/index.js';
|
||||
import { SmartdataDistributedCoordinator, DistributedClass } from '../ts/smartdata.classes.distributedcoordinator.js'; // path might need adjusting
|
||||
import { SmartdataDistributedCoordinator, DistributedClass } from '../ts/classes.distributedcoordinator.js'; // path might need adjusting
|
||||
const totalInstances = 10;
|
||||
|
||||
// =======================================
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/smartdata.plugins.js';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
|
202
test/test.search.ts
Normal file
202
test/test.search.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
// Import the smartdata library
|
||||
import * as smartdata from '../ts/index.js';
|
||||
import { searchable, getSearchableFields } from '../ts/classes.doc.js';
|
||||
|
||||
// Set up database connection
|
||||
let smartmongoInstance: smartmongo.SmartMongo;
|
||||
let testDb: smartdata.SmartdataDb;
|
||||
|
||||
// Define a test class with searchable fields using the standard SmartDataDbDoc
|
||||
@smartdata.Collection(() => testDb)
|
||||
class Product extends smartdata.SmartDataDbDoc<Product, Product> {
|
||||
@smartdata.unI()
|
||||
public id: string = smartunique.shortId();
|
||||
|
||||
@smartdata.svDb()
|
||||
@searchable()
|
||||
public name: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
@searchable()
|
||||
public description: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
@searchable()
|
||||
public category: string;
|
||||
|
||||
@smartdata.svDb()
|
||||
public price: number;
|
||||
|
||||
constructor(nameArg: string, descriptionArg: string, categoryArg: string, priceArg: number) {
|
||||
super();
|
||||
this.name = nameArg;
|
||||
this.description = descriptionArg;
|
||||
this.category = categoryArg;
|
||||
this.price = priceArg;
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should create a test database instance', async () => {
|
||||
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||
testDb = new smartdata.SmartdataDb(await smartmongoInstance.getMongoDescriptor());
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
tap.test('should create test products with searchable fields', async () => {
|
||||
// Create several products with different fields to search
|
||||
const products = [
|
||||
new Product('iPhone 12', 'Latest iPhone with A14 Bionic chip', 'Electronics', 999),
|
||||
new Product('MacBook Pro', 'Powerful laptop for professionals', 'Electronics', 1999),
|
||||
new Product('AirPods', 'Wireless earbuds with noise cancellation', 'Electronics', 249),
|
||||
new Product('Galaxy S21', 'Samsung flagship phone with great camera', 'Electronics', 899),
|
||||
new Product('Kindle Paperwhite', 'E-reader with built-in light', 'Books', 129),
|
||||
new Product('Harry Potter', 'Fantasy book series about wizards', 'Books', 49),
|
||||
new Product('Coffee Maker', 'Automatic drip coffee machine', 'Kitchen', 89),
|
||||
new Product('Blender', 'High-speed blender for smoothies', 'Kitchen', 129)
|
||||
];
|
||||
|
||||
// Save all products to the database
|
||||
for (const product of products) {
|
||||
await product.save();
|
||||
}
|
||||
|
||||
// Verify that we can get all products
|
||||
const allProducts = await Product.getInstances({});
|
||||
expect(allProducts.length).toEqual(products.length);
|
||||
console.log(`Successfully created and saved ${allProducts.length} products`);
|
||||
});
|
||||
|
||||
tap.test('should retrieve searchable fields for a class', async () => {
|
||||
// Use the getSearchableFields function to verify our searchable fields
|
||||
const searchableFields = getSearchableFields('Product');
|
||||
console.log('Searchable fields:', searchableFields);
|
||||
|
||||
expect(searchableFields.length).toEqual(3);
|
||||
expect(searchableFields).toContain('name');
|
||||
expect(searchableFields).toContain('description');
|
||||
expect(searchableFields).toContain('category');
|
||||
});
|
||||
|
||||
tap.test('should search products by exact field match', async () => {
|
||||
// Basic field exact match search
|
||||
const electronicsProducts = await Product.getInstances({ category: 'Electronics' });
|
||||
console.log(`Found ${electronicsProducts.length} products in Electronics category`);
|
||||
|
||||
expect(electronicsProducts.length).toEqual(4);
|
||||
});
|
||||
|
||||
tap.test('should search products by basic search method', async () => {
|
||||
// Using the basic search method with simple Lucene query
|
||||
try {
|
||||
const iPhoneResults = await Product.search('iPhone');
|
||||
console.log(`Found ${iPhoneResults.length} products matching 'iPhone' using basic search`);
|
||||
|
||||
expect(iPhoneResults.length).toEqual(1);
|
||||
expect(iPhoneResults[0].name).toEqual('iPhone 12');
|
||||
} catch (error) {
|
||||
console.error('Basic search error:', error.message);
|
||||
// If basic search fails, we'll demonstrate the enhanced approach in later tests
|
||||
console.log('Will test with enhanced searchWithLucene method next');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should search products with searchWithLucene method', async () => {
|
||||
// Using the robust searchWithLucene method
|
||||
const wirelessResults = await Product.searchWithLucene('wireless');
|
||||
console.log(`Found ${wirelessResults.length} products matching 'wireless' using searchWithLucene`);
|
||||
|
||||
expect(wirelessResults.length).toEqual(1);
|
||||
expect(wirelessResults[0].name).toEqual('AirPods');
|
||||
});
|
||||
|
||||
tap.test('should search products by category with searchWithLucene', 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`);
|
||||
|
||||
expect(kitchenResults.length).toEqual(2);
|
||||
expect(kitchenResults[0].category).toEqual('Kitchen');
|
||||
expect(kitchenResults[1].category).toEqual('Kitchen');
|
||||
});
|
||||
|
||||
tap.test('should search products with partial word matches', async () => {
|
||||
// Testing partial word matches
|
||||
const proResults = await Product.searchWithLucene('Pro');
|
||||
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
||||
|
||||
// Should match both "MacBook Pro" and "professionals" in description
|
||||
expect(proResults.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should search across multiple searchable fields', async () => {
|
||||
// Test searching across all searchable fields
|
||||
const bookResults = await Product.searchWithLucene('book');
|
||||
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
||||
|
||||
// Should match "MacBook" in name and "Books" in category
|
||||
expect(bookResults.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
tap.test('should handle case insensitive searches', async () => {
|
||||
// Test case insensitivity
|
||||
const electronicsResults = await Product.searchWithLucene('electronics');
|
||||
const ElectronicsResults = await Product.searchWithLucene('Electronics');
|
||||
|
||||
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
||||
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
||||
|
||||
// Both searches should return the same results
|
||||
expect(electronicsResults.length).toEqual(ElectronicsResults.length);
|
||||
});
|
||||
|
||||
tap.test('should demonstrate search fallback mechanisms', async () => {
|
||||
console.log('\n====== FALLBACK MECHANISM DEMONSTRATION ======');
|
||||
console.log('If MongoDB query fails, searchWithLucene will:');
|
||||
console.log('1. Try using basic MongoDB filters');
|
||||
console.log('2. Fall back to field-specific searches');
|
||||
console.log('3. As last resort, perform in-memory filtering');
|
||||
console.log('This ensures robust search even with complex queries');
|
||||
console.log('==============================================\n');
|
||||
|
||||
// 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');
|
||||
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');
|
||||
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
||||
|
||||
// "Powerful laptop for professionals" contains "powerful"
|
||||
expect(powerfulResults.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should explain the advantages of the integrated approach', async () => {
|
||||
console.log('\n====== INTEGRATED SEARCH APPROACH BENEFITS ======');
|
||||
console.log('1. No separate class hierarchy - keeps code simple');
|
||||
console.log('2. Enhanced convertFilterForMongoDb handles MongoDB operators');
|
||||
console.log('3. Robust fallback mechanisms ensure searches always work');
|
||||
console.log('4. searchWithLucene provides powerful search capabilities');
|
||||
console.log('5. Backwards compatible with existing code');
|
||||
console.log('================================================\n');
|
||||
|
||||
expect(true).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('close database connection', async () => {
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
await testDb.close();
|
||||
if (smartmongoInstance) {
|
||||
await smartmongoInstance.stopAndDumpToDir(`.nogit/dbdump/test.search.ts`);
|
||||
}
|
||||
setTimeout(() => process.exit(), 2000);
|
||||
});
|
||||
|
||||
tap.start({ throwOnError: true });
|
@ -1,7 +1,7 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/smartdata.plugins.js';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
import * as mongodb from 'mongodb';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/smartdata.plugins.js';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import { smartunique } from '../ts/smartdata.plugins.js';
|
||||
import { smartunique } from '../ts/plugins.js';
|
||||
|
||||
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartdata',
|
||||
version: '5.2.6',
|
||||
version: '5.5.0',
|
||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
||||
import { SmartdataDbCursor } from './smartdata.classes.cursor.js';
|
||||
import { SmartDataDbDoc } from './smartdata.classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.js';
|
||||
import { CollectionFactory } from './smartdata.classes.collectionfactory.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||
import { SmartDataDbDoc } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||
|
||||
export interface IFindOptions {
|
||||
limit?: number;
|
@ -1,6 +1,6 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { SmartdataCollection } from './smartdata.classes.collection.js';
|
||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { SmartdataCollection } from './classes.collection.js';
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
|
||||
export class CollectionFactory {
|
||||
public collections: { [key: string]: SmartdataCollection<any> } = {};
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const getNewUniqueId = async (prefixArg?: string) => {
|
||||
return plugins.smartunique.uni(prefixArg);
|
@ -1,5 +1,5 @@
|
||||
import { SmartDataDbDoc } from './smartdata.classes.doc.js';
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { SmartDataDbDoc } from './classes.doc.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/**
|
||||
* a wrapper for the native mongodb cursor. Exposes better
|
@ -1,9 +1,9 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import { SmartdataCollection } from './smartdata.classes.collection.js';
|
||||
import { EasyStore } from './smartdata.classes.easystore.js';
|
||||
import { SmartdataCollection } from './classes.collection.js';
|
||||
import { EasyStore } from './classes.easystore.js';
|
||||
|
||||
import { logger } from './smartdata.logging.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
/**
|
||||
* interface - indicates the connection status of the db
|
@ -1,8 +1,8 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
||||
import { managed, setDefaultManagerForDoc } from './smartdata.classes.collection.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './smartdata.classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||
|
||||
@managed()
|
||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
@ -1,12 +1,16 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
||||
import { SmartdataDbCursor } from './smartdata.classes.cursor.js';
|
||||
import { type IManager, SmartdataCollection } from './smartdata.classes.collection.js';
|
||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.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';
|
||||
|
||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||||
|
||||
// Set of searchable fields for each class
|
||||
const searchableFieldsMap = new Map<string, Set<string>>();
|
||||
|
||||
export function globalSvDb() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||
@ -30,6 +34,34 @@ export function svDb() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* searchable - marks a property as searchable with Lucene query syntax
|
||||
*/
|
||||
export function searchable() {
|
||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||
console.log(`called searchable() on >${target.constructor.name}.${key}<`);
|
||||
|
||||
// Initialize the set for this class if it doesn't exist
|
||||
const className = target.constructor.name;
|
||||
if (!searchableFieldsMap.has(className)) {
|
||||
searchableFieldsMap.set(className, new Set<string>());
|
||||
}
|
||||
|
||||
// Add the property to the searchable fields set
|
||||
searchableFieldsMap.get(className).add(key);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get searchable fields for a class
|
||||
*/
|
||||
export function getSearchableFields(className: string): string[] {
|
||||
if (!searchableFieldsMap.has(className)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(searchableFieldsMap.get(className));
|
||||
}
|
||||
|
||||
/**
|
||||
* unique index - decorator to mark a unique index
|
||||
*/
|
||||
@ -52,9 +84,22 @@ export function unI() {
|
||||
}
|
||||
|
||||
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 (typeof filterArg2 === 'object') {
|
||||
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;
|
||||
@ -70,6 +115,7 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||
convertedFilter[keyPathArg2] = filterArg2;
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of Object.keys(filterArg)) {
|
||||
convertFilterArgument(key, filterArg[key]);
|
||||
}
|
||||
@ -202,6 +248,124 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
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 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}`);
|
||||
}
|
||||
|
||||
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
||||
return adapter.convert(luceneQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documents using Lucene query syntax
|
||||
* @param luceneQuery Lucene query string
|
||||
* @returns Array of matching documents
|
||||
*/
|
||||
public static async search<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
luceneQuery: 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by text across all searchable fields (fallback method)
|
||||
* @param searchText The text to search for in all searchable fields
|
||||
* @returns Array of matching documents
|
||||
*/
|
||||
private static async searchByTextAcrossFields<T>(
|
||||
this: plugins.tsclass.typeFest.Class<T>,
|
||||
searchText: string
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const className = (this as any).className || this.name;
|
||||
const searchableFields = getSearchableFields(className);
|
||||
|
||||
// Fallback to direct filter if we have searchable fields
|
||||
if (searchableFields.length > 0) {
|
||||
// Create a simple $or query with regex for each field
|
||||
const orConditions = searchableFields.map(field => ({
|
||||
[field]: { $regex: searchText, $options: 'i' }
|
||||
}));
|
||||
|
||||
const filter = { $or: orConditions };
|
||||
|
||||
try {
|
||||
// Try with MongoDB filter first
|
||||
return await (this as any).getInstances(filter);
|
||||
} catch (error) {
|
||||
console.warn('MongoDB filter failed, falling back to in-memory search');
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: get all and filter in memory
|
||||
const allDocs = await (this as any).getInstances({});
|
||||
const lowerSearchText = searchText.toLowerCase();
|
||||
|
||||
return allDocs.filter((doc: any) => {
|
||||
for (const field of searchableFields) {
|
||||
const value = doc[field];
|
||||
if (value && typeof value === 'string' &&
|
||||
value.toLowerCase().includes(lowerSearchText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
|
||||
/**
|
||||
@ -336,4 +500,4 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
||||
}
|
||||
return identifiableObject;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { Collection } from './smartdata.classes.collection.js';
|
||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './smartdata.classes.doc.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { Collection } from './classes.collection.js';
|
||||
import { SmartdataDb } from './classes.db.js';
|
||||
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||
|
||||
/**
|
||||
* EasyStore allows the storage of easy objects. It also allows easy sharing of the object between different instances
|
741
ts/classes.lucene.adapter.ts
Normal file
741
ts/classes.lucene.adapter.ts
Normal file
@ -0,0 +1,741 @@
|
||||
/**
|
||||
* Lucene to MongoDB query adapter for SmartData
|
||||
*/
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// Types
|
||||
type NodeType = 'TERM' | 'PHRASE' | 'FIELD' | 'AND' | 'OR' | 'NOT' | 'RANGE' | 'WILDCARD' | 'FUZZY' | 'GROUP';
|
||||
|
||||
interface QueryNode {
|
||||
type: NodeType;
|
||||
}
|
||||
|
||||
interface TermNode extends QueryNode {
|
||||
type: 'TERM';
|
||||
value: string;
|
||||
boost?: number;
|
||||
}
|
||||
|
||||
interface PhraseNode extends QueryNode {
|
||||
type: 'PHRASE';
|
||||
value: string;
|
||||
proximity?: number;
|
||||
}
|
||||
|
||||
interface FieldNode extends QueryNode {
|
||||
type: 'FIELD';
|
||||
field: string;
|
||||
value: AnyQueryNode;
|
||||
}
|
||||
|
||||
interface BooleanNode extends QueryNode {
|
||||
type: 'AND' | 'OR' | 'NOT';
|
||||
left: AnyQueryNode;
|
||||
right: AnyQueryNode;
|
||||
}
|
||||
|
||||
interface RangeNode extends QueryNode {
|
||||
type: 'RANGE';
|
||||
field: string;
|
||||
lower: string;
|
||||
upper: string;
|
||||
includeLower: boolean;
|
||||
includeUpper: boolean;
|
||||
}
|
||||
|
||||
interface WildcardNode extends QueryNode {
|
||||
type: 'WILDCARD';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FuzzyNode extends QueryNode {
|
||||
type: 'FUZZY';
|
||||
value: string;
|
||||
maxEdits: number;
|
||||
}
|
||||
|
||||
interface GroupNode extends QueryNode {
|
||||
type: 'GROUP';
|
||||
value: AnyQueryNode;
|
||||
}
|
||||
|
||||
type AnyQueryNode = TermNode | PhraseNode | FieldNode | BooleanNode | RangeNode | WildcardNode | FuzzyNode | GroupNode;
|
||||
|
||||
/**
|
||||
* Lucene query parser
|
||||
*/
|
||||
export class LuceneParser {
|
||||
private pos: number = 0;
|
||||
private input: string = '';
|
||||
private tokens: string[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Parse a Lucene query string into an AST
|
||||
*/
|
||||
parse(query: string): AnyQueryNode {
|
||||
this.input = query.trim();
|
||||
this.pos = 0;
|
||||
this.tokens = this.tokenize(this.input);
|
||||
|
||||
return this.parseQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the input string into tokens
|
||||
*/
|
||||
private tokenize(input: string): string[] {
|
||||
const specialChars = /[()\[\]{}"~^:]/;
|
||||
const operators = /AND|OR|NOT|TO/;
|
||||
|
||||
let tokens: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
|
||||
// Handle quoted strings
|
||||
if (char === '"') {
|
||||
if (inQuote) {
|
||||
tokens.push(current + char);
|
||||
current = '';
|
||||
inQuote = false;
|
||||
} else {
|
||||
if (current) tokens.push(current);
|
||||
current = char;
|
||||
inQuote = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inQuote) {
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle whitespace
|
||||
if (char === ' ' || char === '\t' || char === '\n') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special characters
|
||||
if (specialChars.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
|
||||
// Check if current is an operator
|
||||
if (operators.test(current) &&
|
||||
(i + 1 === input.length || /\s/.test(input[i + 1]))) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (current) tokens.push(current);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the main query expression
|
||||
*/
|
||||
private parseQuery(): AnyQueryNode {
|
||||
const left = this.parseBooleanOperand();
|
||||
|
||||
if (this.pos < this.tokens.length) {
|
||||
const token = this.tokens[this.pos];
|
||||
|
||||
if (token === 'AND' || token === 'OR') {
|
||||
this.pos++;
|
||||
const right = this.parseQuery();
|
||||
return {
|
||||
type: token as 'AND' | 'OR',
|
||||
left,
|
||||
right
|
||||
};
|
||||
} else if (token === 'NOT' || token === '-') {
|
||||
this.pos++;
|
||||
const right = this.parseQuery();
|
||||
return {
|
||||
type: 'NOT',
|
||||
left,
|
||||
right
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse boolean operands (terms, phrases, fields, groups)
|
||||
*/
|
||||
private parseBooleanOperand(): AnyQueryNode {
|
||||
if (this.pos >= this.tokens.length) {
|
||||
throw new Error('Unexpected end of input');
|
||||
}
|
||||
|
||||
const token = this.tokens[this.pos];
|
||||
|
||||
// Handle grouping with parentheses
|
||||
if (token === '(') {
|
||||
this.pos++;
|
||||
const group = this.parseQuery();
|
||||
|
||||
if (this.pos < this.tokens.length && this.tokens[this.pos] === ')') {
|
||||
this.pos++;
|
||||
return { type: 'GROUP', value: group } as GroupNode;
|
||||
} else {
|
||||
throw new Error('Unclosed group');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fields (field:value)
|
||||
if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === ':') {
|
||||
const field = token;
|
||||
this.pos += 2; // Skip field and colon
|
||||
|
||||
if (this.pos < this.tokens.length) {
|
||||
const value = this.parseBooleanOperand();
|
||||
return { type: 'FIELD', field, value } as FieldNode;
|
||||
} else {
|
||||
throw new Error('Expected value after field');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle range queries
|
||||
if (token === '[' || token === '{') {
|
||||
return this.parseRange();
|
||||
}
|
||||
|
||||
// Handle phrases ("term term")
|
||||
if (token.startsWith('"') && token.endsWith('"')) {
|
||||
const phrase = token.slice(1, -1);
|
||||
this.pos++;
|
||||
|
||||
// Check for proximity operator
|
||||
let proximity: number | undefined;
|
||||
if (this.pos < this.tokens.length && this.tokens[this.pos] === '~') {
|
||||
this.pos++;
|
||||
if (this.pos < this.tokens.length && /^\d+$/.test(this.tokens[this.pos])) {
|
||||
proximity = parseInt(this.tokens[this.pos], 10);
|
||||
this.pos++;
|
||||
} else {
|
||||
throw new Error('Expected number after proximity operator');
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'PHRASE', value: phrase, proximity } as PhraseNode;
|
||||
}
|
||||
|
||||
// Handle wildcards
|
||||
if (token.includes('*') || token.includes('?')) {
|
||||
this.pos++;
|
||||
return { type: 'WILDCARD', value: token } as WildcardNode;
|
||||
}
|
||||
|
||||
// Handle fuzzy searches
|
||||
if (this.pos + 1 < this.tokens.length && this.tokens[this.pos + 1] === '~') {
|
||||
const term = token;
|
||||
this.pos += 2; // Skip term and tilde
|
||||
|
||||
let maxEdits = 2; // Default
|
||||
if (this.pos < this.tokens.length && /^\d+$/.test(this.tokens[this.pos])) {
|
||||
maxEdits = parseInt(this.tokens[this.pos], 10);
|
||||
this.pos++;
|
||||
}
|
||||
|
||||
return { type: 'FUZZY', value: term, maxEdits } as FuzzyNode;
|
||||
}
|
||||
|
||||
// Simple term
|
||||
this.pos++;
|
||||
return { type: 'TERM', value: token } as TermNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse range queries
|
||||
*/
|
||||
private parseRange(): RangeNode {
|
||||
const includeLower = this.tokens[this.pos] === '[';
|
||||
const includeUpper = this.tokens[this.pos + 4] === ']';
|
||||
|
||||
this.pos++; // Skip open bracket
|
||||
|
||||
if (this.pos + 4 >= this.tokens.length) {
|
||||
throw new Error('Invalid range query syntax');
|
||||
}
|
||||
|
||||
const lower = this.tokens[this.pos];
|
||||
this.pos++;
|
||||
|
||||
if (this.tokens[this.pos] !== 'TO') {
|
||||
throw new Error('Expected TO in range query');
|
||||
}
|
||||
this.pos++;
|
||||
|
||||
const upper = this.tokens[this.pos];
|
||||
this.pos++;
|
||||
|
||||
if (this.tokens[this.pos] !== (includeLower ? ']' : '}')) {
|
||||
throw new Error('Invalid range query closing bracket');
|
||||
}
|
||||
this.pos++;
|
||||
|
||||
// For simplicity, assuming the field is handled separately
|
||||
return {
|
||||
type: 'RANGE',
|
||||
field: '', // This will be filled by the field node
|
||||
lower,
|
||||
upper,
|
||||
includeLower,
|
||||
includeUpper
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformer for Lucene AST to MongoDB query
|
||||
* FIXED VERSION - proper MongoDB query structure
|
||||
*/
|
||||
export class LuceneToMongoTransformer {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Transform a Lucene AST node to a MongoDB query
|
||||
*/
|
||||
transform(node: AnyQueryNode, searchFields?: string[]): any {
|
||||
switch (node.type) {
|
||||
case 'TERM':
|
||||
return this.transformTerm(node, searchFields);
|
||||
case 'PHRASE':
|
||||
return this.transformPhrase(node, searchFields);
|
||||
case 'FIELD':
|
||||
return this.transformField(node);
|
||||
case 'AND':
|
||||
return this.transformAnd(node);
|
||||
case 'OR':
|
||||
return this.transformOr(node);
|
||||
case 'NOT':
|
||||
return this.transformNot(node);
|
||||
case 'RANGE':
|
||||
return this.transformRange(node);
|
||||
case 'WILDCARD':
|
||||
return this.transformWildcard(node, searchFields);
|
||||
case 'FUZZY':
|
||||
return this.transformFuzzy(node, searchFields);
|
||||
case 'GROUP':
|
||||
return this.transform(node.value, searchFields);
|
||||
default:
|
||||
throw new Error(`Unsupported node type: ${(node as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a term to MongoDB query
|
||||
* FIXED: properly structured $or query for multiple fields
|
||||
*/
|
||||
private transformTerm(node: TermNode, searchFields?: string[]): any {
|
||||
// If specific fields are provided, search across those fields
|
||||
if (searchFields && searchFields.length > 0) {
|
||||
// Create an $or query to search across multiple fields
|
||||
const orConditions = searchFields.map(field => ({
|
||||
[field]: { $regex: node.value, $options: 'i' }
|
||||
}));
|
||||
|
||||
return { $or: orConditions };
|
||||
}
|
||||
|
||||
// Otherwise, use text search (requires a text index on desired fields)
|
||||
return { $text: { $search: node.value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a phrase to MongoDB query
|
||||
* FIXED: properly structured $or query for multiple fields
|
||||
*/
|
||||
private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
|
||||
// If specific fields are provided, search phrase across those fields
|
||||
if (searchFields && searchFields.length > 0) {
|
||||
const orConditions = searchFields.map(field => ({
|
||||
[field]: { $regex: `${node.value.replace(/\s+/g, '\\s+')}`, $options: 'i' }
|
||||
}));
|
||||
|
||||
return { $or: orConditions };
|
||||
}
|
||||
|
||||
// For phrases, we use a regex to ensure exact matches
|
||||
return { $text: { $search: `"${node.value}"` } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a field query to MongoDB query
|
||||
*/
|
||||
private transformField(node: FieldNode): any {
|
||||
// Handle special case for range queries on fields
|
||||
if (node.value.type === 'RANGE') {
|
||||
const rangeNode = node.value as RangeNode;
|
||||
rangeNode.field = node.field;
|
||||
return this.transformRange(rangeNode);
|
||||
}
|
||||
|
||||
// Handle special case for wildcards on fields
|
||||
if (node.value.type === 'WILDCARD') {
|
||||
return {
|
||||
[node.field]: {
|
||||
$regex: this.luceneWildcardToRegex((node.value as WildcardNode).value),
|
||||
$options: 'i'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special case for fuzzy searches on fields
|
||||
if (node.value.type === 'FUZZY') {
|
||||
return {
|
||||
[node.field]: {
|
||||
$regex: this.createFuzzyRegex((node.value as FuzzyNode).value),
|
||||
$options: 'i'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Special case for exact term matches on fields
|
||||
if (node.value.type === 'TERM') {
|
||||
return { [node.field]: { $regex: (node.value as TermNode).value, $options: 'i' } };
|
||||
}
|
||||
|
||||
// Special case for phrase matches on fields
|
||||
if (node.value.type === 'PHRASE') {
|
||||
return {
|
||||
[node.field]: {
|
||||
$regex: `${(node.value as PhraseNode).value.replace(/\s+/g, '\\s+')}`,
|
||||
$options: 'i'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// For other cases, we'll transform the value and apply it to the field
|
||||
const transformedValue = this.transform(node.value);
|
||||
|
||||
// If the transformed value uses $text, we need to adapt it for the field
|
||||
if (transformedValue.$text) {
|
||||
return { [node.field]: { $regex: transformedValue.$text.$search, $options: 'i' } };
|
||||
}
|
||||
|
||||
// Handle $or and $and cases
|
||||
if (transformedValue.$or || transformedValue.$and) {
|
||||
// This is a bit complex - we need to restructure the query to apply the field
|
||||
// For now, simplify by just using a regex on the field
|
||||
const term = this.extractTermFromBooleanQuery(transformedValue);
|
||||
if (term) {
|
||||
return { [node.field]: { $regex: term, $options: 'i' } };
|
||||
}
|
||||
}
|
||||
|
||||
return { [node.field]: transformedValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a term from a boolean query (simplification)
|
||||
*/
|
||||
private extractTermFromBooleanQuery(query: any): string | null {
|
||||
if (query.$or && Array.isArray(query.$or) && query.$or.length > 0) {
|
||||
const firstClause = query.$or[0];
|
||||
for (const field in firstClause) {
|
||||
if (firstClause[field].$regex) {
|
||||
return firstClause[field].$regex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.$and && Array.isArray(query.$and) && query.$and.length > 0) {
|
||||
const firstClause = query.$and[0];
|
||||
for (const field in firstClause) {
|
||||
if (firstClause[field].$regex) {
|
||||
return firstClause[field].$regex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform AND operator to MongoDB query
|
||||
* FIXED: $and must be an array
|
||||
*/
|
||||
private transformAnd(node: BooleanNode): any {
|
||||
return { $and: [this.transform(node.left), this.transform(node.right)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform OR operator to MongoDB query
|
||||
* FIXED: $or must be an array
|
||||
*/
|
||||
private transformOr(node: BooleanNode): any {
|
||||
return { $or: [this.transform(node.left), this.transform(node.right)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform NOT operator to MongoDB query
|
||||
* FIXED: $and must be an array and $not usage
|
||||
*/
|
||||
private transformNot(node: BooleanNode): any {
|
||||
const leftQuery = this.transform(node.left);
|
||||
const rightQuery = this.transform(node.right);
|
||||
|
||||
// Create a query that includes left but excludes right
|
||||
if (rightQuery.$text) {
|
||||
// For text searches, we need a different approach
|
||||
// We'll use a negated regex instead
|
||||
const searchTerm = rightQuery.$text.$search.replace(/"/g, '');
|
||||
|
||||
// Determine the fields to apply the negation to
|
||||
const notConditions = [];
|
||||
|
||||
for (const field in leftQuery) {
|
||||
if (field !== '$or' && field !== '$and') {
|
||||
notConditions.push({
|
||||
[field]: { $not: { $regex: searchTerm, $options: 'i' } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If left query has $or or $and, we need to handle it differently
|
||||
if (leftQuery.$or) {
|
||||
return {
|
||||
$and: [
|
||||
leftQuery,
|
||||
{ $nor: [{ $or: notConditions }] }
|
||||
]
|
||||
};
|
||||
} else {
|
||||
// Simple case - just add $not to each field
|
||||
return {
|
||||
$and: [leftQuery, { $and: notConditions }]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For other queries, we can use $not directly
|
||||
// We need to handle different structures based on the rightQuery
|
||||
let notQuery = {};
|
||||
|
||||
if (rightQuery.$or) {
|
||||
notQuery = { $nor: rightQuery.$or };
|
||||
} else if (rightQuery.$and) {
|
||||
// Convert $and to $nor
|
||||
notQuery = { $nor: rightQuery.$and };
|
||||
} else {
|
||||
// Simple field condition
|
||||
for (const field in rightQuery) {
|
||||
notQuery[field] = { $not: rightQuery[field] };
|
||||
}
|
||||
}
|
||||
|
||||
return { $and: [leftQuery, notQuery] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform range query to MongoDB query
|
||||
*/
|
||||
private transformRange(node: RangeNode): any {
|
||||
const range: any = {};
|
||||
|
||||
if (node.lower !== '*') {
|
||||
range[node.includeLower ? '$gte' : '$gt'] = this.parseValue(node.lower);
|
||||
}
|
||||
|
||||
if (node.upper !== '*') {
|
||||
range[node.includeUpper ? '$lte' : '$lt'] = this.parseValue(node.upper);
|
||||
}
|
||||
|
||||
return { [node.field]: range };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform wildcard query to MongoDB query
|
||||
* FIXED: properly structured for multiple fields
|
||||
*/
|
||||
private transformWildcard(node: WildcardNode, searchFields?: string[]): any {
|
||||
// Convert Lucene wildcards to MongoDB regex
|
||||
const regex = this.luceneWildcardToRegex(node.value);
|
||||
|
||||
// If specific fields are provided, search wildcard across those fields
|
||||
if (searchFields && searchFields.length > 0) {
|
||||
const orConditions = searchFields.map(field => ({
|
||||
[field]: { $regex: regex, $options: 'i' }
|
||||
}));
|
||||
|
||||
return { $or: orConditions };
|
||||
}
|
||||
|
||||
// By default, apply to the default field
|
||||
return { $regex: regex, $options: 'i' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform fuzzy query to MongoDB query
|
||||
* FIXED: properly structured for multiple fields
|
||||
*/
|
||||
private transformFuzzy(node: FuzzyNode, searchFields?: string[]): any {
|
||||
// MongoDB doesn't have built-in fuzzy search
|
||||
// This is a very basic approach using regex
|
||||
const regex = this.createFuzzyRegex(node.value);
|
||||
|
||||
// If specific fields are provided, search fuzzy term across those fields
|
||||
if (searchFields && searchFields.length > 0) {
|
||||
const orConditions = searchFields.map(field => ({
|
||||
[field]: { $regex: regex, $options: 'i' }
|
||||
}));
|
||||
|
||||
return { $or: orConditions };
|
||||
}
|
||||
|
||||
// By default, apply to the default field
|
||||
return { $regex: regex, $options: 'i' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Lucene wildcards to MongoDB regex patterns
|
||||
*/
|
||||
private luceneWildcardToRegex(wildcardPattern: string): string {
|
||||
// Replace Lucene wildcards with regex equivalents
|
||||
// * => .*
|
||||
// ? => .
|
||||
// Also escape regex special chars
|
||||
return wildcardPattern
|
||||
.replace(/([.+^${}()|\\])/g, '\\$1') // Escape regex special chars
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simplified fuzzy search regex
|
||||
*/
|
||||
private createFuzzyRegex(term: string): string {
|
||||
// For a very simple approach, we allow some characters to be optional
|
||||
let regex = '';
|
||||
for (let i = 0; i < term.length; i++) {
|
||||
// Make every other character optional (simplified fuzzy)
|
||||
if (i % 2 === 1) {
|
||||
regex += term[i] + '?';
|
||||
} else {
|
||||
regex += term[i];
|
||||
}
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse string values to appropriate types (numbers, dates, etc.)
|
||||
*/
|
||||
private parseValue(value: string): any {
|
||||
// Try to parse as number
|
||||
if (/^-?\d+$/.test(value)) {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
if (/^-?\d+\.\d+$/.test(value)) {
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
// Try to parse as date (simplified)
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
// Default to string
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main adapter class
|
||||
*/
|
||||
export class SmartdataLuceneAdapter {
|
||||
private parser: LuceneParser;
|
||||
private transformer: LuceneToMongoTransformer;
|
||||
private defaultSearchFields: string[] = [];
|
||||
|
||||
/**
|
||||
* @param defaultSearchFields - Optional array of field names to search across when no field is specified
|
||||
*/
|
||||
constructor(defaultSearchFields?: string[]) {
|
||||
this.parser = new LuceneParser();
|
||||
this.transformer = new LuceneToMongoTransformer();
|
||||
if (defaultSearchFields) {
|
||||
this.defaultSearchFields = defaultSearchFields;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Lucene query string to a MongoDB query object
|
||||
* @param luceneQuery - The Lucene query string to convert
|
||||
* @param searchFields - Optional array of field names to search across (overrides defaultSearchFields)
|
||||
*/
|
||||
convert(luceneQuery: string, searchFields?: string[]): any {
|
||||
try {
|
||||
// For simple single term queries, create a simpler query structure
|
||||
if (!luceneQuery.includes(':') &&
|
||||
!luceneQuery.includes(' AND ') &&
|
||||
!luceneQuery.includes(' OR ') &&
|
||||
!luceneQuery.includes(' NOT ') &&
|
||||
!luceneQuery.includes('(') &&
|
||||
!luceneQuery.includes('[')) {
|
||||
|
||||
// This is a simple term, use a more direct approach
|
||||
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
||||
|
||||
if (fieldsToSearch && fieldsToSearch.length > 0) {
|
||||
return {
|
||||
$or: fieldsToSearch.map(field => ({
|
||||
[field]: { $regex: luceneQuery, $options: 'i' }
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For more complex queries, use the full parser
|
||||
// Parse the Lucene query into an AST
|
||||
const ast = this.parser.parse(luceneQuery);
|
||||
|
||||
// Use provided searchFields, fall back to defaultSearchFields
|
||||
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
||||
|
||||
// Transform the AST to a MongoDB query
|
||||
return this.transformWithFields(ast, fieldsToSearch);
|
||||
} catch (error) {
|
||||
console.error(`Failed to convert Lucene query "${luceneQuery}":`, error);
|
||||
throw new Error(`Failed to convert Lucene query: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to transform the AST with field information
|
||||
*/
|
||||
private transformWithFields(node: AnyQueryNode, searchFields: string[]): any {
|
||||
// Special case for term nodes without a specific field
|
||||
if (node.type === 'TERM' || node.type === 'PHRASE' ||
|
||||
node.type === 'WILDCARD' || node.type === 'FUZZY') {
|
||||
return this.transformer.transform(node, searchFields);
|
||||
}
|
||||
|
||||
// For other node types, use the standard transformation
|
||||
return this.transformer.transform(node);
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { SmartDataDbDoc } from './smartdata.classes.doc.js';
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import { SmartDataDbDoc } from './classes.doc.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/**
|
||||
* a wrapper for the native mongodb cursor. Exposes better
|
16
ts/index.ts
16
ts/index.ts
@ -1,14 +1,14 @@
|
||||
export * from './smartdata.classes.db.js';
|
||||
export * from './smartdata.classes.collection.js';
|
||||
export * from './smartdata.classes.doc.js';
|
||||
export * from './smartdata.classes.easystore.js';
|
||||
export * from './smartdata.classes.cursor.js';
|
||||
export * from './classes.db.js';
|
||||
export * from './classes.collection.js';
|
||||
export * from './classes.doc.js';
|
||||
export * from './classes.easystore.js';
|
||||
export * from './classes.cursor.js';
|
||||
|
||||
import * as convenience from './smartadata.convenience.js';
|
||||
import * as convenience from './classes.convenience.js';
|
||||
|
||||
export { convenience };
|
||||
|
||||
// to be removed with the next breaking update
|
||||
import type * as plugins from './smartdata.plugins.js';
|
||||
import type * as plugins from './plugins.js';
|
||||
type IMongoDescriptor = plugins.tsclass.database.IMongoDescriptor;
|
||||
export type { IMongoDescriptor };
|
||||
export type { IMongoDescriptor };
|
@ -1,3 +1,3 @@
|
||||
import * as plugins from './smartdata.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const logger = new plugins.smartlog.ConsoleLog();
|
Reference in New Issue
Block a user