dimond-db 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/package.json +46 -0
- package/src/database/Collection.js +401 -0
- package/src/database/Database.js +121 -0
- package/src/engine/QueryEngine.js +166 -0
- package/src/engine/StorageEngine.js +177 -0
- package/src/errors/DatabaseError.js +68 -0
- package/src/index.js +18 -0
- package/src/query/operators.js +187 -0
- package/src/storage/FileStorage.js +131 -0
- package/src/utils/deepClone.js +31 -0
- package/src/utils/idGenerator.js +35 -0
- package/src/utils/validator.js +133 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { QueryEngine } from '../engine/QueryEngine.js';
|
|
2
|
+
import { StorageEngine } from '../engine/StorageEngine.js';
|
|
3
|
+
import { generateId } from '../utils/idGenerator.js';
|
|
4
|
+
import { deepClone } from '../utils/deepClone.js';
|
|
5
|
+
import {
|
|
6
|
+
validateDocument,
|
|
7
|
+
validateDocuments,
|
|
8
|
+
validateFilter,
|
|
9
|
+
validateUpdate
|
|
10
|
+
} from '../utils/validator.js';
|
|
11
|
+
import {
|
|
12
|
+
DuplicateKeyError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
QueryError
|
|
15
|
+
} from '../errors/DatabaseError.js';
|
|
16
|
+
import { setNestedValue, deleteNestedValue, getNestedValue } from '../query/operators.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Collection - Represents a collection of documents
|
|
20
|
+
*/
|
|
21
|
+
export class Collection {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} name - Collection name
|
|
24
|
+
* @param {StorageEngine} storageEngine - Storage engine instance
|
|
25
|
+
* @param {Database} database - Database instance
|
|
26
|
+
*/
|
|
27
|
+
constructor(name, storageEngine, database) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.storageEngine = storageEngine;
|
|
30
|
+
this.database = database;
|
|
31
|
+
this.documents = [];
|
|
32
|
+
this.loaded = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Loads the collection from disk
|
|
37
|
+
*/
|
|
38
|
+
async load() {
|
|
39
|
+
if (!this.loaded) {
|
|
40
|
+
// Ensure database is initialized first
|
|
41
|
+
await this.database.ensureInitialized();
|
|
42
|
+
this.documents = await this.storageEngine.readCollection(this.name);
|
|
43
|
+
this.loaded = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Persists the collection to disk
|
|
49
|
+
*/
|
|
50
|
+
async persist() {
|
|
51
|
+
await this.storageEngine.writeCollection(this.name, this.documents);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inserts a single document
|
|
56
|
+
* @param {Object} doc - Document to insert
|
|
57
|
+
* @returns {Promise<Object>} Result object with insertedId
|
|
58
|
+
*/
|
|
59
|
+
async insertOne(doc) {
|
|
60
|
+
await this.load();
|
|
61
|
+
|
|
62
|
+
validateDocument(doc);
|
|
63
|
+
|
|
64
|
+
const document = deepClone(doc);
|
|
65
|
+
|
|
66
|
+
// Generate _id if not provided
|
|
67
|
+
if (!document._id) {
|
|
68
|
+
document._id = generateId();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check for duplicate _id
|
|
72
|
+
if (this.documents.some(d => d._id === document._id)) {
|
|
73
|
+
throw new DuplicateKeyError(document._id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.documents.push(document);
|
|
77
|
+
await this.persist();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
acknowledged: true,
|
|
81
|
+
insertedId: document._id
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Inserts multiple documents
|
|
87
|
+
* @param {Array} docs - Array of documents to insert
|
|
88
|
+
* @returns {Promise<Object>} Result object with insertedIds
|
|
89
|
+
*/
|
|
90
|
+
async insertMany(docs) {
|
|
91
|
+
await this.load();
|
|
92
|
+
|
|
93
|
+
validateDocuments(docs);
|
|
94
|
+
|
|
95
|
+
const insertedIds = [];
|
|
96
|
+
const documents = docs.map(doc => {
|
|
97
|
+
const document = deepClone(doc);
|
|
98
|
+
|
|
99
|
+
// Generate _id if not provided
|
|
100
|
+
if (!document._id) {
|
|
101
|
+
document._id = generateId();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for duplicate _id within the batch
|
|
105
|
+
if (insertedIds.includes(document._id)) {
|
|
106
|
+
throw new DuplicateKeyError(document._id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check for duplicate _id in existing documents
|
|
110
|
+
if (this.documents.some(d => d._id === document._id)) {
|
|
111
|
+
throw new DuplicateKeyError(document._id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
insertedIds.push(document._id);
|
|
115
|
+
return document;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.documents.push(...documents);
|
|
119
|
+
await this.persist();
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
acknowledged: true,
|
|
123
|
+
insertedCount: documents.length,
|
|
124
|
+
insertedIds
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Finds documents matching a filter
|
|
130
|
+
* @param {Object} filter - Query filter
|
|
131
|
+
* @returns {Promise<Array>} Array of matching documents
|
|
132
|
+
*/
|
|
133
|
+
async find(filter = {}) {
|
|
134
|
+
await this.load();
|
|
135
|
+
|
|
136
|
+
validateFilter(filter);
|
|
137
|
+
|
|
138
|
+
const results = QueryEngine.find(this.documents, filter);
|
|
139
|
+
return results.map(doc => deepClone(doc));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Finds the first document matching a filter
|
|
144
|
+
* @param {Object} filter - Query filter
|
|
145
|
+
* @returns {Promise<Object|null>} The first matching document or null
|
|
146
|
+
*/
|
|
147
|
+
async findOne(filter = {}) {
|
|
148
|
+
await this.load();
|
|
149
|
+
|
|
150
|
+
validateFilter(filter);
|
|
151
|
+
|
|
152
|
+
const result = QueryEngine.findOne(this.documents, filter);
|
|
153
|
+
return result ? deepClone(result) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Updates a single document
|
|
158
|
+
* @param {Object} filter - Query filter
|
|
159
|
+
* @param {Object} update - Update operations
|
|
160
|
+
* @returns {Promise<Object>} Result object with matchedCount and modifiedCount
|
|
161
|
+
*/
|
|
162
|
+
async updateOne(filter = {}, update) {
|
|
163
|
+
await this.load();
|
|
164
|
+
|
|
165
|
+
validateFilter(filter);
|
|
166
|
+
validateUpdate(update);
|
|
167
|
+
|
|
168
|
+
const docIndex = this.documents.findIndex(doc =>
|
|
169
|
+
QueryEngine.match(doc, filter)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (docIndex === -1) {
|
|
173
|
+
return {
|
|
174
|
+
acknowledged: true,
|
|
175
|
+
matchedCount: 0,
|
|
176
|
+
modifiedCount: 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const originalDoc = JSON.stringify(this.documents[docIndex]);
|
|
181
|
+
this.applyUpdate(this.documents[docIndex], update);
|
|
182
|
+
const modified = JSON.stringify(this.documents[docIndex]) !== originalDoc;
|
|
183
|
+
|
|
184
|
+
await this.persist();
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
acknowledged: true,
|
|
188
|
+
matchedCount: 1,
|
|
189
|
+
modifiedCount: modified ? 1 : 0
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Updates multiple documents
|
|
195
|
+
* @param {Object} filter - Query filter
|
|
196
|
+
* @param {Object} update - Update operations
|
|
197
|
+
* @returns {Promise<Object>} Result object with matchedCount and modifiedCount
|
|
198
|
+
*/
|
|
199
|
+
async updateMany(filter = {}, update) {
|
|
200
|
+
await this.load();
|
|
201
|
+
|
|
202
|
+
validateFilter(filter);
|
|
203
|
+
validateUpdate(update);
|
|
204
|
+
|
|
205
|
+
const matchingIndices = this.documents
|
|
206
|
+
.map((doc, index) => QueryEngine.match(doc, filter) ? index : -1)
|
|
207
|
+
.filter(index => index !== -1);
|
|
208
|
+
|
|
209
|
+
let modifiedCount = 0;
|
|
210
|
+
|
|
211
|
+
for (const index of matchingIndices) {
|
|
212
|
+
const originalDoc = JSON.stringify(this.documents[index]);
|
|
213
|
+
this.applyUpdate(this.documents[index], update);
|
|
214
|
+
if (JSON.stringify(this.documents[index]) !== originalDoc) {
|
|
215
|
+
modifiedCount++;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await this.persist();
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
acknowledged: true,
|
|
223
|
+
matchedCount: matchingIndices.length,
|
|
224
|
+
modifiedCount
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Applies update operations to a document
|
|
230
|
+
* @param {Object} document - The document to update
|
|
231
|
+
* @param {Object} update - Update operations
|
|
232
|
+
*/
|
|
233
|
+
applyUpdate(document, update) {
|
|
234
|
+
const operators = Object.keys(update);
|
|
235
|
+
|
|
236
|
+
// If no operators, it's a direct replacement (not recommended, but supported)
|
|
237
|
+
if (!operators.some(key => key.startsWith('$'))) {
|
|
238
|
+
throw new ValidationError('Update must use update operators ($set, $unset, etc.)');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [operator, fields] of Object.entries(update)) {
|
|
242
|
+
switch (operator) {
|
|
243
|
+
case '$set':
|
|
244
|
+
this.applySet(document, fields);
|
|
245
|
+
break;
|
|
246
|
+
case '$unset':
|
|
247
|
+
this.applyUnset(document, fields);
|
|
248
|
+
break;
|
|
249
|
+
case '$inc':
|
|
250
|
+
this.applyInc(document, fields);
|
|
251
|
+
break;
|
|
252
|
+
case '$push':
|
|
253
|
+
this.applyPush(document, fields);
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
throw new QueryError(`Unknown update operator: ${operator}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Applies $set operator
|
|
263
|
+
* @param {Object} document - The document to update
|
|
264
|
+
* @param {Object} fields - Fields to set
|
|
265
|
+
*/
|
|
266
|
+
applySet(document, fields) {
|
|
267
|
+
for (const [path, value] of Object.entries(fields)) {
|
|
268
|
+
setNestedValue(document, path, deepClone(value));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Applies $unset operator
|
|
274
|
+
* @param {Object} document - The document to update
|
|
275
|
+
* @param {Object} fields - Fields to unset
|
|
276
|
+
*/
|
|
277
|
+
applyUnset(document, fields) {
|
|
278
|
+
for (const path of Object.keys(fields)) {
|
|
279
|
+
deleteNestedValue(document, path);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Applies $inc operator
|
|
285
|
+
* @param {Object} document - The document to update
|
|
286
|
+
* @param {Object} fields - Fields to increment
|
|
287
|
+
*/
|
|
288
|
+
applyInc(document, fields) {
|
|
289
|
+
for (const [path, value] of Object.entries(fields)) {
|
|
290
|
+
if (typeof value !== 'number') {
|
|
291
|
+
throw new ValidationError('$inc operator requires numeric value');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const currentValue = getNestedValue(document, path);
|
|
295
|
+
|
|
296
|
+
if (currentValue === undefined) {
|
|
297
|
+
setNestedValue(document, path, value);
|
|
298
|
+
} else if (typeof currentValue !== 'number') {
|
|
299
|
+
throw new ValidationError('$inc can only be applied to numeric fields');
|
|
300
|
+
} else {
|
|
301
|
+
setNestedValue(document, path, currentValue + value);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Applies $push operator
|
|
308
|
+
* @param {Object} document - The document to update
|
|
309
|
+
* @param {Object} fields - Fields to push to
|
|
310
|
+
*/
|
|
311
|
+
applyPush(document, fields) {
|
|
312
|
+
for (const [path, value] of Object.entries(fields)) {
|
|
313
|
+
const currentValue = getNestedValue(document, path);
|
|
314
|
+
|
|
315
|
+
if (currentValue === undefined) {
|
|
316
|
+
setNestedValue(document, path, [deepClone(value)]);
|
|
317
|
+
} else if (!Array.isArray(currentValue)) {
|
|
318
|
+
throw new ValidationError('$push can only be applied to array fields');
|
|
319
|
+
} else {
|
|
320
|
+
currentValue.push(deepClone(value));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Deletes a single document
|
|
327
|
+
* @param {Object} filter - Query filter
|
|
328
|
+
* @returns {Promise<Object>} Result object with deletedCount
|
|
329
|
+
*/
|
|
330
|
+
async deleteOne(filter = {}) {
|
|
331
|
+
await this.load();
|
|
332
|
+
|
|
333
|
+
validateFilter(filter);
|
|
334
|
+
|
|
335
|
+
const docIndex = this.documents.findIndex(doc =>
|
|
336
|
+
QueryEngine.match(doc, filter)
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (docIndex === -1) {
|
|
340
|
+
return {
|
|
341
|
+
acknowledged: true,
|
|
342
|
+
deletedCount: 0
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
this.documents.splice(docIndex, 1);
|
|
347
|
+
await this.persist();
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
acknowledged: true,
|
|
351
|
+
deletedCount: 1
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Deletes multiple documents
|
|
357
|
+
* @param {Object} filter - Query filter
|
|
358
|
+
* @returns {Promise<Object>} Result object with deletedCount
|
|
359
|
+
*/
|
|
360
|
+
async deleteMany(filter = {}) {
|
|
361
|
+
await this.load();
|
|
362
|
+
|
|
363
|
+
validateFilter(filter);
|
|
364
|
+
|
|
365
|
+
const initialCount = this.documents.length;
|
|
366
|
+
this.documents = this.documents.filter(doc =>
|
|
367
|
+
!QueryEngine.match(doc, filter)
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const deletedCount = initialCount - this.documents.length;
|
|
371
|
+
|
|
372
|
+
await this.persist();
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
acknowledged: true,
|
|
376
|
+
deletedCount
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Counts documents matching a filter
|
|
382
|
+
* @param {Object} filter - Query filter
|
|
383
|
+
* @returns {Promise<number>} Count of matching documents
|
|
384
|
+
*/
|
|
385
|
+
async countDocuments(filter = {}) {
|
|
386
|
+
await this.load();
|
|
387
|
+
|
|
388
|
+
validateFilter(filter);
|
|
389
|
+
|
|
390
|
+
return QueryEngine.find(this.documents, filter).length;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Drops the collection
|
|
395
|
+
*/
|
|
396
|
+
async drop() {
|
|
397
|
+
await this.storageEngine.deleteCollection(this.name);
|
|
398
|
+
this.documents = [];
|
|
399
|
+
this.loaded = false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { StorageEngine } from '../engine/StorageEngine.js';
|
|
3
|
+
import { Collection } from './Collection.js';
|
|
4
|
+
import { validateCollectionName } from '../utils/validator.js';
|
|
5
|
+
import { DatabaseOperationError } from '../errors/DatabaseError.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Database - Main database class
|
|
9
|
+
*/
|
|
10
|
+
export class Database {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options - Database options
|
|
13
|
+
* @param {string} options.database - Database name
|
|
14
|
+
* @param {string} [options.path] - Root path for database storage (defaults to './database')
|
|
15
|
+
*/
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
if (!options.database) {
|
|
18
|
+
throw new DatabaseOperationError(
|
|
19
|
+
'constructor',
|
|
20
|
+
'Database name is required'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.name = options.database;
|
|
25
|
+
this.rootPath = options.path || join(process.cwd(), 'database');
|
|
26
|
+
this.databasePath = join(this.rootPath, this.name);
|
|
27
|
+
|
|
28
|
+
this.storageEngine = new StorageEngine(this.databasePath);
|
|
29
|
+
this.collections = new Map();
|
|
30
|
+
this.initialized = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initializes the database
|
|
35
|
+
*/
|
|
36
|
+
async initialize() {
|
|
37
|
+
if (!this.initialized) {
|
|
38
|
+
await this.storageEngine.initialize();
|
|
39
|
+
this.initialized = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets or creates a collection
|
|
45
|
+
* @param {string} name - Collection name
|
|
46
|
+
* @returns {Collection} Collection instance
|
|
47
|
+
*/
|
|
48
|
+
collection(name) {
|
|
49
|
+
validateCollectionName(name);
|
|
50
|
+
|
|
51
|
+
// Return cached collection if exists
|
|
52
|
+
if (this.collections.has(name)) {
|
|
53
|
+
return this.collections.get(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create new collection instance
|
|
57
|
+
const collection = new Collection(name, this.storageEngine, this);
|
|
58
|
+
this.collections.set(name, collection);
|
|
59
|
+
|
|
60
|
+
return collection;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensures the database is initialized
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
async ensureInitialized() {
|
|
68
|
+
await this.initialize();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lists all collections in the database
|
|
73
|
+
* @returns {Promise<Array<string>>} Array of collection names
|
|
74
|
+
*/
|
|
75
|
+
async listCollections() {
|
|
76
|
+
await this.initialize();
|
|
77
|
+
return await this.storageEngine.listCollections();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Drops the entire database
|
|
82
|
+
*/
|
|
83
|
+
async dropDatabase() {
|
|
84
|
+
await this.storageEngine.dropDatabase();
|
|
85
|
+
this.collections.clear();
|
|
86
|
+
this.initialized = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Closes the database connection (clears cache)
|
|
91
|
+
*/
|
|
92
|
+
async close() {
|
|
93
|
+
this.collections.clear();
|
|
94
|
+
this.initialized = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Gets database statistics
|
|
99
|
+
* @returns {Promise<Object>} Database statistics
|
|
100
|
+
*/
|
|
101
|
+
async stats() {
|
|
102
|
+
await this.initialize();
|
|
103
|
+
|
|
104
|
+
const collectionNames = await this.listCollections();
|
|
105
|
+
let totalDocuments = 0;
|
|
106
|
+
let totalSize = 0;
|
|
107
|
+
|
|
108
|
+
for (const name of collectionNames) {
|
|
109
|
+
const collection = this.collection(name);
|
|
110
|
+
const count = await collection.countDocuments();
|
|
111
|
+
totalDocuments += count;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
database: this.name,
|
|
116
|
+
collections: collectionNames.length,
|
|
117
|
+
documents: totalDocuments,
|
|
118
|
+
path: this.databasePath
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { QueryError } from '../errors/DatabaseError.js';
|
|
2
|
+
import { OPERATORS, isOperator, getNestedValue } from '../query/operators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Query Engine - Handles query parsing and document matching
|
|
6
|
+
*/
|
|
7
|
+
export class QueryEngine {
|
|
8
|
+
/**
|
|
9
|
+
* Matches a document against a query filter
|
|
10
|
+
* @param {Object} document - The document to match
|
|
11
|
+
* @param {Object} filter - The query filter
|
|
12
|
+
* @returns {boolean} True if the document matches the filter
|
|
13
|
+
*/
|
|
14
|
+
static match(document, filter = {}) {
|
|
15
|
+
// Empty filter matches all documents
|
|
16
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return this.matchConditions(document, filter);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Matches a document against multiple conditions
|
|
25
|
+
* @param {Object} document - The document to match
|
|
26
|
+
* @param {Object} conditions - The conditions to match
|
|
27
|
+
* @returns {boolean} True if all conditions match
|
|
28
|
+
*/
|
|
29
|
+
static matchConditions(document, conditions) {
|
|
30
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
31
|
+
// Handle logical operators
|
|
32
|
+
if (key === '$and') {
|
|
33
|
+
if (!this.handleAnd(document, value)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
} else if (key === '$or') {
|
|
37
|
+
if (!this.handleOr(document, value)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// Handle field conditions
|
|
42
|
+
if (!this.matchField(document, key, value)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Matches a document field against a query value
|
|
53
|
+
* @param {Object} document - The document to match
|
|
54
|
+
* @param {string} field - The field name (supports dot notation)
|
|
55
|
+
* @param {*} queryValue - The value or operator object to match against
|
|
56
|
+
* @returns {boolean} True if the field matches
|
|
57
|
+
*/
|
|
58
|
+
static matchField(document, field, queryValue) {
|
|
59
|
+
const fieldValue = getNestedValue(document, field);
|
|
60
|
+
|
|
61
|
+
// If queryValue is an operator object
|
|
62
|
+
if (typeof queryValue === 'object' && queryValue !== null && !Array.isArray(queryValue)) {
|
|
63
|
+
const operators = Object.keys(queryValue);
|
|
64
|
+
|
|
65
|
+
// Check if any key is an operator
|
|
66
|
+
const hasOperators = operators.some(key => isOperator(key));
|
|
67
|
+
|
|
68
|
+
if (hasOperators) {
|
|
69
|
+
// Match against all operators
|
|
70
|
+
for (const [op, opValue] of Object.entries(queryValue)) {
|
|
71
|
+
if (isOperator(op)) {
|
|
72
|
+
const operatorFn = OPERATORS[op];
|
|
73
|
+
if (!operatorFn(fieldValue, opValue)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
} else if (op.startsWith('$')) {
|
|
77
|
+
throw new QueryError(`Unknown operator: ${op}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Direct equality comparison
|
|
85
|
+
return this.deepEquals(fieldValue, queryValue);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handles $and logical operator
|
|
90
|
+
* @param {Object} document - The document to match
|
|
91
|
+
* @param {Array} conditions - Array of condition objects
|
|
92
|
+
* @returns {boolean} True if all conditions match
|
|
93
|
+
*/
|
|
94
|
+
static handleAnd(document, conditions) {
|
|
95
|
+
if (!Array.isArray(conditions)) {
|
|
96
|
+
throw new QueryError('$and operator requires an array');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return conditions.every(condition => this.matchConditions(document, condition));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handles $or logical operator
|
|
104
|
+
* @param {Object} document - The document to match
|
|
105
|
+
* @param {Array} conditions - Array of condition objects
|
|
106
|
+
* @returns {boolean} True if any condition matches
|
|
107
|
+
*/
|
|
108
|
+
static handleOr(document, conditions) {
|
|
109
|
+
if (!Array.isArray(conditions)) {
|
|
110
|
+
throw new QueryError('$or operator requires an array');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return conditions.some(condition => this.matchConditions(document, condition));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Deep equality comparison
|
|
118
|
+
* @param {*} a - First value
|
|
119
|
+
* @param {*} b - Second value
|
|
120
|
+
* @returns {boolean} True if values are deeply equal
|
|
121
|
+
*/
|
|
122
|
+
static deepEquals(a, b) {
|
|
123
|
+
if (a === b) return true;
|
|
124
|
+
|
|
125
|
+
if (a === null || b === null) return false;
|
|
126
|
+
if (a === undefined || b === undefined) return false;
|
|
127
|
+
|
|
128
|
+
if (typeof a !== typeof b) return false;
|
|
129
|
+
|
|
130
|
+
if (typeof a !== 'object') return false;
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(a)) {
|
|
135
|
+
if (a.length !== b.length) return false;
|
|
136
|
+
return a.every((item, index) => this.deepEquals(item, b[index]));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const keysA = Object.keys(a);
|
|
140
|
+
const keysB = Object.keys(b);
|
|
141
|
+
|
|
142
|
+
if (keysA.length !== keysB.length) return false;
|
|
143
|
+
|
|
144
|
+
return keysA.every(key => this.deepEquals(a[key], b[key]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Finds all documents matching a filter
|
|
149
|
+
* @param {Array} documents - The documents to search
|
|
150
|
+
* @param {Object} filter - The query filter
|
|
151
|
+
* @returns {Array} Matching documents
|
|
152
|
+
*/
|
|
153
|
+
static find(documents, filter = {}) {
|
|
154
|
+
return documents.filter(doc => this.match(doc, filter));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds the first document matching a filter
|
|
159
|
+
* @param {Array} documents - The documents to search
|
|
160
|
+
* @param {Object} filter - The query filter
|
|
161
|
+
* @returns {Object|null} The first matching document or null
|
|
162
|
+
*/
|
|
163
|
+
static findOne(documents, filter = {}) {
|
|
164
|
+
return documents.find(doc => this.match(doc, filter)) || null;
|
|
165
|
+
}
|
|
166
|
+
}
|