Compare commits

...

4 Commits

Author SHA1 Message Date
3b29a150a8 v5.16.5
Some checks failed
Default (tags) / security (push) Successful in 53s
Default (tags) / test (push) Failing after 7m30s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-16 18:19:57 +00:00
59186d84a9 fix(watcher): Update dependencies, tooling and watcher import; add .serena cache ignore 2025-11-16 18:19:57 +00:00
7fab4e5dd0 5.16.4
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Successful in 3m12s
Default (tags) / release (push) Failing after 1m0s
Default (tags) / metadata (push) Successful in 1m11s
2025-08-18 20:24:16 +00:00
0dbaa1bc5d fix(classes.doc (convertFilterForMongoDb)): Improve filter conversion: handle logical operators, merge operator objects, add nested filter tests and docs, and fix test script 2025-08-18 20:24:16 +00:00
12 changed files with 10536 additions and 1344 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## 2025-11-16 - 5.16.5 - fix(watcher)
Update dependencies, tooling and watcher import; add .serena cache ignore
- Bump runtime dependencies: @push.rocks/smartlog 3.1.8 → 3.1.10, @push.rocks/smartstring 4.0.15 → 4.1.0, @push.rocks/taskbuffer 3.1.7 → 3.4.0, @tsclass/tsclass 9.2.0 → 9.3.0, mongodb 6.18.0 → 6.20.0
- Bump devDependencies: @git.zone/tsbuild 2.6.7 → 2.6.8, @git.zone/tsrun 1.2.44 → 1.6.2, @git.zone/tstest 2.3.5 → 2.6.2
- Switch EventEmitter import to node:events in ts/classes.watcher.ts to use the namespaced Node import
- Add .serena/.gitignore to ignore /cache
## 2025-08-18 - 5.16.4 - fix(classes.doc (convertFilterForMongoDb))
Improve filter conversion: handle logical operators, merge operator objects, add nested filter tests and docs, and fix test script
- Fix package.json test script: remove stray dot in tstest --verbose argument to ensure tests run correctly
- Enhance convertFilterForMongoDb in ts/classes.doc.ts to properly handle logical operators ($and, $or, $nor, $not) and return them recursively
- Merge operator objects for the same field path (e.g. combining $gte and $lte) to avoid overwriting operator clauses when object and dot-notation are mixed
- Add validation/guards for operator argument types (e.g. $in, $nin, $all must be arrays; $size must be numeric) and preserve existing behavior blocking $where for security
- Add comprehensive nested filter tests in test/test.filters.ts to cover deep nested object queries, $elemMatch, array size, $all, $in on nested fields and more
- Expand README filtering section with detailed examples for basic filtering, deep nested filters, comparison operators, array operations, logical and element operators, and advanced patterns
## 2025-08-18 - 5.16.3 - fix(docs) ## 2025-08-18 - 5.16.3 - fix(docs)
Add local Claude settings and remove outdated codex.md Add local Claude settings and remove outdated codex.md

