Compare commits

..

8 Commits

Author SHA1 Message Date
c5a44da975 5.11.2
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Successful in 3m0s
Default (tags) / release (push) Failing after 52s
Default (tags) / metadata (push) Successful in 58s
2025-04-21 17:31:30 +00:00
969b073939 fix(readme): Update readme to clarify usage of searchable fields retrieval 2025-04-21 17:31:30 +00:00
ac80f90ae0 5.11.1
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Successful in 3m5s
Default (tags) / release (push) Failing after 51s
Default (tags) / metadata (push) Successful in 1m0s
2025-04-21 16:35:29 +00:00
d0e769622e fix(doc): Refactor searchable fields API and improve collection registration. 2025-04-21 16:35:29 +00:00
eef758cabb 5.11.0
Some checks failed
Default (tags) / security (push) Successful in 23s
Default (tags) / test (push) Successful in 3m1s
Default (tags) / release (push) Failing after 52s
Default (tags) / metadata (push) Successful in 59s
2025-04-21 15:29:11 +00:00
d0cc2a0ed2 feat(ts/classes.lucene.adapter): Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities. 2025-04-21 15:29:11 +00:00
87c930121c 5.10.0
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 3m25s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-21 15:27:55 +00:00
23b499b3a8 feat(search): Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests 2025-04-21 15:27:55 +00:00
10 changed files with 438 additions and 164 deletions

View File

@ -1,5 +1,32 @@
# Changelog # Changelog
## 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 Lucenestyle 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) ## 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. Update search API documentation to replace deprecated searchWithLucene examples with the unified search(query) API and clarify its behavior.

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartdata", "name": "@push.rocks/smartdata",
"version": "5.9.2", "version": "5.11.2",
"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",

View File

@ -189,60 +189,57 @@ 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, Lucenestyle 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.search('iPhone'); await Product.search('"Kindle Paperwhite"');
// Field-specific exact match // 2: Wildcard search across all fields
const electronicsProducts = await Product.search('category:Electronics'); await Product.search('Air*');
// Partial word search (regex across all fields) // 3: Fieldscoped wildcard
const laptopResults = await Product.search('laptop'); await Product.search('name:Air*');
// Multi-word literal search // 4: Boolean AND/OR/NOT
const paperwhite = await Product.search('Kindle Paperwhite'); await Product.search('category:Electronics AND name:iPhone');
// Empty query returns all documents // 5: Grouping with parentheses
const allProducts = await Product.search(''); await Product.search('(Furniture OR Electronics) AND Chair');
// 6: Multiterm unquoted (terms ANDd across fields)
await Product.search('TypeScript Aufgabe');
// 7: Empty query returns all documents
await Product.search('');
``` ```
The search functionality includes: The search functionality includes:
- `@searchable()` decorator for marking fields as searchable - `@searchable()` decorator for marking fields as searchable
- `getSearchableFields()` to list searchable fields for a model - `Class.getSearchableFields()` static method to list searchable fields for a model
- `search(query: string)` method supporting: - `search(query: string)` method supporting:
- Field-specific exact matches (`field:value`) - Exact phrase matches (`"my exact string"` or `'my exact string'`)
- Case-insensitive partial matches across all searchable fields - Fieldscoped exact & wildcard searches (`field:value`, `field:Air*`)
- Multi-word literal matching - Wildcard searches across all fields (`Air*`, `?Pods`)
- Boolean operators (`AND`, `OR`, `NOT`) with grouping (`(...)`)
- Multiterm unquoted queries ANDd across fields (`TypeScript Aufgabe`)
- Single/multiterm regex searches across fields
- Empty queries returning all documents - Empty queries returning all documents
- Automatic escaping of special characters to prevent regex injection - Automatic escaping & wildcard conversion to prevent regex injection
### EasyStore ### EasyStore

View File

@ -0,0 +1,187 @@
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']);
});
// 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 });

View File

