Compare commits

..

8 Commits

Author SHA1 Message Date
ccd4b9e1ec 5.12.2
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Successful in 3m7s
Default (tags) / release (push) Failing after 52s
Default (tags) / metadata (push) Successful in 1m2s
2025-04-22 20:09:21 +00:00
9c6d6d9f2c fix(search): Fix handling of quoted wildcard patterns in field-specific search queries and add tests for location-based wildcard phrase searches 2025-04-22 20:09:21 +00:00
e4d787096e 5.12.1
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Successful in 3m7s
Default (tags) / release (push) Failing after 52s
Default (tags) / metadata (push) Successful in 1m0s
2025-04-22 19:37:50 +00:00
2bf923b4f1 fix(search): Improve implicit AND logic for mixed free term and field queries in search and enhance wildcard field handling. 2025-04-22 19:37:50 +00:00
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 198 additions and 12 deletions

View File

@ -1,5 +1,34 @@
# Changelog # Changelog
## 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) ## 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.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

@ -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

@ -9,6 +9,8 @@ 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)
@ -276,6 +278,84 @@ 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));
});
// 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 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 // 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.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

@ -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,30 +381,35 @@ 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+):(.+[*?].*)$/);
if (wildcardField) { if (wildcardField) {
const field = wildcardField[1]; const field = wildcardField[1];
const pattern = wildcardField[2]; // Support quoted wildcard patterns: strip surrounding quotes
let pattern = wildcardField[2];
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
(pattern.startsWith("'") && pattern.endsWith("'"))) {
pattern = pattern.slice(1, -1);
}
if (!searchableFields.includes(field)) { 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}`);
} }
// 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 +417,46 @@ 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: 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 };
}
// free term -> regex across all searchable fields
const esc = escapeForRegex(term);
return { $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) };
});
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 +466,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);
} }