Compare commits

..

4 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
6 changed files with 107 additions and 11 deletions

View File

@ -1,5 +1,20 @@
# 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) ## 2025-04-22 - 5.11.3 - fix(lucene adapter and search tests)
Improve range query parsing in Lucene adapter and expand search test coverage Improve range query parsing in Lucene adapter and expand search test coverage

View File

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

View File

@ -225,6 +225,10 @@ 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:

View File

@ -276,6 +276,20 @@ tap.test('should support wildcard plain term with question mark pattern', async
expect(names).toEqual(['Galaxy S21', 'iPhone 12']); 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 // Close database connection
tap.test('close database connection', async () => { tap.test('close database connection', async () => {
await testDb.mongoDb.dropDatabase(); await testDb.mongoDb.dropDatabase();

View File

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

@ -5,6 +5,15 @@ 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';
@ -318,15 +327,40 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
const ctor = this as any; const ctor = this as any;
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : []; 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 searchableFields = (this as any).getSearchableFields(); const searchableFields = (this as any).getSearchableFields();
if (searchableFields.length === 0) { if (searchableFields.length === 0) {
@ -335,7 +369,8 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
// 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) // simple exact field:value (no spaces, wildcards, quotes)
@ -346,17 +381,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
if (!searchableFields.includes(field)) { if (!searchableFields.includes(field)) {
throw new Error(`Field '${field}' is not searchable for class ${this.name}`); 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+):(.+[*?].*)$/);
@ -369,7 +404,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
// 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('?'))) {
@ -377,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+/);
@ -393,12 +456,12 @@ 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);
} }