sorla-db 1.1.0 → 1.3.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.
@@ -1,8 +1,11 @@
1
1
  const documents = require('../documents/document.js');
2
2
  const operatorsCheck = require('./operators.js');
3
+ const emitter = require('../events.js');
4
+ const { applyUpdate } = require('./update.js');
3
5
 
4
- class manipulation {
6
+ class manipulation extends emitter {
5
7
  constructor(validationSchema = null) {
8
+ super();
6
9
  this.validationSchema = validationSchema;
7
10
  this._index = {};
8
11
  this._prohibitedCharacters = ['$', '.'];
@@ -52,106 +55,374 @@ class manipulation {
52
55
  }
53
56
 
54
57
  /**
55
- * Verify if exists some document that is not object
56
- * @param array documents
58
+ * Whether a value is a document: an object that is not null nor an array
59
+ * @param mixed value
57
60
  * @return boolean
58
61
  */
59
- _validateDocumentsType(documents)
62
+ _isDocument(value)
60
63
  {
61
- const count = documents.reduce((acc, doc) => {
62
- return typeof doc !== 'object' ? ++acc : acc;
63
- }, 0);
64
-
65
- return count;
64
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
66
65
  }
67
66
 
68
67
  /**
69
- * Verify if keys has prohibited characters
68
+ * Verify that no key, at any depth, has prohibited characters
70
69
  * @param object document
71
70
  * @return boolean
72
71
  */
73
72
  _validateKeys(document)
74
73
  {
75
- const keys = Object.keys(document);
76
74
  const operators = new operatorsCheck();
77
- for(let key of keys)
75
+ for(const key of Object.keys(document))
78
76
  {
79
- const hasProhibitedCharacters = this._prohibitedCharacters.reduce((acc, char) => {
80
- return key.indexOf(char) > -1 ? ++acc : acc;
81
- }, 0);
82
-
83
- if(hasProhibitedCharacters > 0 && !operators.isOperator(key))
77
+ const hasProhibitedCharacters = this._prohibitedCharacters.some(char => key.indexOf(char) > -1);
78
+ if(hasProhibitedCharacters && !operators.isOperator(key))
84
79
  return false;
85
80
 
86
- if(typeof document[key] === 'object')
87
- return this._validateKeys(document[key]);
88
-
89
- if(Array.isArray(document[key]))
81
+ const value = document[key];
82
+ const nested = Array.isArray(value) ? value : [value];
83
+ for(const item of nested)
90
84
  {
91
- for(let value of document[key])
92
- {
93
- if(typeof value === 'object')
94
- return this._validateKeys(value);
95
- }
85
+ // null is a valid value, only objects have keys to check
86
+ if(item !== null && typeof item === 'object' && !this._validateKeys(item))
87
+ return false;
96
88
  }
97
89
  }
98
90
 
99
91
  return true;
100
92
  }
101
93
 
94
+ /**
95
+ * Set of the _id values of the collection. It is rebuilt when its size no longer matches the
96
+ * documents, so removals done directly on the documents array are handled too.
97
+ * @return Set
98
+ */
99
+ _ids()
100
+ {
101
+ if(!this._idSet || this._idSet.size !== this._documents.length)
102
+ this._idSet = new Set(this._documents.map(doc => doc._data._id));
103
+
104
+ return this._idSet;
105
+ }
106
+
107
+ /**
108
+ * Copies a document, so the object given by the caller is never changed
109
+ * @param object document
110
+ * @return object
111
+ */
112
+ _copy(document)
113
+ {
114
+ try {
115
+ return structuredClone(document);
116
+ } catch(error) {
117
+ throw new Error('Documents can only contain cloneable values (no functions or DOM nodes)');
118
+ }
119
+ }
120
+
102
121
  /**
103
122
  * Inserts one document to the collection
104
123
  * @author Anderson Arruda < anderson@sysborg.com.br >
105
- * @version 1.0.0
124
+ * @version 1.2.0
106
125
  * @param object document
107
- * @param function callback - under construction
108
- * @return void
126
+ * @param function callback - receives the inserted _id
127
+ * @return mixed the _id of the inserted document
109
128
  **/
110
129
  insertOne(document, callback)
111
130
  {
112
- if(typeof document !== 'object' || Array.isArray(document))
131
+ const id = this._insertDocument(document);
132
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
133
+
134
+ if(typeof callback === 'function')
135
+ callback(id);
136
+
137
+ return id;
138
+ }
139
+
140
+ /**
141
+ * Validates and stores one document, without events
142
+ * @param object document
143
+ * @return mixed the _id
144
+ */
145
+ _insertDocument(document)
146
+ {
147
+ if(!this._isDocument(document))
113
148
  throw new Error('Invalid document type');
114
149
 
115
150
  if(!this._validateKeys(document))
116
151
  throw new Error('Invalid document keys');
117
152
 
118
- if(typeof document._id === 'undefined')
153
+ const data = this._copy(document);
154
+ if(typeof data._id === 'undefined')
119
155
  {
120
- let _idKey = '';
121
156
  do {
122
- _idKey = this._generateKey();
123
- } while(this._documents.find(doc => doc._id === document._id));
124
-
125
- document._id = _idKey;
157
+ data._id = this._generateKey();
158
+ } while(this._ids().has(data._id));
159
+ }
160
+ else if(this._ids().has(data._id))
161
+ {
162
+ throw new Error(`Duplicate _id "${data._id}"`);
126
163
  }
127
164
 
128
- if(!this._validate(document))
165
+ if(!this._validate(data))
129
166
  throw new Error('Invalid document');
130
167
 
131
- this._documents.push(new documents(document));
132
-
133
- if(typeof callback === 'function')
134
- callback();
168
+ this._documents.push(new documents(data));
169
+ this._ids().add(data._id);
170
+ return data._id;
135
171
  }
136
172
 
137
173
  /**
138
- * Insert many documents
174
+ * Insert many documents. Every document is validated before the first one is inserted,
175
+ * so an invalid document never leaves the collection with only part of the batch.
139
176
  * @author Anderson Arruda < anderson@sysborg.com.br >
140
- * @version 1.0.0
177
+ * @version 1.2.0
141
178
  * @param array documents
142
- * @param function callback
143
- * @return void
179
+ * @param function callback - receives the inserted ids
180
+ * @return array the _id of each inserted document
144
181
  */
145
182
  insertMany(documents, callback) {
146
- console.info(this._validateDocumentsType(documents));
147
- if(!Array.isArray(documents) || this._validateDocumentsType(documents) > 0)
183
+ if(!Array.isArray(documents) || !documents.every(document => this._isDocument(document)))
148
184
  throw new Error('Documents must be an array of objects');
149
185
 
150
- for(let document of documents)
151
- this.insertOne(document);
186
+ const given = new Set();
187
+ for(const document of documents)
188
+ {
189
+ if(!this._validateKeys(document))
190
+ throw new Error('Invalid document keys');
191
+
192
+ if(typeof document._id === 'undefined')
193
+ continue;
194
+
195
+ if(given.has(document._id) || this._ids().has(document._id))
196
+ throw new Error(`Duplicate _id "${document._id}"`);
197
+
198
+ given.add(document._id);
199
+ }
200
+
201
+ const start = this._documents.length;
202
+ const ids = documents.map(document => this._insertDocument(document));
203
+ this._emitChange('insert', { ids, documents: this._documents.slice(start).map(doc => this._copy(doc._data)) });
152
204
 
153
205
  if(typeof callback === 'function')
154
- callback();
206
+ callback(ids);
207
+
208
+ return ids;
209
+ }
210
+
211
+ /**
212
+ * Helpers the update operators need
213
+ * @return object
214
+ */
215
+ _updateHelpers()
216
+ {
217
+ return {
218
+ copy: value => this._copy(value),
219
+ validateKeys: value => this._validateKeys(value),
220
+ operators: this._operators
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Document for an upsert: the equality conditions of the filter, then the update
226
+ * @param object filter
227
+ * @return object
228
+ */
229
+ _upsertBase(filter)
230
+ {
231
+ const base = {};
232
+ for(const [key, value] of Object.entries(filter))
233
+ {
234
+ // only top level equality conditions describe the new document
235
+ if(key.startsWith('$') || key.includes('.') || this._operators.isRegExp(value))
236
+ continue;
237
+
238
+ if(this._operators.isOperatorObject(value))
239
+ {
240
+ if('$eq' in value)
241
+ base[key] = value.$eq;
242
+
243
+ continue;
244
+ }
245
+
246
+ base[key] = value;
247
+ }
248
+ return base;
249
+ }
250
+
251
+ /**
252
+ * Applies an update to the matching documents
253
+ * @param object filter
254
+ * @param object update
255
+ * @param object options {upsert}
256
+ * @param boolean many
257
+ * @return object {matchedCount, modifiedCount, upsertedId}
258
+ */
259
+ _update(filter, update, options, many)
260
+ {
261
+ this._requireFilter(filter);
262
+ const helpers = this._updateHelpers();
263
+ const changes = [];
264
+
265
+ // compute every new version first: one invalid update leaves all documents untouched
266
+ for(let i = 0; i < this._documents.length; i++)
267
+ {
268
+ const doc = this._documents[i];
269
+ if(!this._operators.matches(doc, filter))
270
+ continue;
271
+
272
+ changes.push({ index: i, previous: doc._data, next: applyUpdate(doc._data, update, helpers) });
273
+ if(!many) break;
274
+ }
275
+
276
+ if(!changes.length && options.upsert)
277
+ {
278
+ const data = applyUpdate(this._upsertBase(filter), update, helpers);
279
+ const id = this._insertDocument(data);
280
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
281
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: id };
282
+ }
283
+
284
+ const modified = changes.filter(change => JSON.stringify(change.previous) !== JSON.stringify(change.next));
285
+ for(const change of modified)
286
+ this._documents[change.index] = new documents(change.next);
287
+
288
+ if(modified.length)
289
+ {
290
+ this._emitChange('update', {
291
+ ids: modified.map(change => change.next._id),
292
+ documents: modified.map(change => this._copy(change.next)),
293
+ previous: modified.map(change => this._copy(change.previous))
294
+ });
295
+ }
296
+
297
+ return { matchedCount: changes.length, modifiedCount: modified.length, upsertedId: null };
298
+ }
299
+
300
+ /**
301
+ * Updates the first matching document: updateOne({name: 'Ana'}, {$set: {age: 30}, $inc: {visits: 1}})
302
+ * Operators: $set, $unset, $inc, $push (with $each), $pull. The _id cannot change.
303
+ * @param object filter
304
+ * @param object update
305
+ * @param object options {upsert: insert when nothing matches}
306
+ * @return object {matchedCount, modifiedCount, upsertedId}
307
+ */
308
+ updateOne(filter, update, options = {})
309
+ {
310
+ return this._update(filter, update, options, false);
311
+ }
312
+
313
+ /**
314
+ * Updates every matching document, see updateOne()
315
+ * @param object filter
316
+ * @param object update
317
+ * @param object options {upsert}
318
+ * @return object {matchedCount, modifiedCount, upsertedId}
319
+ */
320
+ updateMany(filter, update, options = {})
321
+ {
322
+ return this._update(filter, update, options, true);
323
+ }
324
+
325
+ /**
326
+ * Replaces the first matching document, keeping its _id
327
+ * @param object filter
328
+ * @param object replacement a document without update operators
329
+ * @param object options {upsert}
330
+ * @return object {matchedCount, modifiedCount, upsertedId}
331
+ */
332
+ replaceOne(filter, replacement, options = {})
333
+ {
334
+ this._requireFilter(filter);
335
+ if(!this._isDocument(replacement))
336
+ throw new Error('The replacement must be a document');
337
+
338
+ if(Object.keys(replacement).some(key => key.startsWith('$')))
339
+ throw new Error('The replacement cannot contain update operators, use updateOne()');
340
+
341
+ if(!this._validateKeys(replacement))
342
+ throw new Error('Invalid document keys');
343
+
344
+ const index = this._documents.findIndex(doc => this._operators.matches(doc, filter));
345
+ if(index === -1)
346
+ {
347
+ if(!options.upsert)
348
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: null };
349
+
350
+ const id = this._insertDocument(replacement);
351
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
352
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: id };
353
+ }
354
+
355
+ const previous = this._documents[index]._data;
356
+ if(typeof replacement._id !== 'undefined' && replacement._id !== previous._id)
357
+ throw new Error('The _id field cannot be changed');
358
+
359
+ const next = { ...this._copy(replacement), _id: previous._id };
360
+ const modified = JSON.stringify(previous) !== JSON.stringify(next);
361
+ if(modified)
362
+ {
363
+ this._documents[index] = new documents(next);
364
+ this._emitChange('replace', { ids: [next._id], documents: [this._copy(next)], previous: [this._copy(previous)] });
365
+ }
366
+
367
+ return { matchedCount: 1, modifiedCount: modified ? 1 : 0, upsertedId: null };
368
+ }
369
+
370
+ /**
371
+ * Removes matching documents, keeping the documents array in place
372
+ * @param object filter
373
+ * @param boolean many
374
+ * @return object {deletedCount}
375
+ */
376
+ _delete(filter, many)
377
+ {
378
+ this._requireFilter(filter);
379
+ const removed = [];
380
+ let write = 0;
381
+ for(let read = 0; read < this._documents.length; read++)
382
+ {
383
+ const doc = this._documents[read];
384
+ if((many || !removed.length) && this._operators.matches(doc, filter))
385
+ removed.push(doc._data);
386
+ else
387
+ this._documents[write++] = doc;
388
+ }
389
+ this._documents.length = write;
390
+
391
+ if(removed.length)
392
+ {
393
+ for(const data of removed)
394
+ this._idSet?.delete(data._id);
395
+
396
+ this._emitChange('delete', { ids: removed.map(data => data._id), documents: removed });
397
+ }
398
+
399
+ return { deletedCount: removed.length };
400
+ }
401
+
402
+ /**
403
+ * Deletes the first matching document
404
+ * @param object filter
405
+ * @return object {deletedCount}
406
+ */
407
+ deleteOne(filter)
408
+ {
409
+ return this._delete(filter, false);
410
+ }
411
+
412
+ /**
413
+ * Deletes every matching document; deleteMany({}) empties the collection
414
+ * @param object filter
415
+ * @return object {deletedCount}
416
+ */
417
+ deleteMany(filter)
418
+ {
419
+ return this._delete(filter, true);
420
+ }
421
+
422
+ _requireFilter(filter)
423
+ {
424
+ if(!this._isDocument(filter))
425
+ throw new Error('The filter must be a query object, use {} to match every document');
155
426
  }
156
427
 
157
428
  //create index
@@ -4,7 +4,7 @@ class operators {
4
4
  */
5
5
  constructor() {
6
6
  this._logicalOperators = ['$or', '$and', '$not', '$nor'];
7
- this._comparisonOperators = ['$lt', '$lte', '$gt', '$gte', '$eq', '$ne', '$in', '$nin'];
7
+ this._comparisonOperators = ['$lt', '$lte', '$gt', '$gte', '$eq', '$ne', '$in', '$nin', '$exists', '$regex', '$options'];
8
8
  }
9
9
 
10
10
  /**
@@ -94,9 +94,15 @@ class operators {
94
94
  * @return boolean
95
95
  */
96
96
  matchesCondition(value, condition) {
97
+ if(this.isRegExp(condition))
98
+ return this.matchesRegex(value, condition);
99
+
97
100
  if(!this.isOperatorObject(condition))
98
101
  return this.$eq(value, condition);
99
102
 
103
+ if('$options' in condition && !('$regex' in condition))
104
+ throw new Error('The $options operator must be used with $regex');
105
+
100
106
  for(const operator of Object.keys(condition)) {
101
107
  if(operator === '$not') {
102
108
  if(this.matchesCondition(value, condition[operator]))
@@ -105,6 +111,16 @@ class operators {
105
111
  continue;
106
112
  }
107
113
 
114
+ if(operator === '$options')
115
+ continue;
116
+
117
+ if(operator === '$regex') {
118
+ if(!this.matchesRegex(value, this.toRegExp(condition.$regex, condition.$options)))
119
+ return false;
120
+
121
+ continue;
122
+ }
123
+
108
124
  if(!this.isOperator(operator))
109
125
  throw new Error(`Unknown operator ${operator}`);
110
126
 
@@ -115,6 +131,45 @@ class operators {
115
131
  return true;
116
132
  }
117
133
 
134
+ /**
135
+ * Whether a value is a RegExp (also from another realm: iframes, workers, vm contexts)
136
+ * @param mixed value
137
+ * @return boolean
138
+ */
139
+ isRegExp(value) {
140
+ return Object.prototype.toString.call(value) === '[object RegExp]';
141
+ }
142
+
143
+ /**
144
+ * Builds the RegExp of a $regex condition
145
+ * @param string|RegExp pattern
146
+ * @param string options i (case insensitive), m (multiline), s (dot matches new lines)
147
+ * @return RegExp
148
+ */
149
+ toRegExp(pattern, options = '') {
150
+ if(typeof options !== 'string' || /[^ims]/.test(options))
151
+ throw new Error('The $options operator accepts only the flags i, m and s');
152
+
153
+ if(this.isRegExp(pattern))
154
+ return new RegExp(pattern.source, [...new Set((pattern.flags + options).replace(/[gy]/g, ''))].join(''));
155
+
156
+ if(typeof pattern !== 'string')
157
+ throw new Error('The $regex operator must be a string or a RegExp');
158
+
159
+ return new RegExp(pattern, options);
160
+ }
161
+
162
+ /**
163
+ * Strings match when the pattern finds them, arrays when one of their strings does
164
+ * @param mixed value
165
+ * @param RegExp regex
166
+ * @return boolean
167
+ */
168
+ matchesRegex(value, regex) {
169
+ const test = item => typeof item === 'string' && (regex.lastIndex = 0, regex.test(item));
170
+ return Array.isArray(value) ? value.some(test) : test(value);
171
+ }
172
+
118
173
  /**
119
174
  * Whether a condition is an object made of operators, like {$gt: 5}
120
175
  * @param mixed condition
@@ -128,6 +183,16 @@ class operators {
128
183
  return keys.length > 0 && keys.every(key => key.startsWith('$'));
129
184
  }
130
185
 
186
+ /**
187
+ * Exists operator: {field: {$exists: true}} matches documents that have the field (even null)
188
+ * @param mixed doc_value
189
+ * @param boolean exists
190
+ * @return boolean
191
+ */
192
+ get $exists() {
193
+ return (doc_value, exists) => (doc_value !== undefined) === !!exists;
194
+ }
195
+
131
196
  /**
132
197
  * Not in operator
133
198
  * @param mixed doc_value
@@ -14,9 +14,13 @@ class query extends manipulation
14
14
  * Count documents
15
15
  * @return int
16
16
  */
17
- count()
17
+ count(query)
18
18
  {
19
- return this._documents.length;
19
+ if(typeof query === 'undefined')
20
+ return this._documents.length;
21
+
22
+ // counts without copying the documents
23
+ return this._documents.reduce((total, doc) => total + (this._operators.matches(doc, query) ? 1 : 0), 0);
20
24
  }
21
25
 
22
26
  /**