Compare commits

...

12 Commits

Author SHA1 Message Date
0ca1d452b4 5.12.0
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Successful in 3m9s
Default (tags) / release (push) Failing after 53s
Default (tags) / metadata (push) Successful in 58s
2025-04-22 19:13:17 +00:00
436311ab06 feat(doc/search): Enhance search functionality with filter and validate options for advanced query control 2025-04-22 19:13:17 +00:00
498f586ddb 5.11.4
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Successful in 3m8s
Default (tags) / release (push) Failing after 52s
Default (tags) / metadata (push) Successful in 1m1s
2025-04-22 18:36:47 +00:00
6c50bd23ec fix(search): Implement implicit AND logic for mixed simple term and field:value queries in search 2025-04-22 18:36:47 +00:00
419eb163f4 5.11.3
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Successful in 3m6s
Default (tags) / release (push) Failing after 51s
Default (tags) / metadata (push) Successful in 57s
2025-04-22 18:24:27 +00:00
75aeb12e81 fix(lucene adapter and search tests): Improve range query parsing in Lucene adapter and expand search test coverage 2025-04-22 18:24:26 +00:00
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
10 changed files with 219 additions and 102 deletions

View File

@ -1,5 +1,47 @@
# Changelog # Changelog
## 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) ## 2025-04-21 - 5.10.0 - feat(search)
Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests Improve search functionality: update documentation, refine Lucene query transformation, and add advanced search tests

View File

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

View File

@ -203,7 +203,7 @@ class Product extends SmartDataDbDoc<Product, Product> {
} }
// List searchable fields // List searchable fields
const searchableFields = getSearchableFields('Product'); const searchableFields = Product.getSearchableFields();
// 1: Exact phrase across all fields // 1: Exact phrase across all fields
await Product.search('"Kindle Paperwhite"'); await Product.search('"Kindle Paperwhite"');
@ -225,12 +225,16 @@ await Product.search('TypeScript Aufgabe');
// 7: Empty query returns all documents // 7: Empty query returns all documents
await Product.search(''); await Product.search('');
// 8: Scoped search with additional filter (e.g. multi-tenant isolation)
await Product.search('book', { filter: { ownerId: currentUserId } });
// 9: Post-search validation hook to drop unwanted results (e.g. price check)
await Product.search('', { validate: (p) => p.price < 100 });
``` ```
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:
- Exact phrase matches (`"my exact string"` or `'my exact string'`) - Exact phrase matches (`"my exact string"` or `'my exact string'`)
- Fieldscoped exact & wildcard searches (`field:value`, `field:Air*`) - Fieldscoped exact & wildcard searches (`field:value`, `field:Air*`)

View File

