Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
db2767010d | |||
e2dc094afd | |||
39d2957b7d | |||
490524516e | |||
ccd4b9e1ec | |||
9c6d6d9f2c | |||
e4d787096e | |||
2bf923b4f1 | |||
0ca1d452b4 | |||
436311ab06 | |||
498f586ddb | |||
6c50bd23ec | |||
419eb163f4 | |||
75aeb12e81 | |||
c5a44da975 | |||
969b073939 | |||
ac80f90ae0 | |||
d0e769622e | |||
eef758cabb | |||
d0cc2a0ed2 | |||
87c930121c | |||
23b499b3a8 | |||
0834ec5c91 | |||
6a2a708ea1 | |||
1d977986f1 | |||
e325b42906 | |||
1a359d355a | |||
b5a9449d5e | |||
558f83a3d9 | |||
76ae454221 | |||
90cfc4644d | |||
0be279e5f5 | |||
9755522bba | |||
de8736e99e | |||
c430627a21 | |||
0bfebaf5b9 | |||
4733982d03 | |||
368dc27607 | |||
938b25c925 | |||
ab251858ba | |||
24371ccf78 | |||
ed1eecbab8 |
145
changelog.md
145
changelog.md
@ -1,5 +1,150 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.13.1 - fix(search)
|
||||||
|
Improve search query parsing for implicit AND queries by preserving quoted substrings and better handling free terms, quoted phrases, and field:value tokens.
|
||||||
|
|
||||||
|
- Replace previous implicit AND logic with tokenization that preserves quoted substrings
|
||||||
|
- Support both free term and field:value tokens with wildcards inside quotes
|
||||||
|
- Ensure errors are thrown for non-searchable fields in field-specific queries
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.13.0 - feat(search)
|
||||||
|
Improve search query handling and update documentation
|
||||||
|
|
||||||
|
- Added 'codex.md' providing a high-level project overview and detailed search API documentation.
|
||||||
|
- Enhanced search parsing in SmartDataDbDoc to support combined free-term and quoted field phrase queries.
|
||||||
|
- Introduced a new fallback branch in the search method to handle free term with quoted field input.
|
||||||
|
- Updated tests in test/test.search.ts to cover new combined query scenarios and ensure robust behavior.
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.2 - fix(search)
|
||||||
|
Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches
|
||||||
|
|
||||||
|
- Strip surrounding quotes from wildcard patterns in field queries to correctly transform them to regex
|
||||||
|
- Introduce new tests in test/test.search.ts to validate exact quoted and unquoted wildcard searches on a location field
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.1 - fix(search)
|
||||||
|
Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling.
|
||||||
|
|
||||||
|
- Updated regex for field:value parsing to capture full value with wildcards.
|
||||||
|
- Added explicit handling for free terms by converting to regex across searchable fields.
|
||||||
|
- Improved error messaging for attempts to search non-searchable fields.
|
||||||
|
- Extended tests to cover combined free term and wildcard field searches, including error cases.
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.12.0 - feat(doc/search)
|
||||||
|
Enhance search functionality with filter and validate options for advanced query control
|
||||||
|
|
||||||
|
- Added 'filter' option to merge additional MongoDB query constraints in search
|
||||||
|
- Introduced 'validate' hook to post-process and filter fetched documents
|
||||||
|
- Refactored underlying execQuery function to support additional search options
|
||||||
|
- Updated tests to cover new search scenarios and fallback mechanisms
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.11.4 - fix(search)
|
||||||
|
Implement implicit AND logic for mixed simple term and field:value queries in search
|
||||||
|
|
||||||
|
- Added a new branch to detect and handle search queries that mix field:value pairs with plain terms without explicit operators
|
||||||
|
- Builds an implicit $and filter when query parts contain colon(s) but lack explicit boolean operators or quotes
|
||||||
|
- Ensures proper parsing and improved robustness of search filters
|
||||||
|
|
||||||
|
## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
|
||||||
|
Improve range query parsing in Lucene adapter and expand search test coverage
|
||||||
|
|
||||||
|
- Added a new 'testSearch' script in package.json to run search tests.
|
||||||
|
- Introduced advanced search tests for range queries and combined field filters in test/search.advanced.ts.
|
||||||
|
- Enhanced robustness tests in test/search.ts for wildcard and empty query scenarios.
|
||||||
|
- Fixed token validation in the parseRange method of the Lucene adapter to ensure proper error handling.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.2 - fix(readme)
|
||||||
|
Update readme to clarify usage of searchable fields retrieval
|
||||||
|
|
||||||
|
- Replaced getSearchableFields('Product') with Product.getSearchableFields()
|
||||||
|
- Updated documentation to reference the static method Class.getSearchableFields()
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.1 - fix(doc)
|
||||||
|
Refactor searchable fields API and improve collection registration.
|
||||||
|
|
||||||
|
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
||||||
|
- Updated tests to use the new static method (e.g., Product.getSearchableFields()).
|
||||||
|
- Ensured the Collection decorator attaches a docCtor property to correctly register searchable fields.
|
||||||
|
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
||||||
|
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
||||||
|
|
||||||
|
- Changed luceneWildcardToRegex from private to public in ts/classes.lucene.adapter.ts.
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.10.0 - feat(search)
|
||||||
|
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests
|
||||||
|
|
||||||
|
- Updated readme.md with detailed Lucene‑style search examples and use cases
|
||||||
|
- Enhanced LuceneToMongoTransformer to properly handle wildcard conversion and regex escaping
|
||||||
|
- Improved search query parsing in SmartDataDbDoc for field-specific, multi-term, and advanced Lucene syntax
|
||||||
|
- Added new advanced search tests covering boolean operators, grouping, quoted phrases, and wildcard queries
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.2 - fix(documentation)
|
||||||
|
Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior.
|
||||||
|
|
||||||
|
- Replaced 'searchWithLucene' examples with 'search(query)' in the README.
|
||||||
|
- Updated explanation to detail field-specific exact match, partial word regex search, multi-word literal matching, and handling of empty queries.
|
||||||
|
- Clarified guidelines for creating MongoDB text indexes on searchable fields for optimized search performance.
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.1 - fix(search)
|
||||||
|
Refactor search tests to use unified search API and update text index type casting
|
||||||
|
|
||||||
|
- Replaced all calls from searchWithLucene with search in test/search tests
|
||||||
|
- Updated text index specification in the collection class to use proper type casting
|
||||||
|
|
||||||
|
## 2025-04-18 - 5.9.0 - feat(collections/search)
|
||||||
|
Improve text index creation and search fallback mechanisms in collections and document search methods
|
||||||
|
|
||||||
|
- Auto-create a compound text index on all searchable fields in SmartdataCollection with a one-time flag to prevent duplicate index creation.
|
||||||
|
- Refine the search method in SmartDataDbDoc to support exact field matches and safe regex fallback for non-Lucene queries.
|
||||||
|
|
||||||
|
## 2025-04-17 - 5.8.4 - fix(core)
|
||||||
|
Update commit metadata with no functional code changes
|
||||||
|
|
||||||
|
- Commit info and documentation refreshed
|
||||||
|
- No code or test changes detected in the diff
|
||||||
|
|
||||||
|
## 2025-04-17 - 5.8.3 - fix(readme)
|
||||||
|
Improve readme documentation on data models and connection management
|
||||||
|
|
||||||
|
- Clarify that data models use @Collection, @unI, @svDb, @index, and @searchable decorators
|
||||||
|
- Document that ObjectId and Buffer fields are stored as BSON types natively without extra decorators
|
||||||
|
- Update connection management section to use 'db.close()' instead of 'db.disconnect()'
|
||||||
|
- Revise license section to reference the MIT License without including additional legal details
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.8.2 - fix(classes.doc.ts)
|
||||||
|
Ensure collection initialization before creating a cursor in getCursorExtended
|
||||||
|
|
||||||
|
- Added 'await collection.init()' to guarantee that the MongoDB collection is initialized before using the cursor
|
||||||
|
- Prevents potential runtime errors when accessing collection.mongoDbCollection
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.8.1 - fix(cursor, doc)
|
||||||
|
Add explicit return types and casts to SmartdataDbCursor methods and update getCursorExtended signature in SmartDataDbDoc.
|
||||||
|
|
||||||
|
- Specify Promise<T> as return type for next() in SmartdataDbCursor and cast return value to T.
|
||||||
|
- Specify Promise<T[]> as return type for toArray() in SmartdataDbCursor and cast return value to T[].
|
||||||
|
- Update getCursorExtended to return Promise<SmartdataDbCursor<T>> for clearer type safety.
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.8.0 - feat(cursor)
|
||||||
|
Add toArray method to SmartdataDbCursor to convert raw MongoDB documents into initialized class instances
|
||||||
|
|
||||||
|
- Introduced asynchronous toArray method in SmartdataDbCursor which retrieves all documents from the MongoDB cursor
|
||||||
|
- Maps each native document to a SmartDataDbDoc instance using createInstanceFromMongoDbNativeDoc for consistent API usage
|
||||||
|
|
||||||
|
## 2025-04-14 - 5.7.0 - feat(SmartDataDbDoc)
|
||||||
|
Add extended cursor method getCursorExtended for flexible cursor modifications
|
||||||
|
|
||||||
|
- Introduces getCursorExtended in classes.doc.ts to allow modifier functions for MongoDB cursors
|
||||||
|
- Wraps the modified cursor with SmartdataDbCursor for improved API consistency
|
||||||
|
- Enhances querying capabilities by enabling customized cursor transformations
|
||||||
|
|
||||||
|
## 2025-04-07 - 5.6.0 - feat(indexing)
|
||||||
|
Add support for regular index creation in documents and collections
|
||||||
|
|
||||||
|
- Implement new index decorator in classes.doc.ts to mark properties with regular indexing options
|
||||||
|
- Update SmartdataCollection to create regular indexes if defined on a document during insert
|
||||||
|
- Enhance document structure to store and utilize regular index configurations
|
||||||
|
|
||||||
## 2025-04-06 - 5.5.1 - fix(ci & formatting)
|
## 2025-04-06 - 5.5.1 - fix(ci & formatting)
|
||||||
Minor fixes: update CI workflow image and npmci package references, adjust package.json and readme URLs, and apply consistent code formatting.
|
Minor fixes: update CI workflow image and npmci package references, adjust package.json and readme URLs, and apply consistent code formatting.
|
||||||
|
|
||||||
|
77
codex.md
Normal file
77
codex.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# SmartData Project Overview
|
||||||
|
|
||||||
|
This document provides a high-level overview of the SmartData library (`@push.rocks/smartdata`), its architecture, core components, and key features—including recent enhancements to the search API.
|
||||||
|
|
||||||
|
## 1. Project Purpose
|
||||||
|
- A TypeScript‑first wrapper around MongoDB that supplies:
|
||||||
|
- Strongly‑typed document & collection classes
|
||||||
|
- Decorator‑based schema definition (no external schema files)
|
||||||
|
- Advanced search capabilities with Lucene‑style queries
|
||||||
|
- Built‑in support for real‑time data sync, distributed coordination, and key‑value EasyStore
|
||||||
|
|
||||||
|
## 2. Core Concepts & Components
|
||||||
|
- **SmartDataDb**: Manages the MongoDB connection, pooling, and initialization of collections.
|
||||||
|
- **SmartDataDbDoc**: Base class for all document models; provides CRUD, upsert, and cursor APIs.
|
||||||
|
- **Decorators**:
|
||||||
|
- `@Collection`: Associates a class with a MongoDB collection
|
||||||
|
- `@svDb()`: Marks a field as persisted to the DB
|
||||||
|
- `@unI()`: Marks a field as a unique index
|
||||||
|
- `@index()`: Adds a regular index
|
||||||
|
- `@searchable()`: Marks a field for inclusion in text searches or regex queries
|
||||||
|
- **SmartdataCollection**: Wraps a MongoDB collection; auto‑creates indexes based on decorators.
|
||||||
|
- **Lucene Adapter**: Parses a Lucene query string into an AST and transforms it to a MongoDB filter object.
|
||||||
|
- **EasyStore**: A simple, schema‑less key‑value store built on top of MongoDB for sharing ephemeral data.
|
||||||
|
- **Distributed Coordinator**: Leader election and task‑distribution API for building resilient, multi‑instance systems.
|
||||||
|
- **Watcher**: Listens to change streams for real‑time updates and integrates with RxJS.
|
||||||
|
|
||||||
|
## 3. Search API
|
||||||
|
SmartData provides a unified `.search(query[, opts])` method on all models with `@searchable()` fields:
|
||||||
|
|
||||||
|
- **Supported Syntax**:
|
||||||
|
1. Exact field:value (e.g. `field:Value`)
|
||||||
|
2. Quoted phrases (e.g. `"exact phrase"` or `'exact phrase'`)
|
||||||
|
3. Wildcards: `*` (zero or more chars) and `?` (single char)
|
||||||
|
4. Boolean operators: `AND`, `OR`, `NOT`
|
||||||
|
5. Grouping: parenthesis `(A OR B) AND C`
|
||||||
|
6. Range queries: `[num TO num]`, `{num TO num}`
|
||||||
|
7. Multi‑term unquoted: terms AND’d across all searchable fields
|
||||||
|
8. Empty query returns all documents
|
||||||
|
|
||||||
|
- **Fallback Mechanisms**:
|
||||||
|
1. Text index based `$text` search (if supported)
|
||||||
|
2. Field‑scoped and multi‑field regex queries
|
||||||
|
3. In‑memory filtering for complex or unsupported cases
|
||||||
|
|
||||||
|
### New Security & Extensibility Hooks
|
||||||
|
The `.search(query, opts?)` signature now accepts a `SearchOptions<T>` object:
|
||||||
|
```ts
|
||||||
|
interface SearchOptions<T> {
|
||||||
|
filter?: Record<string, any>; // Additional MongoDB filter AND‑merged
|
||||||
|
validate?: (doc: T) => boolean; // Post‑fetch hook to drop results
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **filter**: Enforces mandatory constraints (e.g. multi‑tenant isolation) directly in the Mongo query.
|
||||||
|
- **validate**: An async function that runs after fetching; return `false` to exclude a document.
|
||||||
|
|
||||||
|
## 4. Testing Strategy
|
||||||
|
- Unit tests in `test/test.search.ts` cover basic search functionality and new options:
|
||||||
|
- Exact, wildcard, phrase, boolean and grouping cases
|
||||||
|
- Implicit AND and mixed free‑term + field searches
|
||||||
|
- Edge cases (non‑searchable fields, quoted wildcards, no matches)
|
||||||
|
- `filter` and `validate` tests ensure security hooks work as intended
|
||||||
|
- Advanced search scenarios are covered in `test/test.search.advanced.ts`.
|
||||||
|
|
||||||
|
## 5. Usage Example
|
||||||
|
```ts
|
||||||
|
// Basic search
|
||||||
|
const prods = await Product.search('wireless earbuds');
|
||||||
|
|
||||||
|
// Scoped search (only your organization’s items)
|
||||||
|
const myItems = await Product.search('book', { filter: { ownerId } });
|
||||||
|
|
||||||
|
// Post‑search validation (only cheap items)
|
||||||
|
const cheapItems = await Product.search('', { validate: p => p.price < 50 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
Last updated: 2025-04-22
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartdata",
|
"name": "@push.rocks/smartdata",
|
||||||
"version": "5.5.1",
|
"version": "5.13.1",
|
||||||
"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",
|
||||||
@ -8,6 +8,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tstest test/",
|
"test": "tstest test/",
|
||||||
|
"testSearch": "tsx test/test.search.ts",
|
||||||
"build": "tsbuild --web --allowimplicitany",
|
"build": "tsbuild --web --allowimplicitany",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
|
107
readme.md
107
readme.md
@ -18,7 +18,7 @@ A powerful TypeScript-first MongoDB wrapper that provides advanced features for
|
|||||||
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
|
- **Enhanced Cursors**: Chainable, type-safe cursor API with memory efficient data processing
|
||||||
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
|
- **Type Conversion**: Automatic handling of MongoDB types like ObjectId and Binary data
|
||||||
- **Serialization Hooks**: Custom serialization and deserialization of document properties
|
- **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
|
- **Powerful Search Capabilities**: Unified `search(query)` API supporting field:value exact matches, multi-field regex searches, case-insensitive matching, and automatic escaping to prevent regex injection
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ await db.init();
|
|||||||
|
|
||||||
### Defining Data Models
|
### Defining Data Models
|
||||||
|
|
||||||
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, and `@svDb` to define your data models.
|
Data models in `@push.rocks/smartdata` are classes that represent collections and documents in your MongoDB database. Use decorators such as `@Collection`, `@unI`, `@svDb`, `@index`, and `@searchable` to define your data models. Fields of type `ObjectId` or `Buffer` decorated with `@svDb()` will be stored as BSON ObjectId and Binary, respectively; no separate `@oid()` or `@bin()` decorators are required.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
@ -74,8 +74,6 @@ import {
|
|||||||
Collection,
|
Collection,
|
||||||
unI,
|
unI,
|
||||||
svDb,
|
svDb,
|
||||||
oid,
|
|
||||||
bin,
|
|
||||||
index,
|
index,
|
||||||
searchable,
|
searchable,
|
||||||
} from '@push.rocks/smartdata';
|
} from '@push.rocks/smartdata';
|
||||||
@ -96,12 +94,10 @@ class User extends SmartDataDbDoc<User, User> {
|
|||||||
public email: string; // Mark 'email' to be saved in DB
|
public email: string; // Mark 'email' to be saved in DB
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@oid() // Automatically handle as ObjectId type
|
public organizationId: ObjectId; // Stored as BSON ObjectId
|
||||||
public organizationId: ObjectId; // Will be automatically converted to/from ObjectId
|
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
@bin() // Automatically handle as Binary data
|
public profilePicture: Buffer; // Stored as BSON Binary
|
||||||
public profilePicture: Buffer; // Will be automatically converted to/from Binary
|
|
||||||
|
|
||||||
@svDb({
|
@svDb({
|
||||||
serialize: (data) => JSON.stringify(data), // Custom serialization
|
serialize: (data) => JSON.stringify(data), // Custom serialization
|
||||||
@ -193,72 +189,61 @@ await user.delete(); // Delete the user from the database
|
|||||||
|
|
||||||
### Search Functionality
|
### Search Functionality
|
||||||
|
|
||||||
SmartData provides powerful search capabilities with a Lucene-like query syntax and robust fallback mechanisms:
|
SmartData provides powerful, Lucene‑style search capabilities with robust fallback mechanisms:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Define a model with searchable fields
|
// Define a model with searchable fields
|
||||||
@Collection(() => db)
|
@Collection(() => db)
|
||||||
class Product extends SmartDataDbDoc<Product, Product> {
|
class Product extends SmartDataDbDoc<Product, Product> {
|
||||||
@unI()
|
@unI() public id: string = 'product-id';
|
||||||
public id: string = 'product-id';
|
@svDb() @searchable() public name: string;
|
||||||
|
@svDb() @searchable() public description: string;
|
||||||
@svDb()
|
@svDb() @searchable() public category: string;
|
||||||
@searchable() // Mark this field as searchable
|
@svDb() public price: number;
|
||||||
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
|
// List searchable fields
|
||||||
const searchableFields = getSearchableFields('Product'); // ['name', 'description', 'category']
|
const searchableFields = Product.getSearchableFields();
|
||||||
|
|
||||||
// Basic search across all searchable fields
|
// 1: Exact phrase across all fields
|
||||||
const iPhoneProducts = await Product.searchWithLucene('iPhone');
|
await Product.search('"Kindle Paperwhite"');
|
||||||
|
|
||||||
// Field-specific search
|
// 2: Wildcard search across all fields
|
||||||
const electronicsProducts = await Product.searchWithLucene('category:Electronics');
|
await Product.search('Air*');
|
||||||
|
|
||||||
// Search with wildcards
|
// 3: Field‑scoped wildcard
|
||||||
const macProducts = await Product.searchWithLucene('Mac*');
|
await Product.search('name:Air*');
|
||||||
|
|
||||||
// Search in specific fields with partial words
|
// 4: Boolean AND/OR/NOT
|
||||||
const laptopResults = await Product.searchWithLucene('description:laptop');
|
await Product.search('category:Electronics AND name:iPhone');
|
||||||
|
|
||||||
// Search is case-insensitive
|
// 5: Grouping with parentheses
|
||||||
const results1 = await Product.searchWithLucene('electronics');
|
await Product.search('(Furniture OR Electronics) AND Chair');
|
||||||
const results2 = await Product.searchWithLucene('Electronics');
|
|
||||||
// results1 and results2 will contain the same documents
|
|
||||||
|
|
||||||
// Using boolean operators (requires text index in MongoDB)
|
// 6: Multi‑term unquoted (terms AND’d across fields)
|
||||||
const wirelessOrLaptop = await Product.searchWithLucene('wireless OR laptop');
|
await Product.search('TypeScript Aufgabe');
|
||||||
|
|
||||||
// Negative searches
|
// 7: Empty query returns all documents
|
||||||
const electronicsNotSamsung = await Product.searchWithLucene('Electronics NOT Samsung');
|
await Product.search('');
|
||||||
|
// 8: Scoped search with additional filter (e.g. multi-tenant isolation)
|
||||||
// Phrase searches
|
await Product.search('book', { filter: { ownerId: currentUserId } });
|
||||||
const exactPhrase = await Product.searchWithLucene('"high-speed blender"');
|
// 9: Post-search validation hook to drop unwanted results (e.g. price check)
|
||||||
|
await Product.search('', { validate: (p) => p.price < 100 });
|
||||||
// Grouping with parentheses
|
|
||||||
const complexQuery = await Product.searchWithLucene('(wireless OR bluetooth) AND Electronics');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The search functionality includes:
|
The search functionality includes:
|
||||||
|
|
||||||
- `@searchable()` decorator for marking fields as searchable
|
- `@searchable()` decorator for marking fields as searchable
|
||||||
- `getSearchableFields()` to retrieve all searchable fields for a class
|
- `Class.getSearchableFields()` static method to list searchable fields for a model
|
||||||
- `search()` method for basic search (requires MongoDB text index)
|
- `search(query: string)` method supporting:
|
||||||
- `searchWithLucene()` method with robust fallback mechanisms
|
- Exact phrase matches (`"my exact string"` or `'my exact string'`)
|
||||||
- Support for field-specific searches, wildcards, and boolean operators
|
- Field‑scoped exact & wildcard searches (`field:value`, `field:Air*`)
|
||||||
- Automatic fallback to regex-based search if MongoDB text search fails
|
- Wildcard searches across all fields (`Air*`, `?Pods`)
|
||||||
|
- Boolean operators (`AND`, `OR`, `NOT`) with grouping (`(...)`)
|
||||||
|
- Multi‑term unquoted queries AND’d across fields (`TypeScript Aufgabe`)
|
||||||
|
- Single/multi‑term regex searches across fields
|
||||||
|
- Empty queries returning all documents
|
||||||
|
- Automatic escaping & wildcard conversion to prevent regex injection
|
||||||
|
|
||||||
### EasyStore
|
### EasyStore
|
||||||
|
|
||||||
@ -541,7 +526,7 @@ class Order extends SmartDataDbDoc<Order, Order> {
|
|||||||
### Connection Management
|
### Connection Management
|
||||||
|
|
||||||
- Always call `db.init()` before using any database features
|
- Always call `db.init()` before using any database features
|
||||||
- Use `db.disconnect()` when shutting down your application
|
- Use `db.close()` when shutting down your application
|
||||||
- Set appropriate connection pool sizes based on your application's needs
|
- Set appropriate connection pool sizes based on your application's needs
|
||||||
|
|
||||||
### Document Design
|
### Document Design
|
||||||
@ -553,10 +538,10 @@ class Order extends SmartDataDbDoc<Order, Order> {
|
|||||||
|
|
||||||
### Search Optimization
|
### Search Optimization
|
||||||
|
|
||||||
- Create MongoDB text indexes for collections that need advanced search operations
|
- (Optional) Create MongoDB text indexes on searchable fields to speed up full-text search
|
||||||
- Use `searchWithLucene()` for robust searches with fallback mechanisms
|
- Use `search(query)` for all search operations (field:value, partial matches, multi-word)
|
||||||
- Prefer field-specific searches when possible for better performance
|
- Prefer field-specific exact matches when possible for optimal performance
|
||||||
- Use simple term queries instead of boolean operators if you don't have text indexes
|
- Avoid unnecessary complexity in query strings to keep regex searches efficient
|
||||||
|
|
||||||
### Performance Optimization
|
### Performance Optimization
|
||||||
|
|
||||||
@ -590,7 +575,7 @@ Please make sure to update tests as appropriate and follow our coding standards.
|
|||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository is licensed under the MIT License. For details, see [MIT License](https://opensource.org/licenses/MIT).
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
202
test/test.search.advanced.ts
Normal file
202
test/test.search.advanced.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import * as smartdata from '../ts/index.js';
|
||||||
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Set up database connection
|
||||||
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
// Define a test class for advanced search scenarios
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize DB and insert sample products
|
||||||
|
tap.test('setup advanced search database', async () => {
|
||||||
|
smartmongoInstance = await smartmongo.SmartMongo.createAndStart();
|
||||||
|
testDb = new smartdata.SmartdataDb(
|
||||||
|
await smartmongoInstance.getMongoDescriptor(),
|
||||||
|
);
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('insert products for advanced search', async () => {
|
||||||
|
const products = [
|
||||||
|
new Product(
|
||||||
|
'Night Owl Lamp',
|
||||||
|
'Bright lamp for night reading',
|
||||||
|
'Lighting',
|
||||||
|
29,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Day Light Lamp',
|
||||||
|
'Daytime lamp with adjustable brightness',
|
||||||
|
'Lighting',
|
||||||
|
39,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Office Chair',
|
||||||
|
'Ergonomic chair for office',
|
||||||
|
'Furniture',
|
||||||
|
199,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'Gaming Chair',
|
||||||
|
'Comfortable for long gaming sessions',
|
||||||
|
'Furniture',
|
||||||
|
299,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'iPhone 12',
|
||||||
|
'Latest iPhone with A14 Bionic chip',
|
||||||
|
'Electronics',
|
||||||
|
999,
|
||||||
|
),
|
||||||
|
new Product(
|
||||||
|
'AirPods',
|
||||||
|
'Wireless earbuds with noise cancellation',
|
||||||
|
'Electronics',
|
||||||
|
249,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const p of products) {
|
||||||
|
await p.save();
|
||||||
|
}
|
||||||
|
const all = await Product.getInstances({});
|
||||||
|
expect(all.length).toEqual(products.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple exact field:value matching
|
||||||
|
tap.test('simpleExact: category:Furniture returns chairs', async () => {
|
||||||
|
const res = await Product.search('category:Furniture');
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simpleExact invalid field should throw
|
||||||
|
tap.test('simpleExact invalid field errors', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('price:29');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quoted phrase search
|
||||||
|
tap.test('quoted phrase "Bright lamp" matches Night Owl Lamp', async () => {
|
||||||
|
const res = await Product.search('"Bright lamp"');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test("quoted phrase 'night reading' matches Night Owl Lamp", async () => {
|
||||||
|
const res = await Product.search("'night reading'");
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
tap.test('wildcard description:*gaming* matches Gaming Chair', async () => {
|
||||||
|
const res = await Product.search('description:*gaming*');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Gaming Chair');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Boolean AND and OR
|
||||||
|
tap.test('boolean AND: category:Lighting AND lamp', async () => {
|
||||||
|
const res = await Product.search('category:Lighting AND lamp');
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('boolean OR: Furniture OR Electronics', async () => {
|
||||||
|
const res = await Product.search('Furniture OR Electronics');
|
||||||
|
expect(res.length).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-term unquoted -> AND across terms
|
||||||
|
tap.test('multi-term unquoted adjustable brightness', async () => {
|
||||||
|
const res = await Product.search('adjustable brightness');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Day Light Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multi-term unquoted Night Lamp', async () => {
|
||||||
|
const res = await Product.search('Night Lamp');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Night Owl Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grouping with parentheses
|
||||||
|
tap.test('grouping: (Furniture OR Electronics) AND Chair', async () => {
|
||||||
|
const res = await Product.search(
|
||||||
|
'(Furniture OR Electronics) AND Chair',
|
||||||
|
);
|
||||||
|
expect(res.length).toEqual(2);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional range and combined query tests
|
||||||
|
tap.test('range query price:[30 TO 300] returns expected products', async () => {
|
||||||
|
const res = await Product.search('price:[30 TO 300]');
|
||||||
|
// Expect products with price between 30 and 300 inclusive: Day Light Lamp, Gaming Chair, Office Chair, AirPods
|
||||||
|
expect(res.length).toEqual(4);
|
||||||
|
const names = res.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['AirPods', 'Day Light Lamp', 'Gaming Chair', 'Office Chair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should filter category and price range', async () => {
|
||||||
|
const res = await Product.search('category:Lighting AND price:[30 TO 40]');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].name).toEqual('Day Light Lamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Teardown
|
||||||
|
tap.test('cleanup advanced search database', async () => {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
await testDb.close();
|
||||||
|
if (smartmongoInstance) {
|
||||||
|
await smartmongoInstance.stopAndDumpToDir(
|
||||||
|
`.nogit/dbdump/test.search.advanced.ts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => process.exit(), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({ throwOnError: true });
|
@ -4,11 +4,13 @@ import { smartunique } from '../ts/plugins.js';
|
|||||||
|
|
||||||
// Import the smartdata library
|
// Import the smartdata library
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
import { searchable, getSearchableFields } from '../ts/classes.doc.js';
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
|
|
||||||
// Set up database connection
|
// Set up database connection
|
||||||
let smartmongoInstance: smartmongo.SmartMongo;
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
let testDb: smartdata.SmartdataDb;
|
let testDb: smartdata.SmartdataDb;
|
||||||
|
// Class for location-based wildcard/phrase tests
|
||||||
|
let LocationDoc: any;
|
||||||
|
|
||||||
// Define a test class with searchable fields using the standard SmartDataDbDoc
|
// Define a test class with searchable fields using the standard SmartDataDbDoc
|
||||||
@smartdata.Collection(() => testDb)
|
@smartdata.Collection(() => testDb)
|
||||||
@ -72,7 +74,7 @@ tap.test('should create test products with searchable fields', async () => {
|
|||||||
|
|
||||||
tap.test('should retrieve searchable fields for a class', async () => {
|
tap.test('should retrieve searchable fields for a class', async () => {
|
||||||
// Use the getSearchableFields function to verify our searchable fields
|
// Use the getSearchableFields function to verify our searchable fields
|
||||||
const searchableFields = getSearchableFields('Product');
|
const searchableFields = Product.getSearchableFields();
|
||||||
console.log('Searchable fields:', searchableFields);
|
console.log('Searchable fields:', searchableFields);
|
||||||
|
|
||||||
expect(searchableFields.length).toEqual(3);
|
expect(searchableFields.length).toEqual(3);
|
||||||
@ -104,21 +106,21 @@ tap.test('should search products by basic search method', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should search products with searchWithLucene method', async () => {
|
tap.test('should search products with search method', async () => {
|
||||||
// Using the robust searchWithLucene method
|
// Using the robust searchWithLucene method
|
||||||
const wirelessResults = await Product.searchWithLucene('wireless');
|
const wirelessResults = await Product.search('wireless');
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${wirelessResults.length} products matching 'wireless' using searchWithLucene`,
|
`Found ${wirelessResults.length} products matching 'wireless' using search`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wirelessResults.length).toEqual(1);
|
expect(wirelessResults.length).toEqual(1);
|
||||||
expect(wirelessResults[0].name).toEqual('AirPods');
|
expect(wirelessResults[0].name).toEqual('AirPods');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should search products by category with searchWithLucene', async () => {
|
tap.test('should search products by category with search', async () => {
|
||||||
// Using field-specific search with searchWithLucene
|
// Using field-specific search with searchWithLucene
|
||||||
const kitchenResults = await Product.searchWithLucene('category:Kitchen');
|
const kitchenResults = await Product.search('category:Kitchen');
|
||||||
console.log(`Found ${kitchenResults.length} products in Kitchen category using searchWithLucene`);
|
console.log(`Found ${kitchenResults.length} products in Kitchen category using search`);
|
||||||
|
|
||||||
expect(kitchenResults.length).toEqual(2);
|
expect(kitchenResults.length).toEqual(2);
|
||||||
expect(kitchenResults[0].category).toEqual('Kitchen');
|
expect(kitchenResults[0].category).toEqual('Kitchen');
|
||||||
@ -127,7 +129,7 @@ tap.test('should search products by category with searchWithLucene', async () =>
|
|||||||
|
|
||||||
tap.test('should search products with partial word matches', async () => {
|
tap.test('should search products with partial word matches', async () => {
|
||||||
// Testing partial word matches
|
// Testing partial word matches
|
||||||
const proResults = await Product.searchWithLucene('Pro');
|
const proResults = await Product.search('Pro');
|
||||||
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
console.log(`Found ${proResults.length} products matching 'Pro'`);
|
||||||
|
|
||||||
// Should match both "MacBook Pro" and "professionals" in description
|
// Should match both "MacBook Pro" and "professionals" in description
|
||||||
@ -136,7 +138,7 @@ tap.test('should search products with partial word matches', async () => {
|
|||||||
|
|
||||||
tap.test('should search across multiple searchable fields', async () => {
|
tap.test('should search across multiple searchable fields', async () => {
|
||||||
// Test searching across all searchable fields
|
// Test searching across all searchable fields
|
||||||
const bookResults = await Product.searchWithLucene('book');
|
const bookResults = await Product.search('book');
|
||||||
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
console.log(`Found ${bookResults.length} products matching 'book' across all fields`);
|
||||||
|
|
||||||
// Should match "MacBook" in name and "Books" in category
|
// Should match "MacBook" in name and "Books" in category
|
||||||
@ -145,8 +147,8 @@ tap.test('should search across multiple searchable fields', async () => {
|
|||||||
|
|
||||||
tap.test('should handle case insensitive searches', async () => {
|
tap.test('should handle case insensitive searches', async () => {
|
||||||
// Test case insensitivity
|
// Test case insensitivity
|
||||||
const electronicsResults = await Product.searchWithLucene('electronics');
|
const electronicsResults = await Product.search('electronics');
|
||||||
const ElectronicsResults = await Product.searchWithLucene('Electronics');
|
const ElectronicsResults = await Product.search('Electronics');
|
||||||
|
|
||||||
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
console.log(`Found ${electronicsResults.length} products matching lowercase 'electronics'`);
|
||||||
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
console.log(`Found ${ElectronicsResults.length} products matching capitalized 'Electronics'`);
|
||||||
@ -166,14 +168,14 @@ tap.test('should demonstrate search fallback mechanisms', async () => {
|
|||||||
|
|
||||||
// Use a simpler term that should be found in descriptions
|
// Use a simpler term that should be found in descriptions
|
||||||
// Avoid using "OR" operator which requires a text index
|
// Avoid using "OR" operator which requires a text index
|
||||||
const results = await Product.searchWithLucene('high');
|
const results = await Product.search('high');
|
||||||
console.log(`Found ${results.length} products matching 'high'`);
|
console.log(`Found ${results.length} products matching 'high'`);
|
||||||
|
|
||||||
// "High-speed blender" contains "high"
|
// "High-speed blender" contains "high"
|
||||||
expect(results.length).toBeGreaterThan(0);
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Try another fallback example that won't need $text
|
// Try another fallback example that won't need $text
|
||||||
const powerfulResults = await Product.searchWithLucene('powerful');
|
const powerfulResults = await Product.search('powerful');
|
||||||
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
console.log(`Found ${powerfulResults.length} products matching 'powerful'`);
|
||||||
|
|
||||||
// "Powerful laptop for professionals" contains "powerful"
|
// "Powerful laptop for professionals" contains "powerful"
|
||||||
@ -192,6 +194,208 @@ tap.test('should explain the advantages of the integrated approach', async () =>
|
|||||||
expect(true).toEqual(true);
|
expect(true).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Additional robustness tests
|
||||||
|
tap.test('should search exact name using field:value', async () => {
|
||||||
|
const nameResults = await Product.search('name:AirPods');
|
||||||
|
expect(nameResults.length).toEqual(1);
|
||||||
|
expect(nameResults[0].name).toEqual('AirPods');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw when searching non-searchable field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('price:129');
|
||||||
|
} catch (err) {
|
||||||
|
error = err as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('empty query should return all products', async () => {
|
||||||
|
const allResults = await Product.search('');
|
||||||
|
expect(allResults.length).toEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search multi-word term across fields', async () => {
|
||||||
|
const termResults = await Product.search('iPhone 12');
|
||||||
|
expect(termResults.length).toEqual(1);
|
||||||
|
expect(termResults[0].name).toEqual('iPhone 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional search scenarios
|
||||||
|
tap.test('should return zero results for non-existent terms', async () => {
|
||||||
|
const noResults = await Product.search('NonexistentTerm');
|
||||||
|
expect(noResults.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search products by description term "noise"', async () => {
|
||||||
|
const noiseResults = await Product.search('noise');
|
||||||
|
expect(noiseResults.length).toEqual(1);
|
||||||
|
expect(noiseResults[0].name).toEqual('AirPods');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search products by description term "flagship"', async () => {
|
||||||
|
const flagshipResults = await Product.search('flagship');
|
||||||
|
expect(flagshipResults.length).toEqual(1);
|
||||||
|
expect(flagshipResults[0].name).toEqual('Galaxy S21');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search numeric strings "12"', async () => {
|
||||||
|
const twelveResults = await Product.search('12');
|
||||||
|
expect(twelveResults.length).toEqual(1);
|
||||||
|
expect(twelveResults[0].name).toEqual('iPhone 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search hyphenated terms "high-speed"', async () => {
|
||||||
|
const hyphenResults = await Product.search('high-speed');
|
||||||
|
expect(hyphenResults.length).toEqual(1);
|
||||||
|
expect(hyphenResults[0].name).toEqual('Blender');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should search hyphenated terms "E-reader"', async () => {
|
||||||
|
const ereaderResults = await Product.search('E-reader');
|
||||||
|
expect(ereaderResults.length).toEqual(1);
|
||||||
|
expect(ereaderResults[0].name).toEqual('Kindle Paperwhite');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional robustness tests
|
||||||
|
tap.test('should return all products for empty search', async () => {
|
||||||
|
const searchResults = await Product.search('');
|
||||||
|
const allProducts = await Product.getInstances({});
|
||||||
|
expect(searchResults.length).toEqual(allProducts.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support wildcard plain term across all fields', async () => {
|
||||||
|
const results = await Product.search('*book*');
|
||||||
|
const names = results.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Harry Potter', 'Kindle Paperwhite', 'MacBook Pro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support wildcard plain term with question mark pattern', async () => {
|
||||||
|
const results = await Product.search('?one?');
|
||||||
|
const names = results.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(['Galaxy S21', 'iPhone 12']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter and Validation tests
|
||||||
|
tap.test('should apply filter option to restrict results', async () => {
|
||||||
|
// search term 'book' across all fields but restrict to Books category
|
||||||
|
const bookFiltered = await Product.search('book', { filter: { category: 'Books' } });
|
||||||
|
expect(bookFiltered.length).toEqual(2);
|
||||||
|
bookFiltered.forEach((p) => expect(p.category).toEqual('Books'));
|
||||||
|
});
|
||||||
|
tap.test('should apply validate hook to post-filter results', async () => {
|
||||||
|
// return only products with price > 500
|
||||||
|
const expensive = await Product.search('', { validate: (p) => p.price > 500 });
|
||||||
|
expect(expensive.length).toBeGreaterThan(0);
|
||||||
|
expensive.forEach((p) => expect(p.price).toBeGreaterThan(500));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for quoted and wildcard field-specific phrases
|
||||||
|
tap.test('setup location test products', async () => {
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class LD extends smartdata.SmartDataDbDoc<LD, LD> {
|
||||||
|
@smartdata.unI() public id: string = smartunique.shortId();
|
||||||
|
@smartdata.svDb() @searchable() public location: string;
|
||||||
|
constructor(loc: string) { super(); this.location = loc; }
|
||||||
|
}
|
||||||
|
// Assign to outer variable for subsequent tests
|
||||||
|
LocationDoc = LD;
|
||||||
|
const locations = ['Berlin', 'Frankfurt am Main', 'Frankfurt am Oder', 'London'];
|
||||||
|
for (const loc of locations) {
|
||||||
|
await new LocationDoc(loc).save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tap.test('should search exact quoted field phrase', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt am Main"');
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].location).toEqual('Frankfurt am Main');
|
||||||
|
});
|
||||||
|
tap.test('should search wildcard quoted field phrase', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt am *"');
|
||||||
|
const names = results.map((d: any) => d.location).sort();
|
||||||
|
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
tap.test('should search unquoted wildcard field', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:Frankfurt*');
|
||||||
|
const names = results.map((d: any) => d.location).sort();
|
||||||
|
expect(names).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combined free-term + field phrase/wildcard tests
|
||||||
|
let CombinedDoc: any;
|
||||||
|
tap.test('setup combined docs for free-term and location tests', async () => {
|
||||||
|
@smartdata.Collection(() => testDb)
|
||||||
|
class CD extends smartdata.SmartDataDbDoc<CD, CD> {
|
||||||
|
@smartdata.unI() public id: string = smartunique.shortId();
|
||||||
|
@smartdata.svDb() @searchable() public name: string;
|
||||||
|
@smartdata.svDb() @searchable() public location: string;
|
||||||
|
constructor(name: string, location: string) { super(); this.name = name; this.location = location; }
|
||||||
|
}
|
||||||
|
CombinedDoc = CD;
|
||||||
|
const docs = [
|
||||||
|
new CombinedDoc('TypeScript', 'Berlin'),
|
||||||
|
new CombinedDoc('TypeScript', 'Frankfurt am Main'),
|
||||||
|
new CombinedDoc('TypeScript', 'Frankfurt am Oder'),
|
||||||
|
new CombinedDoc('JavaScript', 'Berlin'),
|
||||||
|
];
|
||||||
|
for (const d of docs) await d.save();
|
||||||
|
});
|
||||||
|
tap.test('should search free term and exact quoted field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Berlin"');
|
||||||
|
expect(res.length).toEqual(1);
|
||||||
|
expect(res[0].location).toEqual('Berlin');
|
||||||
|
});
|
||||||
|
tap.test('should not match free term with non-matching quoted field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Frankfurt d"');
|
||||||
|
expect(res.length).toEqual(0);
|
||||||
|
});
|
||||||
|
tap.test('should search free term with quoted wildcard field phrase', async () => {
|
||||||
|
const res = await CombinedDoc.search('TypeScript location:"Frankfurt am *"');
|
||||||
|
const locs = res.map((r: any) => r.location).sort();
|
||||||
|
expect(locs).toEqual(['Frankfurt am Main', 'Frankfurt am Oder']);
|
||||||
|
});
|
||||||
|
// Quoted exact field phrase without wildcard should return no matches if no exact match
|
||||||
|
tap.test('should not match location:"Frankfurt d"', async () => {
|
||||||
|
const results = await (LocationDoc as any).search('location:"Frankfurt d"');
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combined free-term and field wildcard tests
|
||||||
|
tap.test('should combine free term and wildcard field search', async () => {
|
||||||
|
const results = await Product.search('book category:Book*');
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
results.forEach((p) => expect(p.category).toEqual('Books'));
|
||||||
|
});
|
||||||
|
tap.test('should not match when free term matches but wildcard field does not', async () => {
|
||||||
|
const results = await Product.search('book category:Kitchen*');
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-searchable field should cause an error for combined queries
|
||||||
|
tap.test('should throw when combining term with non-searchable field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('book location:Berlin');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
tap.test('should throw when combining term with non-searchable wildcard field', async () => {
|
||||||
|
let error: Error;
|
||||||
|
try {
|
||||||
|
await Product.search('book location:Berlin*');
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(error.message).toMatch(/not searchable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
tap.test('close database connection', async () => {
|
tap.test('close database connection', async () => {
|
||||||
await testDb.mongoDb.dropDatabase();
|
await testDb.mongoDb.dropDatabase();
|
||||||
await testDb.close();
|
await testDb.close();
|
||||||
|
@ -64,7 +64,11 @@ tap.test('should watch a collection', async (toolsArg) => {
|
|||||||
// close the database connection
|
// close the database connection
|
||||||
// =======================================
|
// =======================================
|
||||||
tap.test('close', async () => {
|
tap.test('close', async () => {
|
||||||
await testDb.mongoDb.dropDatabase();
|
try {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('dropDatabase error ignored in cleanup:', err.message || err);
|
||||||
|
}
|
||||||
await testDb.close();
|
await testDb.close();
|
||||||
if (smartmongoInstance) {
|
if (smartmongoInstance) {
|
||||||
await smartmongoInstance.stop();
|
await smartmongoInstance.stop();
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartdata',
|
name: '@push.rocks/smartdata',
|
||||||
version: '5.5.1',
|
version: '5.13.1',
|
||||||
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,7 +1,7 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { SmartdataDb } from './classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||||
import { SmartDataDbDoc } from './classes.doc.js';
|
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||||
|
|
||||||
@ -32,13 +32,22 @@ export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
|||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
// Attach document constructor for searchableFields lookup
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
public get collection() {
|
public get collection() {
|
||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return decoratedClass;
|
return decoratedClass;
|
||||||
@ -127,6 +136,9 @@ export class SmartdataCollection<T> {
|
|||||||
public collectionName: string;
|
public collectionName: string;
|
||||||
public smartdataDb: SmartdataDb;
|
public smartdataDb: SmartdataDb;
|
||||||
public uniqueIndexes: string[] = [];
|
public uniqueIndexes: string[] = [];
|
||||||
|
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
||||||
|
// flag to ensure text index is created only once
|
||||||
|
private textIndexCreated: boolean = false;
|
||||||
|
|
||||||
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
|
constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
|
||||||
// tell the collection where it belongs
|
// tell the collection where it belongs
|
||||||
@ -152,6 +164,18 @@ export class SmartdataCollection<T> {
|
|||||||
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
console.log(`Successfully initiated Collection ${this.collectionName}`);
|
||||||
}
|
}
|
||||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||||
|
// Auto-create a compound text index on all searchable fields
|
||||||
|
// Use document constructor's searchableFields registered via decorator
|
||||||
|
const docCtor = (this as any).docCtor;
|
||||||
|
const searchableFields: string[] = docCtor?.searchableFields || [];
|
||||||
|
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
||||||
|
// Build a compound text index spec
|
||||||
|
const indexSpec: Record<string, 'text'> = {};
|
||||||
|
searchableFields.forEach(f => { indexSpec[f] = 'text'; });
|
||||||
|
// Cast to any to satisfy TypeScript IndexSpecification typing
|
||||||
|
await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
|
||||||
|
this.textIndexCreated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,6 +194,24 @@ export class SmartdataCollection<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates regular indexes for the collection
|
||||||
|
*/
|
||||||
|
public createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
|
||||||
|
for (const indexDef of indexesArg) {
|
||||||
|
// Check if we've already created this index
|
||||||
|
const indexKey = indexDef.field;
|
||||||
|
if (!this.regularIndexes.some(i => i.field === indexKey)) {
|
||||||
|
this.mongoDbCollection.createIndex(
|
||||||
|
{ [indexDef.field]: 1 }, // Simple single-field index
|
||||||
|
indexDef.options
|
||||||
|
);
|
||||||
|
// Track that we've created this index
|
||||||
|
this.regularIndexes.push(indexDef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* adds a validation function that all newly inserted and updated objects have to pass
|
* adds a validation function that all newly inserted and updated objects have to pass
|
||||||
*/
|
*/
|
||||||
@ -238,6 +280,12 @@ export class SmartdataCollection<T> {
|
|||||||
await this.init();
|
await this.init();
|
||||||
await this.checkDoc(dbDocArg);
|
await this.checkDoc(dbDocArg);
|
||||||
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
this.markUniqueIndexes(dbDocArg.uniqueIndexes);
|
||||||
|
|
||||||
|
// Create regular indexes if available
|
||||||
|
if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
|
||||||
|
this.createRegularIndexes(dbDocArg.regularIndexes);
|
||||||
|
}
|
||||||
|
|
||||||
const saveableObject = await dbDocArg.createSavableObject();
|
const saveableObject = await dbDocArg.createSavableObject();
|
||||||
const result = await this.mongoDbCollection.insertOne(saveableObject);
|
const result = await this.mongoDbCollection.insertOne(saveableObject);
|
||||||
return result;
|
return result;
|
||||||
@ -295,4 +343,4 @@ export class SmartdataCollection<T> {
|
|||||||
}
|
}
|
||||||
return done.promise;
|
return done.promise;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -15,14 +15,14 @@ export class SmartdataDbCursor<T = any> {
|
|||||||
this.smartdataDbDoc = dbDocArg;
|
this.smartdataDbDoc = dbDocArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async next(closeAtEnd = true) {
|
public async next(closeAtEnd = true): Promise<T> {
|
||||||
const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(
|
const result = this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(
|
||||||
await this.mongodbCursor.next(),
|
await this.mongodbCursor.next(),
|
||||||
);
|
);
|
||||||
if (!result && closeAtEnd) {
|
if (!result && closeAtEnd) {
|
||||||
await this.close();
|
await this.close();
|
||||||
}
|
}
|
||||||
return result;
|
return result as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) {
|
public async forEach(forEachFuncArg: (itemArg: T) => Promise<any>, closeCursorAtEnd = true) {
|
||||||
@ -40,6 +40,11 @@ export class SmartdataDbCursor<T = any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async toArray(): Promise<T[]> {
|
||||||
|
const result = await this.mongodbCursor.toArray();
|
||||||
|
return result.map((itemArg) => this.smartdataDbDoc.createInstanceFromMongoDbNativeDoc(itemArg)) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
public async close() {
|
public async close() {
|
||||||
await this.mongodbCursor.close();
|
await this.mongodbCursor.close();
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,19 @@ import { SmartdataDbCursor } from './classes.cursor.js';
|
|||||||
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
import { type IManager, SmartdataCollection } from './classes.collection.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
||||||
|
/**
|
||||||
|
* Search options for `.search()`:
|
||||||
|
* - filter: additional MongoDB query to AND-merge
|
||||||
|
* - validate: post-fetch validator, return true to keep a doc
|
||||||
|
*/
|
||||||
|
export interface SearchOptions<T> {
|
||||||
|
filter?: Record<string, any>;
|
||||||
|
validate?: (doc: T) => Promise<boolean> | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
export function globalSvDb() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
@ -39,27 +47,18 @@ export function svDb() {
|
|||||||
*/
|
*/
|
||||||
export function searchable() {
|
export function searchable() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called searchable() on >${target.constructor.name}.${key}<`);
|
// Attach to class constructor for direct access
|
||||||
|
const ctor = target.constructor as any;
|
||||||
// Initialize the set for this class if it doesn't exist
|
if (!Array.isArray(ctor.searchableFields)) {
|
||||||
const className = target.constructor.name;
|
ctor.searchableFields = [];
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
searchableFieldsMap.set(className, new Set<string>());
|
|
||||||
}
|
}
|
||||||
|
ctor.searchableFields.push(key);
|
||||||
// Add the property to the searchable fields set
|
|
||||||
searchableFieldsMap.get(className).add(key);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Escape user input for safe use in MongoDB regular expressions
|
||||||
* Get searchable fields for a class
|
function escapeForRegex(input: string): string {
|
||||||
*/
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
export function getSearchableFields(className: string): string[] {
|
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.from(searchableFieldsMap.get(className));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,6 +82,46 @@ export function unI() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for MongoDB indexes
|
||||||
|
*/
|
||||||
|
export interface IIndexOptions {
|
||||||
|
background?: boolean;
|
||||||
|
unique?: boolean;
|
||||||
|
sparse?: boolean;
|
||||||
|
expireAfterSeconds?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* index - decorator to mark a field for regular indexing
|
||||||
|
*/
|
||||||
|
export function index(options?: IIndexOptions) {
|
||||||
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
|
console.log(`called index() on >${target.constructor.name}.${key}<`);
|
||||||
|
|
||||||
|
// Initialize regular indexes array if it doesn't exist
|
||||||
|
if (!target.regularIndexes) {
|
||||||
|
target.regularIndexes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this field to regularIndexes with its options
|
||||||
|
target.regularIndexes.push({
|
||||||
|
field: key,
|
||||||
|
options: options || {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also ensure it's marked as saveable
|
||||||
|
if (!target.saveableProperties) {
|
||||||
|
target.saveableProperties = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.saveableProperties.includes(key)) {
|
||||||
|
target.saveableProperties.push(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
||||||
// Special case: detect MongoDB operators and pass them through directly
|
// Special case: detect MongoDB operators and pass them through directly
|
||||||
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
|
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
|
||||||
@ -209,6 +248,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getCursorExtended<T>(
|
||||||
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
|
filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
|
||||||
|
modifierFunction = (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => cursorArg,
|
||||||
|
): Promise<SmartdataDbCursor<T>> {
|
||||||
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
||||||
|
await collection.init();
|
||||||
|
let cursor: plugins.mongodb.FindCursor<any> = collection.mongoDbCollection.find(
|
||||||
|
convertFilterForMongoDb(filterArg),
|
||||||
|
);
|
||||||
|
cursor = modifierFunction(cursor);
|
||||||
|
return new SmartdataDbCursor<T>(cursor, this as any as typeof SmartDataDbDoc);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* watch the collection
|
* watch the collection
|
||||||
* @param this
|
* @param this
|
||||||
@ -260,118 +313,184 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
luceneQuery: string,
|
luceneQuery: string,
|
||||||
): any {
|
): any {
|
||||||
const className = (this as any).className || this.name;
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
|
|
||||||
if (searchableFields.length === 0) {
|
if (searchableFields.length === 0) {
|
||||||
throw new Error(`No searchable fields defined for class ${className}`);
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
||||||
return adapter.convert(luceneQuery);
|
return adapter.convert(luceneQuery);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* List all searchable fields defined on this class
|
||||||
|
*/
|
||||||
|
public static getSearchableFields(): string[] {
|
||||||
|
const ctor = this as any;
|
||||||
|
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Execute a query with optional hard filter and post-fetch validation
|
||||||
|
*/
|
||||||
|
private static async execQuery<T>(
|
||||||
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
|
baseFilter: Record<string, any>,
|
||||||
|
opts?: SearchOptions<T>
|
||||||
|
): Promise<T[]> {
|
||||||
|
let mongoFilter = baseFilter || {};
|
||||||
|
if (opts?.filter) {
|
||||||
|
mongoFilter = { $and: [mongoFilter, opts.filter] };
|
||||||
|
}
|
||||||
|
let docs: T[] = await (this as any).getInstances(mongoFilter);
|
||||||
|
if (opts?.validate) {
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (await opts.validate(d)) out.push(d);
|
||||||
|
}
|
||||||
|
docs = out;
|
||||||
|
}
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search documents using Lucene query syntax
|
* Search documents by text or field:value syntax, with safe regex fallback
|
||||||
* @param luceneQuery Lucene query string
|
* Supports additional filtering and post-fetch validation via opts
|
||||||
|
* @param query A search term or field:value expression
|
||||||
|
* @param opts Optional filter and validate hooks
|
||||||
* @returns Array of matching documents
|
* @returns Array of matching documents
|
||||||
*/
|
*/
|
||||||
public static async search<T>(
|
public static async search<T>(
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
luceneQuery: string,
|
query: string,
|
||||||
|
opts?: SearchOptions<T>,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const filter = (this as any).createSearchFilter(luceneQuery);
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
return await (this as any).getInstances(filter);
|
if (searchableFields.length === 0) {
|
||||||
}
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}
|
// empty query -> return all
|
||||||
|
const q = query.trim();
|
||||||
/**
|
if (!q) {
|
||||||
* Search by text across all searchable fields (fallback method)
|
// empty query: fetch all, apply opts
|
||||||
* @param searchText The text to search for in all searchable fields
|
return await (this as any).execQuery({}, opts);
|
||||||
* @returns Array of matching documents
|
}
|
||||||
*/
|
// simple exact field:value (no spaces, no wildcards, no quotes)
|
||||||
private static async searchByTextAcrossFields<T>(
|
// simple exact field:value (no spaces, wildcards, quotes)
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
||||||
searchText: string,
|
if (simpleExact) {
|
||||||
): Promise<T[]> {
|
const field = simpleExact[1];
|
||||||
try {
|
const value = simpleExact[2];
|
||||||
const className = (this as any).className || this.name;
|
if (!searchableFields.includes(field)) {
|
||||||
const searchableFields = getSearchableFields(className);
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// simple field:value search
|
||||||
// Last resort: get all and filter in memory
|
return await (this as any).execQuery({ [field]: value }, opts);
|
||||||
const allDocs = await (this as any).getInstances({});
|
}
|
||||||
const lowerSearchText = searchText.toLowerCase();
|
// quoted phrase across all searchable fields: exact match of phrase
|
||||||
|
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
|
||||||
return allDocs.filter((doc: any) => {
|
if (quoted) {
|
||||||
for (const field of searchableFields) {
|
const phrase = quoted[1] || quoted[2] || '';
|
||||||
const value = doc[field];
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||||||
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
|
const pattern = parts.join('\\s+');
|
||||||
return true;
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
|
}
|
||||||
|
// wildcard field:value (supports * and ?) -> direct regex on that field
|
||||||
|
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
|
||||||
|
if (wildcardField) {
|
||||||
|
const field = wildcardField[1];
|
||||||
|
// Support quoted wildcard patterns: strip surrounding quotes
|
||||||
|
let pattern = wildcardField[2];
|
||||||
|
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
|
||||||
|
(pattern.startsWith("'") && pattern.endsWith("'"))) {
|
||||||
|
pattern = pattern.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
}
|
||||||
|
// escape regex special chars except * and ?, then convert wildcards
|
||||||
|
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
|
||||||
|
}
|
||||||
|
// wildcard plain term across all fields (supports * and ?)
|
||||||
|
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
|
||||||
|
// build wildcard regex pattern: escape all except * and ? then convert
|
||||||
|
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
|
}
|
||||||
|
// implicit AND for multiple tokens: free terms, quoted phrases, and field:values
|
||||||
|
{
|
||||||
|
// Split query into tokens, preserving quoted substrings
|
||||||
|
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
||||||
|
// Only apply when more than one token and no boolean operators or grouping
|
||||||
|
if (
|
||||||
|
rawTokens.length > 1 &&
|
||||||
|
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
|
||||||
|
!/\[|\]/.test(q)
|
||||||
|
) {
|
||||||
|
const andConds: any[] = [];
|
||||||
|
for (let token of rawTokens) {
|
||||||
|
// field:value token
|
||||||
|
const fv = token.match(/^(\w+):(.+)$/);
|
||||||
|
if (fv) {
|
||||||
|
const field = fv[1];
|
||||||
|
let value = fv[2];
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
}
|
||||||
|
// Strip surrounding quotes if present
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
// Wildcard search?
|
||||||
|
if (value.includes('*') || value.includes('?')) {
|
||||||
|
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
|
||||||
|
} else {
|
||||||
|
andConds.push({ [field]: value });
|
||||||
|
}
|
||||||
|
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||||
|
// Quoted free phrase across all fields
|
||||||
|
const phrase = token.slice(1, -1);
|
||||||
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||||||
|
const pattern = parts.join('\\s+');
|
||||||
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
|
||||||
|
} else {
|
||||||
|
// Free term across all fields
|
||||||
|
const esc = escapeForRegex(token);
|
||||||
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
||||||
});
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
|
||||||
|
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
|
||||||
|
if (luceneSyntax.test(q)) {
|
||||||
|
const filter = (this as any).createSearchFilter(q);
|
||||||
|
return await (this as any).execQuery(filter, opts);
|
||||||
|
}
|
||||||
|
// multi-term unquoted -> AND of regex across fields for each term
|
||||||
|
const terms = q.split(/\s+/);
|
||||||
|
if (terms.length > 1) {
|
||||||
|
const andConds = terms.map((term) => {
|
||||||
|
const esc = escapeForRegex(term);
|
||||||
|
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
||||||
|
return { $or: ors };
|
||||||
|
});
|
||||||
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
||||||
|
}
|
||||||
|
// single term -> regex across all searchable fields
|
||||||
|
const esc = escapeForRegex(q);
|
||||||
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
||||||
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// INSTANCE
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -401,6 +520,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
*/
|
*/
|
||||||
public uniqueIndexes: string[];
|
public uniqueIndexes: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regular indexes with their options
|
||||||
|
*/
|
||||||
|
public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* an array of saveable properties of a specific doc
|
* an array of saveable properties of a specific doc
|
||||||
*/
|
*/
|
||||||
@ -503,4 +627,4 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
}
|
}
|
||||||
return identifiableObject;
|
return identifiableObject;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -290,11 +290,11 @@ export class LuceneParser {
|
|||||||
const includeLower = this.tokens[this.pos] === '[';
|
const includeLower = this.tokens[this.pos] === '[';
|
||||||
const includeUpper = this.tokens[this.pos + 4] === ']';
|
const includeUpper = this.tokens[this.pos + 4] === ']';
|
||||||
|
|
||||||
this.pos++; // Skip open bracket
|
// Ensure tokens for lower, TO, upper, and closing bracket exist
|
||||||
|
|
||||||
if (this.pos + 4 >= this.tokens.length) {
|
if (this.pos + 4 >= this.tokens.length) {
|
||||||
throw new Error('Invalid range query syntax');
|
throw new Error('Invalid range query syntax');
|
||||||
}
|
}
|
||||||
|
this.pos++; // Skip open bracket
|
||||||
|
|
||||||
const lower = this.tokens[this.pos];
|
const lower = this.tokens[this.pos];
|
||||||
this.pos++;
|
this.pos++;
|
||||||
@ -329,7 +329,16 @@ export class LuceneParser {
|
|||||||
* FIXED VERSION - proper MongoDB query structure
|
* FIXED VERSION - proper MongoDB query structure
|
||||||
*/
|
*/
|
||||||
export class LuceneToMongoTransformer {
|
export class LuceneToMongoTransformer {
|
||||||
constructor() {}
|
private defaultFields: string[];
|
||||||
|
constructor(defaultFields: string[] = []) {
|
||||||
|
this.defaultFields = defaultFields;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Escape special characters for use in RegExp patterns
|
||||||
|
*/
|
||||||
|
private escapeRegex(input: string): string {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a Lucene AST node to a MongoDB query
|
* Transform a Lucene AST node to a MongoDB query
|
||||||
@ -366,18 +375,21 @@ export class LuceneToMongoTransformer {
|
|||||||
* FIXED: properly structured $or query for multiple fields
|
* FIXED: properly structured $or query for multiple fields
|
||||||
*/
|
*/
|
||||||
private transformTerm(node: TermNode, searchFields?: string[]): any {
|
private transformTerm(node: TermNode, searchFields?: string[]): any {
|
||||||
// If specific fields are provided, search across those fields
|
// Build regex pattern, support wildcard (*) and fuzzy (?) if present
|
||||||
if (searchFields && searchFields.length > 0) {
|
const term = node.value;
|
||||||
// Create an $or query to search across multiple fields
|
// Determine regex pattern: wildcard conversion or exact escape
|
||||||
const orConditions = searchFields.map((field) => ({
|
let pattern: string;
|
||||||
[field]: { $regex: node.value, $options: 'i' },
|
if (term.includes('*') || term.includes('?')) {
|
||||||
}));
|
pattern = this.luceneWildcardToRegex(term);
|
||||||
|
} else {
|
||||||
return { $or: orConditions };
|
pattern = this.escapeRegex(term);
|
||||||
}
|
}
|
||||||
|
// Search across provided fields or default fields
|
||||||
// Otherwise, use text search (requires a text index on desired fields)
|
const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
|
||||||
return { $text: { $search: node.value } };
|
const orConditions = fields.map((field) => ({
|
||||||
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
|
}));
|
||||||
|
return { $or: orConditions };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -385,17 +397,14 @@ export class LuceneToMongoTransformer {
|
|||||||
* FIXED: properly structured $or query for multiple fields
|
* FIXED: properly structured $or query for multiple fields
|
||||||
*/
|
*/
|
||||||
private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
|
private transformPhrase(node: PhraseNode, searchFields?: string[]): any {
|
||||||
// If specific fields are provided, search phrase across those fields
|
// Use regex across provided fields or default fields, respecting word boundaries
|
||||||
if (searchFields && searchFields.length > 0) {
|
const parts = node.value.split(/\s+/).map((t) => this.escapeRegex(t));
|
||||||
const orConditions = searchFields.map((field) => ({
|
const pattern = parts.join('\\s+');
|
||||||
[field]: { $regex: `${node.value.replace(/\s+/g, '\\s+')}`, $options: 'i' },
|
const fields = searchFields && searchFields.length > 0 ? searchFields : this.defaultFields;
|
||||||
}));
|
const orConditions = fields.map((field) => ({
|
||||||
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
return { $or: orConditions };
|
}));
|
||||||
}
|
return { $or: orConditions };
|
||||||
|
|
||||||
// For phrases, we use a regex to ensure exact matches
|
|
||||||
return { $text: { $search: `"${node.value}"` } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -429,9 +438,14 @@ export class LuceneToMongoTransformer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for exact term matches on fields
|
// Special case for exact term matches on fields (supporting wildcard characters)
|
||||||
if (node.value.type === 'TERM') {
|
if (node.value.type === 'TERM') {
|
||||||
return { [node.field]: { $regex: (node.value as TermNode).value, $options: 'i' } };
|
const val = (node.value as TermNode).value;
|
||||||
|
if (val.includes('*') || val.includes('?')) {
|
||||||
|
const regex = this.luceneWildcardToRegex(val);
|
||||||
|
return { [node.field]: { $regex: regex, $options: 'i' } };
|
||||||
|
}
|
||||||
|
return { [node.field]: { $regex: val, $options: 'i' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for phrase matches on fields
|
// Special case for phrase matches on fields
|
||||||
@ -626,7 +640,7 @@ export class LuceneToMongoTransformer {
|
|||||||
/**
|
/**
|
||||||
* Convert Lucene wildcards to MongoDB regex patterns
|
* Convert Lucene wildcards to MongoDB regex patterns
|
||||||
*/
|
*/
|
||||||
private luceneWildcardToRegex(wildcardPattern: string): string {
|
public luceneWildcardToRegex(wildcardPattern: string): string {
|
||||||
// Replace Lucene wildcards with regex equivalents
|
// Replace Lucene wildcards with regex equivalents
|
||||||
// * => .*
|
// * => .*
|
||||||
// ? => .
|
// ? => .
|
||||||
@ -691,7 +705,8 @@ export class SmartdataLuceneAdapter {
|
|||||||
*/
|
*/
|
||||||
constructor(defaultSearchFields?: string[]) {
|
constructor(defaultSearchFields?: string[]) {
|
||||||
this.parser = new LuceneParser();
|
this.parser = new LuceneParser();
|
||||||
this.transformer = new LuceneToMongoTransformer();
|
// Pass default searchable fields into transformer
|
||||||
|
this.transformer = new LuceneToMongoTransformer(defaultSearchFields || []);
|
||||||
if (defaultSearchFields) {
|
if (defaultSearchFields) {
|
||||||
this.defaultSearchFields = defaultSearchFields;
|
this.defaultSearchFields = defaultSearchFields;
|
||||||
}
|
}
|
||||||
@ -704,7 +719,7 @@ export class SmartdataLuceneAdapter {
|
|||||||
*/
|
*/
|
||||||
convert(luceneQuery: string, searchFields?: string[]): any {
|
convert(luceneQuery: string, searchFields?: string[]): any {
|
||||||
try {
|
try {
|
||||||
// For simple single term queries, create a simpler query structure
|
// For simple single-term queries (no field:, boolean, grouping), use simpler regex
|
||||||
if (
|
if (
|
||||||
!luceneQuery.includes(':') &&
|
!luceneQuery.includes(':') &&
|
||||||
!luceneQuery.includes(' AND ') &&
|
!luceneQuery.includes(' AND ') &&
|
||||||
@ -713,13 +728,17 @@ export class SmartdataLuceneAdapter {
|
|||||||
!luceneQuery.includes('(') &&
|
!luceneQuery.includes('(') &&
|
||||||
!luceneQuery.includes('[')
|
!luceneQuery.includes('[')
|
||||||
) {
|
) {
|
||||||
// This is a simple term, use a more direct approach
|
|
||||||
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
const fieldsToSearch = searchFields || this.defaultSearchFields;
|
||||||
|
|
||||||
if (fieldsToSearch && fieldsToSearch.length > 0) {
|
if (fieldsToSearch && fieldsToSearch.length > 0) {
|
||||||
|
// Handle wildcard characters in query
|
||||||
|
let pattern = luceneQuery;
|
||||||
|
if (luceneQuery.includes('*') || luceneQuery.includes('?')) {
|
||||||
|
// Use transformer to convert wildcard pattern
|
||||||
|
pattern = this.transformer.luceneWildcardToRegex(luceneQuery);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
$or: fieldsToSearch.map((field) => ({
|
$or: fieldsToSearch.map((field) => ({
|
||||||
[field]: { $regex: luceneQuery, $options: 'i' },
|
[field]: { $regex: pattern, $options: 'i' },
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user