Skip to content

Commit 00c50e6

Browse files
committed
feat(driver-memory): implement query matching and filtering in InMemoryDriver
1 parent 772d681 commit 00c50e6

2 files changed

Lines changed: 268 additions & 8 deletions

File tree

packages/plugins/driver-memory/src/memory-driver.ts

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { QueryAST, QueryInput } from '@objectstack/spec/data';
22
import { DriverOptions } from '@objectstack/spec/data';
33
import { DriverInterface, Logger, createLogger } from '@objectstack/core';
4+
import { match, getValueByPath } from './memory-matcher.js';
45

56
/**
67
* Example: In-Memory Driver
@@ -36,10 +37,10 @@ export class InMemoryDriver implements DriverInterface {
3637
transactions: false,
3738

3839
// Query Operations
39-
queryFilters: false, // TODO: Not implemented - basic find() doesn't handle filters
40+
queryFilters: true, // Implemented via memory-matcher
4041
queryAggregations: false, // TODO: Not implemented - count() only returns total
41-
querySorting: false, // TODO: Not implemented - find() doesn't handle sorting
42-
queryPagination: true, // Basic pagination via 'limit' is implemented
42+
querySorting: true, // Implemented via JS sort
43+
queryPagination: true, // Implemented
4344
queryWindowFunctions: false, // TODO: Not implemented
4445
querySubqueries: false, // TODO: Not implemented
4546
joins: false, // TODO: Not implemented
@@ -101,13 +102,46 @@ export class InMemoryDriver implements DriverInterface {
101102
this.logger.debug('Find operation', { object, query });
102103

103104
const table = this.getTable(object);
104-
105-
// 💡 Naive Implementation
106-
let results = [...table];
105+
let results = table;
106+
107+
// 1. Filter
108+
if (query.where) {
109+
results = results.filter(record => match(record, query.where));
110+
}
111+
112+
// 2. Sort
113+
if (query.orderBy) {
114+
// Normalize sort to array
115+
const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
116+
117+
results.sort((a, b) => {
118+
for (const { field, order } of sortFields) {
119+
const valA = getValueByPath(a, field);
120+
const valB = getValueByPath(b, field);
121+
122+
if (valA === valB) continue;
123+
124+
const comparison = valA > valB ? 1 : -1;
125+
return order === 'desc' ? -comparison : comparison;
126+
}
127+
return 0;
128+
});
129+
}
130+
131+
// 3. Pagination (Offset/Skip)
132+
if (query.offset) {
133+
results = results.slice(query.offset);
134+
} else if (query.skip) {
135+
// Alias for offset
136+
results = results.slice(query.skip);
137+
}
107138

108-
// Simple limiting for demonstration
139+
// 4. Pagination (Limit/Top)
109140
if (query.limit) {
110141
results = results.slice(0, query.limit);
142+
} else if (query.top) {
143+
// Alias for limit
144+
results = results.slice(0, query.top);
111145
}
112146

113147
this.logger.debug('Find completed', { object, resultCount: results.length });
@@ -211,7 +245,11 @@ export class InMemoryDriver implements DriverInterface {
211245
}
212246

213247
async count(object: string, query?: QueryInput, options?: DriverOptions) {
214-
const count = this.getTable(object).length;
248+
let results = this.getTable(object);
249+
if (query?.where) {
250+
results = results.filter(record => match(record, query.where));
251+
}
252+
const count = results.length;
215253
this.logger.debug('Count operation', { object, count });
216254
return count;
217255
}
@@ -226,7 +264,62 @@ export class InMemoryDriver implements DriverInterface {
226264
this.logger.debug('BulkCreate completed', { object, count: results.length });
227265
return results;
228266
}
267+
268+
async updateMany(object: string, query: QueryInput, data: Record<string, any>, options?: DriverOptions) {
269+
this.logger.debug('UpdateMany operation', { object, query });
270+
271+
const table = this.getTable(object);
272+
let targetRecords = table;
273+
274+
if (query && query.where) {
275+
targetRecords = targetRecords.filter(r => match(r, query.where));
276+
}
277+
278+
const count = targetRecords.length;
279+
280+
// Update each record
281+
for (const record of targetRecords) {
282+
// Find index in original table
283+
const index = table.findIndex(r => r.id === record.id);
284+
if (index !== -1) {
285+
const updated = {
286+
...table[index],
287+
...data,
288+
updated_at: new Date()
289+
};
290+
table[index] = updated;
291+
}
292+
}
293+
294+
this.logger.debug('UpdateMany completed', { object, count });
295+
return { count };
296+
}
297+
298+
async deleteMany(object: string, query: QueryInput, options?: DriverOptions) {
299+
this.logger.debug('DeleteMany operation', { object, query });
300+
301+
const table = this.getTable(object);
302+
const initialLength = table.length;
303+
304+
// Filter IN PLACE or create new array?
305+
// Creating new array is safer for now.
306+
307+
const remaining = table.filter(r => {
308+
if (!query || !query.where) return false; // Delete all? No, standard safety implies explicit empty filter for delete all.
309+
// Wait, normally deleteMany({}) deletes all.
310+
// Let's assume if query passed, use it.
311+
const matches = match(r, query.where);
312+
return !matches; // Keep if it DOES NOT match
313+
});
314+
315+
this.db[object] = remaining;
316+
const count = initialLength - remaining.length;
317+
318+
this.logger.debug('DeleteMany completed', { object, count });
319+
return { count };
320+
}
229321

322+
// Compatibility aliases
230323
async bulkUpdate(object: string, updates: { id: string | number, data: Record<string, any> }[], options?: DriverOptions) {
231324
this.logger.debug('BulkUpdate operation', { object, count: updates.length });
232325
const results = await Promise.all(updates.map(u => this.update(object, u.id, u.data, options)));
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
2+
/**
3+
* Simple In-Memory Query Matcher
4+
*
5+
* Implements a subset of the ObjectStack Filter Protocol (MongoDB-compatible)
6+
* for evaluating conditions against in-memory JavaScript objects.
7+
*/
8+
9+
type RecordType = Record<string, any>;
10+
11+
/**
12+
* matches - Check if a record matches a filter criteria
13+
* @param record The data record to check
14+
* @param filter The filter condition (where clause)
15+
*/
16+
export function match(record: RecordType, filter: any): boolean {
17+
if (!filter || Object.keys(filter).length === 0) return true;
18+
19+
// 1. Handle Top-Level Logical Operators ($and, $or, $not)
20+
// These usually appear at the root or nested.
21+
22+
// $and: [ { ... }, { ... } ]
23+
if (Array.isArray(filter.$and)) {
24+
if (!filter.$and.every((f: any) => match(record, f))) {
25+
return false;
26+
}
27+
}
28+
29+
// $or: [ { ... }, { ... } ]
30+
if (Array.isArray(filter.$or)) {
31+
if (!filter.$or.some((f: any) => match(record, f))) {
32+
return false;
33+
}
34+
}
35+
36+
// $not: { ... }
37+
if (filter.$not) {
38+
if (match(record, filter.$not)) {
39+
return false;
40+
}
41+
}
42+
43+
// 2. Iterate over field constraints
44+
for (const key of Object.keys(filter)) {
45+
// Skip logical operators we already handled (or future ones)
46+
if (key.startsWith('$')) continue;
47+
48+
const condition = filter[key];
49+
const value = getValueByPath(record, key);
50+
51+
if (!checkCondition(value, condition)) {
52+
return false;
53+
}
54+
}
55+
56+
return true;
57+
}
58+
59+
/**
60+
* Access nested properties via dot-notation (e.g. "user.name")
61+
*/
62+
export function getValueByPath(obj: any, path: string): any {
63+
if (!path.includes('.')) return obj[path];
64+
return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj);
65+
}
66+
67+
/**
68+
* Evaluate a specific condition against a value
69+
*/
70+
function checkCondition(value: any, condition: any): boolean {
71+
// Case A: Implicit Equality (e.g. status: 'active')
72+
// If condition is a primitive or Date/Array (exact match), treat as equality.
73+
if (
74+
typeof condition !== 'object' ||
75+
condition === null ||
76+
condition instanceof Date ||
77+
Array.isArray(condition)
78+
) {
79+
// Loose equality to handle undefined/null mismatch or string/number coercion if desired.
80+
// But stick to == for JS loose equality which is often convenient in weakly typed queries.
81+
return value == condition;
82+
}
83+
84+
// Case B: Operator Object (e.g. { $gt: 10, $lt: 20 })
85+
const keys = Object.keys(condition);
86+
const isOperatorObject = keys.some(k => k.startsWith('$'));
87+
88+
if (!isOperatorObject) {
89+
// It's just a nested object comparison or implicit equality against an object
90+
// Simplistic check:
91+
return JSON.stringify(value) === JSON.stringify(condition);
92+
}
93+
94+
// Iterate operators
95+
for (const op of keys) {
96+
const target = condition[op];
97+
98+
// Handle undefined values
99+
if (value === undefined && op !== '$exists' && op !== '$ne') {
100+
return false;
101+
}
102+
103+
switch (op) {
104+
case '$eq':
105+
if (value != target) return false;
106+
break;
107+
case '$ne':
108+
if (value == target) return false;
109+
break;
110+
111+
// Numeric / Date
112+
case '$gt':
113+
if (!(value > target)) return false;
114+
break;
115+
case '$gte':
116+
if (!(value >= target)) return false;
117+
break;
118+
case '$lt':
119+
if (!(value < target)) return false;
120+
break;
121+
case '$lte':
122+
if (!(value <= target)) return false;
123+
break;
124+
case '$between':
125+
// target should be [min, max]
126+
if (Array.isArray(target) && (value < target[0] || value > target[1])) return false;
127+
break;
128+
129+
// Sets
130+
case '$in':
131+
if (!Array.isArray(target) || !target.includes(value)) return false;
132+
break;
133+
case '$nin':
134+
if (Array.isArray(target) && target.includes(value)) return false;
135+
break;
136+
137+
// Existence
138+
case '$exists':
139+
const exists = value !== undefined && value !== null;
140+
if (exists !== !!target) return false;
141+
break;
142+
143+
// Strings
144+
case '$contains':
145+
if (typeof value !== 'string' || !value.includes(target)) return false;
146+
break;
147+
case '$startsWith':
148+
if (typeof value !== 'string' || !value.startsWith(target)) return false;
149+
break;
150+
case '$endsWith':
151+
if (typeof value !== 'string' || !value.endsWith(target)) return false;
152+
break;
153+
case '$regex':
154+
try {
155+
const re = new RegExp(target, condition.$options || '');
156+
if (!re.test(String(value))) return false;
157+
} catch (e) { return false; }
158+
break;
159+
160+
default:
161+
// Unknown operator, ignore or fail. Ignoring safe for optional features.
162+
break;
163+
}
164+
}
165+
166+
return true;
167+
}

0 commit comments

Comments
 (0)