Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
39d2957b7d | |||
490524516e | |||
ccd4b9e1ec | |||
9c6d6d9f2c | |||
e4d787096e | |||
2bf923b4f1 | |||
0ca1d452b4 | |||
436311ab06 | |||
498f586ddb | |||
6c50bd23ec | |||
419eb163f4 | |||
75aeb12e81 | |||
c5a44da975 | |||
969b073939 | |||
ac80f90ae0 | |||
d0e769622e | |||
eef758cabb | |||
d0cc2a0ed2 | |||
87c930121c | |||
23b499b3a8 | |||
0834ec5c91 | |||
6a2a708ea1 |
79
changelog.md
79
changelog.md
@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2025-04-18 - 5.9.1 - fix(search)
|
||||||
Refactor search tests to use unified search API and update text index type casting
|
Refactor search tests to use unified search API and update text index type casting
|
||||||
|
|
||||||
|
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.9.1",
|
"version": "5.13.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",
|
||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
93
readme.md
93
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
|
||||||
|
|
||||||
@ -189,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
|
||||||
|
|
||||||
@ -549,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
|
||||||
|
|
||||||
|
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);
|
||||||
@ -221,6 +223,179 @@ tap.test('should search multi-word term across fields', async () => {
|
|||||||
expect(termResults[0].name).toEqual('iPhone 12');
|
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.9.1',
|
version: '5.13.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,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, type IIndexOptions, getSearchableFields } 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;
|
||||||
@ -156,7 +165,9 @@ export class SmartdataCollection<T> {
|
|||||||
}
|
}
|
||||||
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
|
// Auto-create a compound text index on all searchable fields
|
||||||
const searchableFields = getSearchableFields(this.collectionName);
|
// 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) {
|
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
||||||
// Build a compound text index spec
|
// Build a compound text index spec
|
||||||
const indexSpec: Record<string, 'text'> = {};
|
const indexSpec: Record<string, 'text'> = {};
|
||||||
|
@ -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,28 +47,15 @@ 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);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get searchable fields for a class
|
|
||||||
*/
|
|
||||||
export function getSearchableFields(className: string): string[] {
|
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.from(searchableFieldsMap.get(className));
|
|
||||||
}
|
|
||||||
// Escape user input for safe use in MongoDB regular expressions
|
// Escape user input for safe use in MongoDB regular expressions
|
||||||
function escapeForRegex(input: string): string {
|
function escapeForRegex(input: string): string {
|
||||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@ -318,99 +313,194 @@ 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 by text or field:value syntax, with safe regex fallback
|
* Search documents by text or field:value syntax, with safe regex fallback
|
||||||
|
* Supports additional filtering and post-fetch validation via opts
|
||||||
* @param query A search term or field:value expression
|
* @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>,
|
||||||
query: string,
|
query: string,
|
||||||
|
opts?: SearchOptions<T>,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
// field:value exact match (case-sensitive for non-regex fields)
|
// empty query -> return all
|
||||||
const fv = query.match(/^(\w+):(.+)$/);
|
const q = query.trim();
|
||||||
if (fv) {
|
if (!q) {
|
||||||
const field = fv[1];
|
// empty query: fetch all, apply opts
|
||||||
const value = fv[2];
|
return await (this as any).execQuery({}, opts);
|
||||||
|
}
|
||||||
|
// simple exact field:value (no spaces, no wildcards, no quotes)
|
||||||
|
// simple exact field:value (no spaces, wildcards, quotes)
|
||||||
|
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
||||||
|
if (simpleExact) {
|
||||||
|
const field = simpleExact[1];
|
||||||
|
const value = simpleExact[2];
|
||||||
if (!searchableFields.includes(field)) {
|
if (!searchableFields.includes(field)) {
|
||||||
throw new Error(`Field '${field}' is not searchable for class ${className}`);
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
}
|
}
|
||||||
return await (this as any).getInstances({ [field]: value });
|
// simple field:value search
|
||||||
|
return await (this as any).execQuery({ [field]: value }, opts);
|
||||||
}
|
}
|
||||||
// safe regex across all searchable fields (case-insensitive)
|
// quoted phrase across all searchable fields: exact match of phrase
|
||||||
const escaped = escapeForRegex(query);
|
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
|
||||||
const orConditions = searchableFields.map((field) => ({
|
if (quoted) {
|
||||||
[field]: { $regex: escaped, $options: 'i' },
|
const phrase = quoted[1] || quoted[2] || '';
|
||||||
}));
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
||||||
return await (this as any).getInstances({ $or: orConditions });
|
const pattern = parts.join('\\s+');
|
||||||
}
|
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
|
||||||
* Search by text across all searchable fields (fallback method)
|
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
|
||||||
* @param searchText The text to search for in all searchable fields
|
if (wildcardField) {
|
||||||
* @returns Array of matching documents
|
const field = wildcardField[1];
|
||||||
*/
|
// Support quoted wildcard patterns: strip surrounding quotes
|
||||||
private static async searchByTextAcrossFields<T>(
|
let pattern = wildcardField[2];
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
|
||||||
searchText: string,
|
(pattern.startsWith("'") && pattern.endsWith("'"))) {
|
||||||
): Promise<T[]> {
|
pattern = pattern.slice(1, -1);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
// Last resort: get all and filter in memory
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
const allDocs = await (this as any).getInstances({});
|
}
|
||||||
const lowerSearchText = searchText.toLowerCase();
|
// escape regex special chars except * and ?, then convert wildcards
|
||||||
|
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
return allDocs.filter((doc: any) => {
|
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
for (const field of searchableFields) {
|
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
|
||||||
const value = doc[field];
|
}
|
||||||
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
|
// wildcard plain term across all fields (supports * and ?)
|
||||||
return true;
|
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: combine free terms and field:value terms (with or without wildcards)
|
||||||
|
const parts = q.split(/\s+/);
|
||||||
|
const hasColon = parts.some((t) => t.includes(':'));
|
||||||
|
if (
|
||||||
|
parts.length > 1 && hasColon &&
|
||||||
|
!q.includes(' AND ') && !q.includes(' OR ') && !q.includes(' NOT ') &&
|
||||||
|
!q.includes('(') && !q.includes(')') &&
|
||||||
|
!q.includes('[') && !q.includes(']') &&
|
||||||
|
!q.includes('"') && !q.includes("'")
|
||||||
|
) {
|
||||||
|
const andConds = parts.map((term) => {
|
||||||
|
const m = term.match(/^(\w+):(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
const field = m[1];
|
||||||
|
const value = m[2];
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
}
|
}
|
||||||
|
if (value.includes('*') || value.includes('?')) {
|
||||||
|
// wildcard field search
|
||||||
|
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
return { [field]: { $regex: pattern, $options: 'i' } };
|
||||||
|
}
|
||||||
|
// exact field:value
|
||||||
|
return { [field]: value };
|
||||||
}
|
}
|
||||||
return false;
|
// free term -> regex across all searchable fields
|
||||||
|
const esc = escapeForRegex(term);
|
||||||
|
return { $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
||||||
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// free term and quoted field phrase (exact or wildcard), e.g. 'term field:"phrase"' or 'term field:"ph*se"'
|
||||||
|
const freeWithQuotedField = q.match(/^(\S+)\s+(\w+):"(.+)"$/);
|
||||||
|
if (freeWithQuotedField) {
|
||||||
|
const freeTerm = freeWithQuotedField[1];
|
||||||
|
const field = freeWithQuotedField[2];
|
||||||
|
let phrase = freeWithQuotedField[3];
|
||||||
|
if (!searchableFields.includes(field)) {
|
||||||
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
|
}
|
||||||
|
// free term condition across all searchable fields
|
||||||
|
const freeEsc = escapeForRegex(freeTerm);
|
||||||
|
const freeCond = { $or: searchableFields.map((f) => ({ [f]: { $regex: freeEsc, $options: 'i' } })) };
|
||||||
|
// field condition: exact match or wildcard pattern
|
||||||
|
let fieldCond;
|
||||||
|
if (phrase.includes('*') || phrase.includes('?')) {
|
||||||
|
const escaped = phrase.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
fieldCond = { [field]: { $regex: pattern, $options: 'i' } };
|
||||||
|
} else {
|
||||||
|
fieldCond = { [field]: phrase };
|
||||||
|
}
|
||||||
|
return await (this as any).execQuery({ $and: [freeCond, fieldCond] }, opts);
|
||||||
|
}
|
||||||
|
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
|
||||||
|
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
|
||||||
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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