@ -1,7 +1,7 @@
import { tap, expect } from '@push.rocks/tapbundle'; import { tap, expect } from '@push.rocks/tapbundle';
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartmongo from '@push.rocks/smartmongo';
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';
import { smartunique } from '../ts/plugins.js'; import { smartunique } from '../ts/plugins.js';
// Set up database connection // Set up database connection
@ -172,6 +172,21 @@ tap.test('grouping: (Furniture OR Electronics) AND Chair', async () => {
expect(names).toEqual(['Gaming Chair', 'Office Chair']); 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 // Teardown
tap.test('cleanup advanced search database', async () => { tap.test('cleanup advanced search database', async () => {
await testDb.mongoDb.dropDatabase(); await testDb.mongoDb.dropDatabase();

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);
@ -257,6 +257,40 @@ tap.test('should search hyphenated terms "E-reader"', async () => {
expect(ereaderResults[0].name).toEqual('Kindle Paperwhite'); 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));
});
// 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();

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.10.0', version: '5.12.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.'
} }

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

@ -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,55 +313,85 @@ 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}`);
} }
// empty query -> return all // empty query -> return all
const q = query.trim(); const q = query.trim();
if (!q) { if (!q) {
return await (this as any).getInstances({}); // empty query: fetch all, apply opts
return await (this as any).execQuery({}, opts);
} }
// simple exact field:value (no spaces, no wildcards, no quotes) // simple exact field:value (no spaces, no wildcards, no quotes)
// simple exact field:value (no spaces, wildcards, quotes)
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/); const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
if (simpleExact) { if (simpleExact) {
const field = simpleExact[1]; const field = simpleExact[1];
const value = simpleExact[2]; 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);
} }
// quoted phrase across all searchable fields: exact match of phrase // quoted phrase across all searchable fields: exact match of phrase
const quoted = q.match(/^"(.+)"$|^'(.+)'$/); const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
if (quoted) { if (quoted) {
const phrase = quoted[1] || quoted[2] || ''; const phrase = quoted[1] || quoted[2] || '';
// build regex that matches the exact phrase (allowing flexible whitespace)
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t)); const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
const pattern = parts.join('\\s+'); const pattern = parts.join('\\s+');
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })); const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
return await (this as any).getInstances({ $or: orConds }); return await (this as any).execQuery({ $or: orConds }, opts);
} }
// wildcard field:value (supports * and ?) -> direct regex on that field // wildcard field:value (supports * and ?) -> direct regex on that field
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/); const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
@ -374,12 +399,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const field = wildcardField[1]; const field = wildcardField[1];
const pattern = wildcardField[2]; const pattern = wildcardField[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}`);
} }
// escape regex special chars except * and ?, then convert wildcards // escape regex special chars except * and ?, then convert wildcards
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1'); const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
return await (this as any).getInstances({ [field]: { $regex: regexPattern, $options: 'i' } }); return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
} }
// wildcard plain term across all fields (supports * and ?) // wildcard plain term across all fields (supports * and ?)
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) { if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
@ -387,13 +412,41 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1'); const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })); const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
return await (this as any).getInstances({ $or: orConds }); return await (this as any).execQuery({ $or: orConds }, opts);
}
// implicit AND for mixed simple term + field:value queries (no explicit operators)
const parts = q.split(/\s+/);
const hasColon = parts.some((t) => t.includes(':'));
// implicit AND for mixed simple term + field:value queries (no explicit operators or range syntax)
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("'") &&
!q.includes('*') && !q.includes('?')
) {
const andConds = parts.map((term) => {
const m = term.match(/^(\\w+):([^"'\\*\\?\\s]+)$/);
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}`);
}
return { [field]: value };
} else {
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);
} }
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping // detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/; const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
if (luceneSyntax.test(q)) { if (luceneSyntax.test(q)) {
const filter = (this as any).createSearchFilter(q); const filter = (this as any).createSearchFilter(q);
return await (this as any).getInstances(filter); return await (this as any).execQuery(filter, opts);
} }
// multi-term unquoted -> AND of regex across fields for each term // multi-term unquoted -> AND of regex across fields for each term
const terms = q.split(/\s+/); const terms = q.split(/\s+/);
@ -403,63 +456,16 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })); const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return { $or: ors }; return { $or: ors };
}); });
return await (this as any).getInstances({ $and: andConds }); return await (this as any).execQuery({ $and: andConds }, opts);
} }
// single term -> regex across all searchable fields // single term -> regex across all searchable fields
const esc = escapeForRegex(q); const esc = escapeForRegex(q);
const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })); const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
return await (this as any).getInstances({ $or: orConds }); return await (this as any).execQuery({ $or: orConds }, opts);
} }
/** // INSTANCE
* Search by text across all searchable fields (fallback method)
* @param searchText The text to search for in all searchable fields
* @returns Array of matching documents
*/
private static async searchByTextAcrossFields<T>(
this: plugins.tsclass.typeFest.Class<T>,
searchText: string,
): Promise<T[]> {
try {
const className = (this as any).className || this.name;
const searchableFields = getSearchableFields(className);
// Fallback to direct filter if we have searchable fields
if (searchableFields.length > 0) {
// Create a simple $or query with regex for each field
const orConditions = searchableFields.map((field) => ({
[field]: { $regex: searchText, $options: 'i' },
}));
const filter = { $or: orConditions };
try {
// Try with MongoDB filter first
return await (this as any).getInstances(filter);
} catch (error) {
console.warn('MongoDB filter failed, falling back to in-memory search');
}
}
// Last resort: get all and filter in memory
const allDocs = await (this as any).getInstances({});
const lowerSearchText = searchText.toLowerCase();
return allDocs.filter((doc: any) => {
for (const field of searchableFields) {
const value = doc[field];
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
return true;
}
}
return false;
});
} catch (error) {
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
return [];
}
}
// INSTANCE // INSTANCE

View File

@ -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++;
@ -640,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
// * => .* // * => .*
// ? => . // ? => .