@ -4,7 +4,7 @@ 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;
@ -72,7 +72,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 +221,42 @@ 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');
});
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();

View File

@ -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 () => {
try {
await testDb.mongoDb.dropDatabase(); 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();

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartdata', name: '@push.rocks/smartdata',
version: '5.9.2', version: '5.11.2',
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.'
} }

View File

@ -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'> = {};

View File

@ -8,8 +8,7 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
export type TDocCreation = 'db' | 'new' | 'mixed'; export type TDocCreation = 'db' | 'new' | 'mixed';
// Set of searchable fields for each class
const searchableFieldsMap = new Map<string, Set<string>>();
export function globalSvDb() { export function globalSvDb() {
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => { return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
@ -39,28 +38,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,16 +304,20 @@ 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 : [];
}
/** /**
* Search documents by text or field:value syntax, with safe regex fallback * Search documents by text or field:value syntax, with safe regex fallback
@ -338,79 +328,82 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
this: plugins.tsclass.typeFest.Class<T>, this: plugins.tsclass.typeFest.Class<T>,
query: string, query: string,
): 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]; return await (this as any).getInstances({});
const value = fv[2]; }
// 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 }); return await (this as any).getInstances({ [field]: value });
} }
// 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] || '';
})); // build regex that matches the exact phrase (allowing flexible whitespace)
return await (this as any).getInstances({ $or: orConditions }); const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
const pattern = parts.join('\\s+');
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
return await (this as any).getInstances({ $or: orConds });
} }
// wildcard field:value (supports * and ?) -> direct regex on that field
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
/** if (wildcardField) {
* Search by text across all searchable fields (fallback method) const field = wildcardField[1];
* @param searchText The text to search for in all searchable fields const pattern = wildcardField[2];
* @returns Array of matching documents if (!searchableFields.includes(field)) {
*/ throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
private static async searchByTextAcrossFields<T>( }
this: plugins.tsclass.typeFest.Class<T>, // escape regex special chars except * and ?, then convert wildcards
searchText: string, const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
): Promise<T[]> { const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
try { return await (this as any).getInstances({ [field]: { $regex: regexPattern, $options: 'i' } });
const className = (this as any).className || this.name; }
const searchableFields = getSearchableFields(className); // wildcard plain term across all fields (supports * and ?)
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
// Fallback to direct filter if we have searchable fields // build wildcard regex pattern: escape all except * and ? then convert
if (searchableFields.length > 0) { const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
// Create a simple $or query with regex for each field const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
const orConditions = searchableFields.map((field) => ({ const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
[field]: { $regex: searchText, $options: 'i' }, return await (this as any).getInstances({ $or: orConds });
})); }
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
const filter = { $or: orConditions }; const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
if (luceneSyntax.test(q)) {
try { const filter = (this as any).createSearchFilter(q);
// Try with MongoDB filter first
return await (this as any).getInstances(filter); return await (this as any).getInstances(filter);
} catch (error) {
console.warn('MongoDB filter failed, falling back to in-memory search');
} }
} // multi-term unquoted -> AND of regex across fields for each term
const terms = q.split(/\s+/);
// Last resort: get all and filter in memory if (terms.length > 1) {
const allDocs = await (this as any).getInstances({}); const andConds = terms.map((term) => {
const lowerSearchText = searchText.toLowerCase(); const esc = escapeForRegex(term);
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return allDocs.filter((doc: any) => { return { $or: ors };
for (const field of searchableFields) {
const value = doc[field];
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
return true;
}
}
return false;
}); });
} catch (error) { return await (this as any).getInstances({ $and: andConds });
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
return [];
} }
// 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).getInstances({ $or: orConds });
} }
// INSTANCE
// INSTANCE // INSTANCE
/** /**

View File

@ -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,19 +397,16 @@ 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}"` } };
}
/** /**
* Transform a field query to MongoDB query * Transform a field query to MongoDB query
*/ */
@ -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' },
})), })),
}; };
} }