Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
4fac974fc9 | |||
cad2decf59 | |||
0f61bdc455 | |||
408b2cce4a | |||
7a08700451 | |||
ebaf3e685c | |||
c8d51a30d8 | |||
d957e911de | |||
fee936c75f | |||
ac867401de | |||
c066464526 | |||
0105aa2a18 | |||
4c2477c269 | |||
ea0d2bb251 | |||
b3e30a8711 | |||
64621dd38f | |||
117c257a27 | |||
b30522c505 | |||
57d2d56d00 | |||
90751002aa | |||
7606e074a5 | |||
7ec39e397e | |||
21d8d3dc32 | |||
6d456955d8 | |||
d08544c782 | |||
bda9ac8a07 |
@ -119,6 +119,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
npmci node install stable
|
||||||
npmci npm install
|
npmci npm install
|
||||||
pnpm install -g @gitzone/tsdoc
|
pnpm install -g @git.zone/tsdoc
|
||||||
npmci command tsdoc
|
npmci command tsdoc
|
||||||
continue-on-error: true
|
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",
|
"name": "@push.rocks/smartdata",
|
||||||
"version": "5.2.2",
|
"version": "5.5.0",
|
||||||
"private": false,
|
"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.",
|
"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",
|
"main": "dist_ts/index.js",
|
||||||
@ -25,23 +25,23 @@
|
|||||||
"@push.rocks/lik": "^6.0.14",
|
"@push.rocks/lik": "^6.0.14",
|
||||||
"@push.rocks/smartdelay": "^3.0.1",
|
"@push.rocks/smartdelay": "^3.0.1",
|
||||||
"@push.rocks/smartlog": "^3.0.2",
|
"@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/smartpromise": "^4.0.2",
|
||||||
"@push.rocks/smartrx": "^3.0.7",
|
"@push.rocks/smartrx": "^3.0.7",
|
||||||
"@push.rocks/smartstring": "^4.0.15",
|
"@push.rocks/smartstring": "^4.0.15",
|
||||||
"@push.rocks/smarttime": "^4.0.6",
|
"@push.rocks/smarttime": "^4.0.6",
|
||||||
"@push.rocks/smartunique": "^3.0.8",
|
"@push.rocks/smartunique": "^3.0.8",
|
||||||
"@push.rocks/taskbuffer": "^3.1.7",
|
"@push.rocks/taskbuffer": "^3.1.7",
|
||||||
"@tsclass/tsclass": "^4.0.52",
|
"@tsclass/tsclass": "^8.2.0",
|
||||||
"mongodb": "^6.5.0"
|
"mongodb": "^6.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gitzone/tsbuild": "^2.1.66",
|
"@git.zone/tsbuild": "^2.3.2",
|
||||||
"@gitzone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^1.2.44",
|
||||||
"@gitzone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^1.0.77",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@push.rocks/qenv": "^6.0.5",
|
||||||
"@push.rocks/tapbundle": "^5.0.22",
|
"@push.rocks/tapbundle": "^5.6.2",
|
||||||
"@types/node": "^20.11.30"
|
"@types/node": "^22.14.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@ -67,5 +67,6 @@
|
|||||||
"collections",
|
"collections",
|
||||||
"custom data types",
|
"custom data types",
|
||||||
"ODM"
|
"ODM"
|
||||||
]
|
],
|
||||||
|
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||||
}
|
}
|
||||||
|
13505
pnpm-lock.yaml
generated
13505
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
|
479
readme.md
479
readme.md
@ -1,5 +1,30 @@
|
|||||||
# @push.rocks/smartdata
|
# @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
|
## Install
|
||||||
To install `@push.rocks/smartdata`, use npm:
|
To install `@push.rocks/smartdata`, use npm:
|
||||||
@ -8,26 +33,31 @@ To install `@push.rocks/smartdata`, use npm:
|
|||||||
npm install @push.rocks/smartdata --save
|
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
|
## 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
|
### 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
|
```typescript
|
||||||
import { SmartdataDb } from '@push.rocks/smartdata';
|
import { SmartdataDb } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
// Create a new instance of SmartdataDb with MongoDB connection details
|
// Create a new instance of SmartdataDb with MongoDB connection details
|
||||||
const db = new SmartdataDb({
|
const db = new SmartdataDb({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017',
|
mongoDbUrl: 'mongodb://<USERNAME>:<PASSWORD>@localhost:27017/<DBNAME>',
|
||||||
mongoDbName: 'your-database-name',
|
mongoDbName: 'your-database-name',
|
||||||
mongoDbUser: 'your-username',
|
mongoDbUser: 'your-username',
|
||||||
mongoDbPass: 'your-password',
|
mongoDbPass: 'your-password',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize and connect to the database
|
// Initialize and connect to the database
|
||||||
|
// This sets up a connection pool with max 100 connections
|
||||||
await db.init();
|
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.
|
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
|
```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
|
@Collection(() => db) // Associate this model with the database instance
|
||||||
class User extends SmartDataDbDoc<User, User> {
|
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
|
public id: string = 'unique-user-id'; // Mark 'id' as a unique index
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
|
@searchable() // Mark 'username' as searchable
|
||||||
public username: string; // Mark 'username' to be saved in DB
|
public username: string; // Mark 'username' to be saved in DB
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
|
@searchable() // Mark 'email' as searchable
|
||||||
|
@index() // Create a regular index for this field
|
||||||
public email: string; // Mark 'email' to be saved in DB
|
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) {
|
constructor(username: string, email: string) {
|
||||||
super();
|
super();
|
||||||
this.username = username;
|
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.
|
`@push.rocks/smartdata` simplifies CRUD operations with intuitive methods on model instances.
|
||||||
|
|
||||||
#### Create
|
#### Create
|
||||||
@ -72,6 +120,32 @@ const user = await User.getInstance({ username: 'myUsername' });
|
|||||||
|
|
||||||
// Fetch multiple users that match criteria
|
// Fetch multiple users that match criteria
|
||||||
const users = await User.getInstances({ email: 'myEmail@example.com' });
|
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
|
#### Update
|
||||||
@ -79,6 +153,15 @@ const users = await User.getInstances({ email: 'myEmail@example.com' });
|
|||||||
// Assuming 'user' is an instance of User
|
// Assuming 'user' is an instance of User
|
||||||
user.email = 'newEmail@example.com';
|
user.email = 'newEmail@example.com';
|
||||||
await user.save(); // Update the user in the database
|
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
|
#### Delete
|
||||||
@ -87,13 +170,385 @@ await user.save(); // Update the user in the database
|
|||||||
await user.delete(); // Delete the user from the database
|
await user.delete(); // Delete the user from the database
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Usage
|
## Advanced Features
|
||||||
`@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.
|
|
||||||
|
|
||||||
### Conclusion
|
### Search Functionality
|
||||||
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.
|
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
|
## License and Legal Information
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import * as smartmongo from '@push.rocks/smartmongo';
|
|||||||
import type * as taskbuffer from '@push.rocks/taskbuffer';
|
import type * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
import * as smartdata from '../ts/index.js';
|
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;
|
const totalInstances = 10;
|
||||||
|
|
||||||
// =======================================
|
// =======================================
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
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/');
|
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 { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
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';
|
import * as mongodb from 'mongodb';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
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/');
|
const testQenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
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/');
|
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 = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartdata',
|
name: '@push.rocks/smartdata',
|
||||||
version: '5.2.2',
|
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.'
|
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 * as plugins from './plugins.js';
|
||||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { SmartdataDbCursor } from './smartdata.classes.cursor.js';
|
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||||
import { SmartDataDbDoc } from './smartdata.classes.doc.js';
|
import { SmartDataDbDoc } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { CollectionFactory } from './smartdata.classes.collectionfactory.js';
|
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||||
|
|
||||||
export interface IFindOptions {
|
export interface IFindOptions {
|
||||||
limit?: number;
|
limit?: number;
|
@ -1,6 +1,6 @@
|
|||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { SmartdataCollection } from './smartdata.classes.collection.js';
|
import { SmartdataCollection } from './classes.collection.js';
|
||||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
|
|
||||||
export class CollectionFactory {
|
export class CollectionFactory {
|
||||||
public collections: { [key: string]: SmartdataCollection<any> } = {};
|
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) => {
|
export const getNewUniqueId = async (prefixArg?: string) => {
|
||||||
return plugins.smartunique.uni(prefixArg);
|
return plugins.smartunique.uni(prefixArg);
|
@ -1,5 +1,5 @@
|
|||||||
import { SmartDataDbDoc } from './smartdata.classes.doc.js';
|
import { SmartDataDbDoc } from './classes.doc.js';
|
||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a wrapper for the native mongodb cursor. Exposes better
|
* 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 { SmartdataCollection } from './classes.collection.js';
|
||||||
import { EasyStore } from './smartdata.classes.easystore.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
|
* interface - indicates the connection status of the db
|
@ -1,8 +1,8 @@
|
|||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { managed, setDefaultManagerForDoc } from './smartdata.classes.collection.js';
|
import { managed, setDefaultManagerForDoc } from './classes.collection.js';
|
||||||
import { SmartDataDbDoc, svDb, unI } from './smartdata.classes.doc.js';
|
import { SmartDataDbDoc, svDb, unI } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
|
|
||||||
@managed()
|
@managed()
|
||||||
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
export class DistributedClass extends SmartDataDbDoc<DistributedClass, DistributedClass> {
|
@ -1,25 +1,67 @@
|
|||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { SmartdataDbCursor } from './smartdata.classes.cursor.js';
|
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||||
import { type IManager, SmartdataCollection } from './smartdata.classes.collection.js';
|
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
||||||
import { SmartdataDbWatcher } from './smartdata.classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
|
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
||||||
|
|
||||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
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}<`);
|
||||||
|
if (!target.globalSaveableProperties) {
|
||||||
|
target.globalSaveableProperties = [];
|
||||||
|
}
|
||||||
|
target.globalSaveableProperties.push(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* saveable - saveable decorator to be used on class properties
|
* saveable - saveable decorator to be used on class properties
|
||||||
*/
|
*/
|
||||||
export function svDb() {
|
export function svDb() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
console.log(`called svDb() on >${target.constructor.name}.${key}<`);
|
||||||
if (!target.constructor.prototype.saveableProperties) {
|
if (!target.saveableProperties) {
|
||||||
target.constructor.prototype.saveableProperties = [];
|
target.saveableProperties = [];
|
||||||
}
|
}
|
||||||
target.constructor.prototype.saveableProperties.push(key);
|
target.saveableProperties.push(key);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* unique index - decorator to mark a unique index
|
||||||
*/
|
*/
|
||||||
@ -28,23 +70,36 @@ export function unI() {
|
|||||||
console.log(`called unI on >>${target.constructor.name}.${key}<<`);
|
console.log(`called unI on >>${target.constructor.name}.${key}<<`);
|
||||||
|
|
||||||
// mark the index as unique
|
// mark the index as unique
|
||||||
if (!target.constructor.prototype.uniqueIndexes) {
|
if (!target.uniqueIndexes) {
|
||||||
target.constructor.prototype.uniqueIndexes = [];
|
target.uniqueIndexes = [];
|
||||||
}
|
}
|
||||||
target.constructor.prototype.uniqueIndexes.push(key);
|
target.uniqueIndexes.push(key);
|
||||||
|
|
||||||
// and also save it
|
// and also save it
|
||||||
if (!target.constructor.prototype.saveableProperties) {
|
if (!target.saveableProperties) {
|
||||||
target.constructor.prototype.saveableProperties = [];
|
target.saveableProperties = [];
|
||||||
}
|
}
|
||||||
target.constructor.prototype.saveableProperties.push(key);
|
target.saveableProperties.push(key);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
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 convertedFilter: { [key: string]: any } = {};
|
||||||
|
|
||||||
const convertFilterArgument = (keyPathArg2: string, filterArg2: 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)) {
|
for (const key of Object.keys(filterArg2)) {
|
||||||
if (key.startsWith('$')) {
|
if (key.startsWith('$')) {
|
||||||
convertedFilter[keyPathArg2] = filterArg2;
|
convertedFilter[keyPathArg2] = filterArg2;
|
||||||
@ -60,6 +115,7 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
|||||||
convertedFilter[keyPathArg2] = filterArg2;
|
convertedFilter[keyPathArg2] = filterArg2;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(filterArg)) {
|
for (const key of Object.keys(filterArg)) {
|
||||||
convertFilterArgument(key, filterArg[key]);
|
convertFilterArgument(key, filterArg[key]);
|
||||||
}
|
}
|
||||||
@ -192,6 +248,124 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
return await collection.getCount(filterArg);
|
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
|
// INSTANCE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -202,22 +376,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
/**
|
/**
|
||||||
* updated from db in any case where doc comes from db
|
* updated from db in any case where doc comes from db
|
||||||
*/
|
*/
|
||||||
@svDb()
|
@globalSvDb()
|
||||||
_createdAt: string = (new Date()).toISOString();
|
_createdAt: string = (new Date()).toISOString();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* will be updated everytime the doc is saved
|
* will be updated everytime the doc is saved
|
||||||
*/
|
*/
|
||||||
@svDb()
|
@globalSvDb()
|
||||||
_updatedAt: string = (new Date()).toISOString();
|
_updatedAt: string = (new Date()).toISOString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* an array of saveable properties of ALL doc
|
||||||
|
*/
|
||||||
|
public globalSaveableProperties: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* unique indexes
|
* unique indexes
|
||||||
*/
|
*/
|
||||||
public uniqueIndexes: string[];
|
public uniqueIndexes: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* an array of saveable properties of a doc
|
* an array of saveable properties of a specific doc
|
||||||
*/
|
*/
|
||||||
public saveableProperties: string[];
|
public saveableProperties: string[];
|
||||||
|
|
||||||
@ -301,7 +480,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
public async createSavableObject(): Promise<TImplements> {
|
public async createSavableObject(): Promise<TImplements> {
|
||||||
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
||||||
for (const propertyNameString of this.constructor.prototype.saveableProperties) {
|
const saveableProperties = [
|
||||||
|
...this.globalSaveableProperties,
|
||||||
|
...this.saveableProperties
|
||||||
|
]
|
||||||
|
for (const propertyNameString of saveableProperties) {
|
||||||
saveableObject[propertyNameString] = this[propertyNameString];
|
saveableObject[propertyNameString] = this[propertyNameString];
|
||||||
}
|
}
|
||||||
return saveableObject as TImplements;
|
return saveableObject as TImplements;
|
@ -1,7 +1,7 @@
|
|||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { Collection } from './smartdata.classes.collection.js';
|
import { Collection } from './classes.collection.js';
|
||||||
import { SmartdataDb } from './smartdata.classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { SmartDataDbDoc, svDb, unI } from './smartdata.classes.doc.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
|
* 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 { SmartDataDbDoc } from './classes.doc.js';
|
||||||
import * as plugins from './smartdata.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a wrapper for the native mongodb cursor. Exposes better
|
* a wrapper for the native mongodb cursor. Exposes better
|
14
ts/index.ts
14
ts/index.ts
@ -1,14 +1,14 @@
|
|||||||
export * from './smartdata.classes.db.js';
|
export * from './classes.db.js';
|
||||||
export * from './smartdata.classes.collection.js';
|
export * from './classes.collection.js';
|
||||||
export * from './smartdata.classes.doc.js';
|
export * from './classes.doc.js';
|
||||||
export * from './smartdata.classes.easystore.js';
|
export * from './classes.easystore.js';
|
||||||
export * from './smartdata.classes.cursor.js';
|
export * from './classes.cursor.js';
|
||||||
|
|
||||||
import * as convenience from './smartadata.convenience.js';
|
import * as convenience from './classes.convenience.js';
|
||||||
|
|
||||||
export { convenience };
|
export { convenience };
|
||||||
|
|
||||||
// to be removed with the next breaking update
|
// 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;
|
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();
|
export const logger = new plugins.smartlog.ConsoleLog();
|
Reference in New Issue
Block a user