diff --git a/changelog.md b/changelog.md index d614263..6ab81b5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/test/test.search.ts b/test/test.search.ts index 125dbee..a186341 100644 --- a/test/test.search.ts +++ b/test/test.search.ts @@ -290,6 +290,39 @@ tap.test('should apply validate hook to post-filter results', async () => { expensive.forEach((p) => expect(p.price).toBeGreaterThan(500)); }); +// Combined free-term and field wildcard tests +tap.test('should combine free term and wildcard field search', async () => { + const results = await Product.search('book category:Book*'); + expect(results.length).toEqual(2); + results.forEach((p) => expect(p.category).toEqual('Books')); +}); +tap.test('should not match when free term matches but wildcard field does not', async () => { + const results = await Product.search('book category:Kitchen*'); + expect(results.length).toEqual(0); +}); + +// Non-searchable field should cause an error for combined queries +tap.test('should throw when combining term with non-searchable field', async () => { + let error: Error; + try { + await Product.search('book location:Berlin'); + } catch (e) { + error = e as Error; + } + expect(error).toBeTruthy(); + expect(error.message).toMatch(/not searchable/); +}); +tap.test('should throw when combining term with non-searchable wildcard field', async () => { + let error: Error; + try { + await Product.search('book location:Berlin*'); + } catch (e) { + error = e as Error; + } + expect(error).toBeTruthy(); + expect(error.message).toMatch(/not searchable/); +}); + // Close database connection tap.test('close database connection', async () => { await testDb.mongoDb.dropDatabase(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 29adb24..6100604 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartdata', - version: '5.12.0', + version: '5.12.1', description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.' } diff --git a/ts/classes.doc.ts b/ts/classes.doc.ts index abcffbf..ebcfde3 100644 --- a/ts/classes.doc.ts +++ b/ts/classes.doc.ts @@ -414,31 +414,35 @@ export class SmartDataDbDoc ({ [f]: { $regex: pattern, $options: 'i' } })); return await (this as any).execQuery({ $or: orConds }, opts); } - // implicit AND for mixed simple term + field:value queries (no explicit operators) + // 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(':')); - // 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('?') + !q.includes('(') && !q.includes(')') && + !q.includes('[') && !q.includes(']') ) { const andConds = parts.map((term) => { - const m = term.match(/^(\\w+):([^"'\\*\\?\\s]+)$/); + 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 }; - } else { - const esc = escapeForRegex(term); - const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })); - return { $or: ors }; } + // 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); }