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.
@@ -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
+ }