8117
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{ {
"name": "@push.rocks/smartdata", "name": "@push.rocks/smartdata",
"version": "5.16.3", "version": "5.16.5",
"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",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "tstest test/ --verbose. --logfile --timeout 120", "test": "tstest test/ --verbose --logfile --timeout 120",
"testSearch": "tsx test/test.search.ts", "testSearch": "tsx test/test.search.ts",
"build": "tsbuild --web --allowimplicitany", "build": "tsbuild --web --allowimplicitany",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
@@ -25,21 +25,21 @@
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.2.2", "@push.rocks/lik": "^6.2.2",
"@push.rocks/smartdelay": "^3.0.1", "@push.rocks/smartdelay": "^3.0.1",
"@push.rocks/smartlog": "^3.1.8", "@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartmongo": "^2.0.12", "@push.rocks/smartmongo": "^2.0.12",
"@push.rocks/smartpromise": "^4.0.2", "@push.rocks/smartpromise": "^4.0.2",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smarttime": "^4.0.6", "@push.rocks/smarttime": "^4.0.6",
"@push.rocks/smartunique": "^3.0.8", "@push.rocks/smartunique": "^3.0.8",
"@push.rocks/taskbuffer": "^3.1.7", "@push.rocks/taskbuffer": "^3.4.0",
"@tsclass/tsclass": "^9.2.0", "@tsclass/tsclass": "^9.3.0",
"mongodb": "^6.18.0" "mongodb": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.7", "@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.6.2",
"@git.zone/tstest": "^2.3.5", "@git.zone/tstest": "^2.6.2",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/tapbundle": "^6.0.3", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.15.2" "@types/node": "^22.15.2"

3246
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

196
readme.md
View File

@@ -144,40 +144,130 @@ await foundUser.delete();
SmartData provides the most advanced type-safe filtering system for MongoDB, supporting all operators while maintaining full IntelliSense: SmartData provides the most advanced type-safe filtering system for MongoDB, supporting all operators while maintaining full IntelliSense:
#### Basic Filtering
```typescript ```typescript
// Simple equality
const john = await User.getInstances({ name: 'John Doe' });
// Multiple fields (implicit AND)
const activeAdults = await User.getInstances({
status: 'active',
age: { $gte: 18 }
});
// Union types work perfectly // Union types work perfectly
const users = await User.getInstances({ const users = await User.getInstances({
status: { $in: ['active', 'pending'] } // TypeScript validates these values! status: { $in: ['active', 'pending'] } // TypeScript validates these values!
}); });
```
// Comparison operators with type checking #### Deep Nested Object Filtering
SmartData supports **both** nested object notation and dot notation for querying nested fields, with intelligent merging when both are used:
```typescript
// Nested object notation - natural TypeScript syntax
const users = await User.getInstances({
metadata: {
loginCount: { $gte: 5 }
}
});
// Dot notation - MongoDB style
const sameUsers = await User.getInstances({
'metadata.loginCount': { $gte: 5 }
});
// POWERFUL: Combine both notations - operators are merged!
const filtered = await User.getInstances({
metadata: { loginCount: { $gte: 3 } }, // Object notation
'metadata.loginCount': { $lte: 10 } // Dot notation
// Result: metadata.loginCount between 3 and 10
});
// Deep nesting with full type safety
const deepQuery = await User.getInstances({
profile: {
settings: {
notifications: {
email: true,
frequency: { $in: ['daily', 'weekly'] }
}
}
}
});
// Mix styles for complex queries
const advanced = await User.getInstances({
// Object notation for structure
profile: {
age: { $gte: 21 },
verified: true
},
// Dot notation for specific overrides
'profile.settings.theme': 'dark',
'profile.lastSeen': { $gte: new Date('2024-01-01') }
});
```
#### Comparison Operators
```typescript
// Numeric comparisons with type checking
const adults = await User.getInstances({ const adults = await User.getInstances({
age: { $gte: 18, $lt: 65 } // Type-safe numeric comparisons age: { $gte: 18, $lt: 65 } // Type-safe numeric comparisons
}); });
// Date comparisons
const recentUsers = await User.getInstances({
createdAt: { $gte: new Date('2024-01-01') }
});
// Not equal
const nonAdmins = await User.getInstances({
role: { $ne: 'admin' }
});
```
#### Array Operations
```typescript
// Array operations with full type safety // Array operations with full type safety
const experts = await User.getInstances({ const experts = await User.getInstances({
tags: { $all: ['typescript', 'mongodb'] }, // Must have all tags tags: { $all: ['typescript', 'mongodb'] }, // Must have all tags
skills: { $size: 5 } // Exactly 5 skills skills: { $size: 5 } // Exactly 5 skills
}); });
// Complex nested queries // Array element matching
const results = await Order.getInstances({ const results = await Order.getInstances({
$and: [
{ status: { $in: ['pending', 'processing'] } },
{ 'items.price': { $gte: 100 } }, // Dot notation for nested fields
{
items: { items: {
$elemMatch: { // Array element matching $elemMatch: { // Match array elements
product: 'laptop', product: 'laptop',
quantity: { $gte: 2 } quantity: { $gte: 2 }
} }
} }
} });
// Check if value exists in array field
const nodeUsers = await User.getInstances({
skills: { $in: ['nodejs'] } // Has nodejs in skills array
});
```
#### Logical Operators
```typescript
// Complex nested queries with $and
const results = await Order.getInstances({
$and: [
{ status: { $in: ['pending', 'processing'] } },
{ 'items.price': { $gte: 100 } },
{ customer: { verified: true } }
] ]
}); });
// Logical operators // $or operator
const urgentOrHighValue = await Order.getInstances({ const urgentOrHighValue = await Order.getInstances({
$or: [ $or: [
{ priority: 'urgent' }, { priority: 'urgent' },
@@ -185,8 +275,94 @@ const urgentOrHighValue = await Order.getInstances({
] ]
}); });
// Security: $where is automatically blocked // $nor operator - none of the conditions
const excluded = await User.getInstances({
$nor: [
{ status: 'banned' },
{ role: 'guest' }
]
});
// Combine logical operators
const complex = await Order.getInstances({
$and: [
{ status: 'active' },
{
$or: [
{ priority: 'high' },
{ value: { $gte: 1000 } }
]
}
]
});
```
#### Element Operators
```typescript
// Check field existence
const withEmail = await User.getInstances({
email: { $exists: true }
});
// Check for null or missing nested fields
const noPreferences = await User.getInstances({
'profile.preferences': { $exists: false }
});
```
#### Text and Pattern Matching
```typescript
// Regex patterns
const gmailUsers = await User.getInstances({
email: { $regex: '@gmail\\.com$', $options: 'i' }
});
// Starts with pattern
const johnUsers = await User.getInstances({
name: { $regex: '^John' }
});
```
#### Security Features
```typescript
// Security: $where is automatically blocked for injection protection
// await User.getInstances({ $where: '...' }); // ❌ Throws security error // await User.getInstances({ $where: '...' }); // ❌ Throws security error
// Invalid operators are caught
// await User.getInstances({ $badOp: 'value' }); // ⚠️ Warning logged
```
#### Advanced Patterns
```typescript
// Combine multiple filtering approaches
const advancedQuery = await User.getInstances({
// Direct field matching
status: 'active',
// Nested object with operators
profile: {
age: { $gte: 18, $lte: 65 },
verified: true
},
// Dot notation for deep paths
'settings.notifications.email': true,
'metadata.lastLogin': { $gte: new Date(Date.now() - 30*24*60*60*1000) },
// Array operations
roles: { $in: ['admin', 'moderator'] },
tags: { $all: ['verified', 'premium'] },
// Logical grouping
$or: [
{ 'subscription.plan': 'premium' },
{ 'subscription.trial': true }
]
});
``` ```
### 🔎 Powerful Search Engine ### 🔎 Powerful Search Engine

View File

@@ -213,6 +213,221 @@ tap.test('should filter by nested object fields', async () => {
expect(users[0].name).toEqual('John Doe'); expect(users[0].name).toEqual('John Doe');
}); });
// ============= COMPREHENSIVE NESTED FILTER TESTS =============
tap.test('should filter by nested object with direct object syntax', async () => {
// Direct nested object matching (exact match)
const users = await TestUser.getInstances({
metadata: {
loginCount: 5,
lastLogin: (await TestUser.getInstances({}))[0].metadata.lastLogin // Get the exact date
}
});
expect(users.length).toEqual(1);
expect(users[0].name).toEqual('John Doe');
});
tap.test('should filter by partial nested object match', async () => {
// When using object syntax, only specified fields must match
const users = await TestUser.getInstances({
metadata: { loginCount: 5 } // Only checks loginCount, ignores other fields
});
expect(users.length).toEqual(1);
expect(users[0].name).toEqual('John Doe');
});
tap.test('should combine nested object and dot notation', async () => {
const users = await TestUser.getInstances({
metadata: { loginCount: { $gte: 3 } }, // Object syntax with operator
'metadata.loginCount': { $lte: 10 } // Dot notation with operator
});
expect(users.length).toEqual(3); // Jane (3), John (5), and Alice (10) have loginCount between 3-10
});
tap.test('should filter nested fields with operators using dot notation', async () => {
const users = await TestUser.getInstances({
'metadata.loginCount': { $gte: 5 }
});
expect(users.length).toEqual(2); // John (5) and Alice (10)
const names = users.map(u => u.name).sort();
expect(names).toEqual(['Alice Brown', 'John Doe']);
});
tap.test('should filter nested fields with multiple operators', async () => {
const users = await TestUser.getInstances({
'metadata.loginCount': { $gte: 3, $lt: 10 }
});
expect(users.length).toEqual(2); // Jane (3) and John (5)
const names = users.map(u => u.name).sort();
expect(names).toEqual(['Jane Smith', 'John Doe']);
});
tap.test('should handle deeply nested object structures', async () => {
// First, create a user with deep nesting in preferences
const deepUser = new TestUser({
name: 'Deep Nester',
age: 40,
email: 'deep@example.com',
roles: ['admin'],
tags: [],
status: 'active',
metadata: {
loginCount: 1,
preferences: {
theme: {
colors: {
primary: '#000000',
secondary: '#ffffff'
},
fonts: {
heading: 'Arial',
body: 'Helvetica'
}
},
notifications: {
email: true,
push: false
}
}
},
scores: []
});
await deepUser.save();
// Test deep nesting with dot notation
const deepResults = await TestUser.getInstances({
'metadata.preferences.theme.colors.primary': '#000000'
});
expect(deepResults.length).toEqual(1);
expect(deepResults[0].name).toEqual('Deep Nester');
// Test deep nesting with operators
const boolResults = await TestUser.getInstances({
'metadata.preferences.notifications.email': { $eq: true }
});
expect(boolResults.length).toEqual(1);
expect(boolResults[0].name).toEqual('Deep Nester');
// Clean up
await deepUser.delete();
});
tap.test('should filter arrays of nested objects using $elemMatch', async () => {
const orders = await TestOrder.getInstances({
items: {
$elemMatch: {
product: 'laptop',
price: { $gte: 1000 }
}
}
});
expect(orders.length).toEqual(2); // Both laptop orders have price >= 1000
});
tap.test('should filter nested arrays with dot notation', async () => {
// Query for any order that has an item with specific product
const orders = await TestOrder.getInstances({
'items.product': 'laptop'
});
expect(orders.length).toEqual(2); // Two orders contain laptops
});
tap.test('should combine nested object filters with logical operators', async () => {
const users = await TestUser.getInstances({
$or: [
{ 'metadata.loginCount': { $gte: 10 } }, // Alice has 10
{
$and: [
{ 'metadata.loginCount': { $lt: 5 } }, // Jane has 3, Bob has 0, Charlie has 1
{ status: 'active' } // Jane is active, Bob is inactive, Charlie is pending
]
}
]
});
expect(users.length).toEqual(2); // Alice (loginCount >= 10), Jane (loginCount < 5 AND active)
const names = users.map(u => u.name).sort();
expect(names).toEqual(['Alice Brown', 'Jane Smith']);
});
tap.test('should handle null and undefined in nested fields', async () => {
// Users without lastLogin
const noLastLogin = await TestUser.getInstances({
'metadata.lastLogin': { $exists: false }
});
expect(noLastLogin.length).toEqual(4); // Everyone except John
// Users with preferences (none have it set)
const withPreferences = await TestUser.getInstances({
'metadata.preferences': { $exists: true }
});
expect(withPreferences.length).toEqual(0);
});
tap.test('should filter nested arrays by size', async () => {
// Create an order with specific number of items
const multiItemOrder = new TestOrder({
userId: 'test-user',
items: [
{ product: 'item1', quantity: 1, price: 10 },
{ product: 'item2', quantity: 2, price: 20 },
{ product: 'item3', quantity: 3, price: 30 },
{ product: 'item4', quantity: 4, price: 40 }
],
totalAmount: 100,
status: 'pending',
tags: ['test']
});
await multiItemOrder.save();
const fourItemOrders = await TestOrder.getInstances({
items: { $size: 4 }
});
expect(fourItemOrders.length).toEqual(1);
// Clean up
await multiItemOrder.delete();
});
tap.test('should handle nested field comparison between documents', async () => {
// Find users where loginCount equals their age divided by 6 (John: 30/6=5)
const users = await TestUser.getInstances({
$and: [
{ 'metadata.loginCount': 5 },
{ age: 30 }
]
});
expect(users.length).toEqual(1);
expect(users[0].name).toEqual('John Doe');
});
tap.test('should filter using $in on nested fields', async () => {
const users = await TestUser.getInstances({
'metadata.loginCount': { $in: [0, 1, 5] }
});
expect(users.length).toEqual(3); // Bob (0), Charlie (1), John (5)
const names = users.map(u => u.name).sort();
expect(names).toEqual(['Bob Johnson', 'Charlie Wilson', 'John Doe']);
});
tap.test('should filter nested arrays with $all', async () => {
// Create an order with multiple tags
const taggedOrder = new TestOrder({
userId: 'test-user',
items: [{ product: 'test', quantity: 1, price: 10 }],
totalAmount: 10,
status: 'completed',
tags: ['urgent', 'priority', 'electronics']
});
await taggedOrder.save();
const priorityElectronics = await TestOrder.getInstances({
tags: { $all: ['priority', 'electronics'] }
});
expect(priorityElectronics.length).toEqual(2); // Original order and new one
// Clean up
await taggedOrder.delete();
});
// ============= COMPARISON OPERATOR TESTS ============= // ============= COMPARISON OPERATOR TESTS =============
tap.test('should filter using $gt operator', async () => { tap.test('should filter using $gt operator', async () => {
const users = await TestUser.getInstances({ const users = await TestUser.getInstances({

View File

@@ -1,3 +1,5 @@
// TODO: Decorator support during testing for bun and deno in @git.zone/tstest
import { tap, expect } from '@push.rocks/tapbundle'; import { tap, expect } from '@push.rocks/tapbundle';
import { Qenv } from '@push.rocks/qenv'; import { Qenv } from '@push.rocks/qenv';
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartmongo from '@push.rocks/smartmongo';

View File

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

@@ -199,21 +199,51 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
throw new Error('$where operator is not allowed for security reasons'); throw new Error('$where operator is not allowed for security reasons');
} }
// Special case: detect MongoDB operators and pass them through directly // Handle logical operators recursively
const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$regex']; const logicalOperators = ['$and', '$or', '$nor', '$not'];
const processedFilter: { [key: string]: any } = {};
for (const key of Object.keys(filterArg)) { for (const key of Object.keys(filterArg)) {
if (topLevelOperators.includes(key)) { if (logicalOperators.includes(key)) {
return filterArg; // Return the filter as-is for MongoDB operators if (key === '$not') {
processedFilter[key] = convertFilterForMongoDb(filterArg[key]);
} else if (Array.isArray(filterArg[key])) {
processedFilter[key] = filterArg[key].map((subFilter: any) => convertFilterForMongoDb(subFilter));
} }
} }
}
// If only logical operators, return them
const hasOnlyLogicalOperators = Object.keys(filterArg).every(key => logicalOperators.includes(key));
if (hasOnlyLogicalOperators) {
return processedFilter;
}
// Original conversion logic for non-MongoDB query objects // Original conversion logic for non-MongoDB query objects
const convertedFilter: { [key: string]: any } = {}; const convertedFilter: { [key: string]: any } = {};
// Helper to merge operator objects
const mergeIntoConverted = (path: string, value: any) => {
const existing = convertedFilter[path];
if (!existing) {
convertedFilter[path] = value;
} else if (
typeof existing === 'object' && !Array.isArray(existing) &&
typeof value === 'object' && !Array.isArray(value) &&
(Object.keys(existing).some(k => k.startsWith('$')) || Object.keys(value).some(k => k.startsWith('$')))
) {
// Both have operators, merge them
convertedFilter[path] = { ...existing, ...value };
} else {
// Otherwise later wins
convertedFilter[path] = value;
}
};
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => { const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
if (Array.isArray(filterArg2)) { if (Array.isArray(filterArg2)) {
// Arrays are typically used as values for operators like $in or as direct equality matches // Arrays are typically used as values for operators like $in or as direct equality matches
convertedFilter[keyPathArg2] = filterArg2; mergeIntoConverted(keyPathArg2, filterArg2);
return; return;
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) { } else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
// Check if this is an object with MongoDB operators // Check if this is an object with MongoDB operators
@@ -264,8 +294,8 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
throw new Error('$size operator requires a numeric value'); throw new Error('$size operator requires a numeric value');
} }
// Pass the operator object through // Use merge helper to handle duplicate paths
convertedFilter[keyPathArg2] = filterArg2; mergeIntoConverted(keyPathArg2, filterArg2);
return; return;
} }
@@ -282,13 +312,20 @@ export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
} }
} else { } else {
// Primitive values // Primitive values
convertedFilter[keyPathArg2] = filterArg2; mergeIntoConverted(keyPathArg2, filterArg2);
} }
}; };
for (const key of Object.keys(filterArg)) { for (const key of Object.keys(filterArg)) {
// Skip logical operators, they were already processed
if (!logicalOperators.includes(key)) {
convertFilterArgument(key, filterArg[key]); convertFilterArgument(key, filterArg[key]);
} }
}
// Add back processed logical operators
Object.assign(convertedFilter, processedFilter);
return convertedFilter; return convertedFilter;
}; };

View File

@@ -1,6 +1,6 @@
import { SmartDataDbDoc } from './classes.doc.js'; import { SmartDataDbDoc } from './classes.doc.js';
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
/** /**
* a wrapper for the native mongodb cursor. Exposes better * a wrapper for the native mongodb cursor. Exposes better