sorla-db 1.2.0 → 1.4.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,12 @@
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');
5
+ const { compileSchema, ValidationError } = require('../schema/schema.js');
3
6
 
4
- class manipulation {
7
+ class manipulation extends emitter {
5
8
  constructor(validationSchema = null) {
9
+ super();
6
10
  this.validationSchema = validationSchema;
7
11
  this._index = {};
8
12
  this._prohibitedCharacters = ['$', '.'];
@@ -21,34 +25,72 @@ class manipulation {
21
25
  }
22
26
 
23
27
  /**
24
- * Valid and set validation schema for collection
28
+ * Sets the validation schema of the collection (JSON Schema subset or a function).
29
+ * Documents inserted, updated or replaced afterwards must satisfy it; existing ones are not checked.
25
30
  * @author Anderson Arruda < anderson@sysborg.com.br >
26
- * @version 1.0.0
27
- * @param validationSchema
31
+ * @version 1.4.0
32
+ * @param object|function|null validationSchema
28
33
  * @return void
29
34
  **/
30
35
  set validationSchema(validationSchema)
31
36
  {
32
- if(typeof schemaValidation === 'undefined' || schemaValidation === null)
33
- return;
34
-
35
- if(['object', 'function'].indexOf(typeof schemaValidation) == -1)
37
+ if(validationSchema === null || typeof validationSchema === 'undefined')
36
38
  {
37
- throw new Error('Invalid schema validation');
39
+ this._schema = null;
40
+ return;
38
41
  }
39
42
 
40
- this._validationSchema = validationSchema;
43
+ if(['object', 'function'].indexOf(typeof validationSchema) === -1 || Array.isArray(validationSchema))
44
+ throw new Error('The validation schema must be an object (JSON Schema) or a function');
45
+
46
+ this._schema = compileSchema(validationSchema);
47
+ }
48
+
49
+ /**
50
+ * The schema given to the collection, or null
51
+ * @return object|function|null
52
+ */
53
+ get validationSchema()
54
+ {
55
+ return this._schema ? this._schema.source : null;
41
56
  }
42
57
 
43
58
  /**
44
- * Validate data
59
+ * @param object|function|null schema
60
+ * @return this
61
+ */
62
+ setSchema(schema)
63
+ {
64
+ this.validationSchema = schema;
65
+ return this;
66
+ }
67
+
68
+ getSchema()
69
+ {
70
+ return this.validationSchema;
71
+ }
72
+
73
+ /**
74
+ * Checks a document against the schema without storing it (e.g. to validate a form)
75
+ * @param object document
76
+ * @return object {valid, errors: [{path, rule, message}]}
77
+ */
78
+ validate(document)
79
+ {
80
+ const errors = this._schema ? this._schema.validate(document) : [];
81
+ return { valid: errors.length === 0, errors };
82
+ }
83
+
84
+ /**
85
+ * Throws a ValidationError when the data does not satisfy the schema
45
86
  * @param object data
46
- * @return boolean
87
+ * @return void
47
88
  */
48
89
  _validate(data)
49
90
  {
50
- //under construction
51
- return true;
91
+ const errors = this._schema ? this._schema.validate(data) : [];
92
+ if(errors.length)
93
+ throw new ValidationError(errors);
52
94
  }
53
95
 
54
96
  /**
@@ -124,6 +166,33 @@ class manipulation {
124
166
  * @return mixed the _id of the inserted document
125
167
  **/
126
168
  insertOne(document, callback)
169
+ {
170
+ const id = this._insertDocument(document);
171
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
172
+
173
+ if(typeof callback === 'function')
174
+ callback(id);
175
+
176
+ return id;
177
+ }
178
+
179
+ /**
180
+ * Validates and stores one document, without events
181
+ * @param object document
182
+ * @return mixed the _id
183
+ */
184
+ _insertDocument(document)
185
+ {
186
+ return this._store(this._prepare(document));
187
+ }
188
+
189
+ /**
190
+ * Copy of a document ready to be stored: _id, schema defaults, keys and schema checked
191
+ * @param object document
192
+ * @param Set reserved ids taken by the same batch
193
+ * @return object
194
+ */
195
+ _prepare(document, reserved = null)
127
196
  {
128
197
  if(!this._isDocument(document))
129
198
  throw new Error('Invalid document type');
@@ -131,27 +200,38 @@ class manipulation {
131
200
  if(!this._validateKeys(document))
132
201
  throw new Error('Invalid document keys');
133
202
 
203
+ const taken = id => this._ids().has(id) || (reserved !== null && reserved.has(id));
134
204
  const data = this._copy(document);
135
205
  if(typeof data._id === 'undefined')
136
206
  {
137
207
  do {
138
208
  data._id = this._generateKey();
139
- } while(this._ids().has(data._id));
209
+ } while(taken(data._id));
140
210
  }
141
- else if(this._ids().has(data._id))
211
+ else if(taken(data._id))
142
212
  {
143
213
  throw new Error(`Duplicate _id "${data._id}"`);
144
214
  }
145
215
 
146
- if(!this._validate(data))
147
- throw new Error('Invalid document');
216
+ if(this._schema)
217
+ {
218
+ this._schema.applyDefaults(data, value => this._copy(value));
219
+ if(!this._validateKeys(data))
220
+ throw new Error('Invalid document keys');
221
+ }
222
+
223
+ this._validate(data);
224
+ return data;
225
+ }
148
226
 
227
+ /**
228
+ * @param object data prepared document
229
+ * @return mixed the _id
230
+ */
231
+ _store(data)
232
+ {
149
233
  this._documents.push(new documents(data));
150
234
  this._ids().add(data._id);
151
-
152
- if(typeof callback === 'function')
153
- callback(data._id);
154
-
155
235
  return data._id;
156
236
  }
157
237
 
@@ -168,27 +248,242 @@ class manipulation {
168
248
  if(!Array.isArray(documents) || !documents.every(document => this._isDocument(document)))
169
249
  throw new Error('Documents must be an array of objects');
170
250
 
171
- const given = new Set();
172
- for(const document of documents)
251
+ // prepare (and validate) the whole batch before storing anything
252
+ const reserved = new Set();
253
+ const prepared = documents.map(document => {
254
+ const data = this._prepare(document, reserved);
255
+ reserved.add(data._id);
256
+ return data;
257
+ });
258
+
259
+ const start = this._documents.length;
260
+ const ids = prepared.map(data => this._store(data));
261
+ this._emitChange('insert', { ids, documents: this._documents.slice(start).map(doc => this._copy(doc._data)) });
262
+
263
+ if(typeof callback === 'function')
264
+ callback(ids);
265
+
266
+ return ids;
267
+ }
268
+
269
+ /**
270
+ * Helpers the update operators need
271
+ * @return object
272
+ */
273
+ _updateHelpers()
274
+ {
275
+ return {
276
+ copy: value => this._copy(value),
277
+ validateKeys: value => this._validateKeys(value),
278
+ operators: this._operators
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Document for an upsert: the equality conditions of the filter, then the update
284
+ * @param object filter
285
+ * @return object
286
+ */
287
+ _upsertBase(filter)
288
+ {
289
+ const base = {};
290
+ for(const [key, value] of Object.entries(filter))
173
291
  {
174
- if(!this._validateKeys(document))
175
- throw new Error('Invalid document keys');
292
+ // only top level equality conditions describe the new document
293
+ if(key.startsWith('$') || key.includes('.') || this._operators.isRegExp(value))
294
+ continue;
295
+
296
+ if(this._operators.isOperatorObject(value))
297
+ {
298
+ if('$eq' in value)
299
+ base[key] = value.$eq;
176
300
 
177
- if(typeof document._id === 'undefined')
178
301
  continue;
302
+ }
303
+
304
+ base[key] = value;
305
+ }
306
+ return base;
307
+ }
179
308
 
180
- if(given.has(document._id) || this._ids().has(document._id))
181
- throw new Error(`Duplicate _id "${document._id}"`);
309
+ /**
310
+ * Applies an update to the matching documents
311
+ * @param object filter
312
+ * @param object update
313
+ * @param object options {upsert}
314
+ * @param boolean many
315
+ * @return object {matchedCount, modifiedCount, upsertedId}
316
+ */
317
+ _update(filter, update, options, many)
318
+ {
319
+ this._requireFilter(filter);
320
+ const helpers = this._updateHelpers();
321
+ const changes = [];
182
322
 
183
- given.add(document._id);
323
+ // compute every new version first: one invalid update leaves all documents untouched
324
+ for(let i = 0; i < this._documents.length; i++)
325
+ {
326
+ const doc = this._documents[i];
327
+ if(!this._operators.matches(doc, filter))
328
+ continue;
329
+
330
+ const next = applyUpdate(doc._data, update, helpers);
331
+ this._validate(next);
332
+ changes.push({ index: i, previous: doc._data, next });
333
+ if(!many) break;
184
334
  }
185
335
 
186
- const ids = documents.map(document => this.insertOne(document));
336
+ if(!changes.length && options.upsert)
337
+ {
338
+ const data = applyUpdate(this._upsertBase(filter), update, helpers);
339
+ const id = this._insertDocument(data);
340
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
341
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: id };
342
+ }
187
343
 
188
- if(typeof callback === 'function')
189
- callback(ids);
344
+ const modified = changes.filter(change => JSON.stringify(change.previous) !== JSON.stringify(change.next));
345
+ for(const change of modified)
346
+ this._documents[change.index] = new documents(change.next);
190
347
 
191
- return ids;
348
+ if(modified.length)
349
+ {
350
+ this._emitChange('update', {
351
+ ids: modified.map(change => change.next._id),
352
+ documents: modified.map(change => this._copy(change.next)),
353
+ previous: modified.map(change => this._copy(change.previous))
354
+ });
355
+ }
356
+
357
+ return { matchedCount: changes.length, modifiedCount: modified.length, upsertedId: null };
358
+ }
359
+
360
+ /**
361
+ * Updates the first matching document: updateOne({name: 'Ana'}, {$set: {age: 30}, $inc: {visits: 1}})
362
+ * Operators: $set, $unset, $inc, $push (with $each), $pull. The _id cannot change.
363
+ * @param object filter
364
+ * @param object update
365
+ * @param object options {upsert: insert when nothing matches}
366
+ * @return object {matchedCount, modifiedCount, upsertedId}
367
+ */
368
+ updateOne(filter, update, options = {})
369
+ {
370
+ return this._update(filter, update, options, false);
371
+ }
372
+
373
+ /**
374
+ * Updates every matching document, see updateOne()
375
+ * @param object filter
376
+ * @param object update
377
+ * @param object options {upsert}
378
+ * @return object {matchedCount, modifiedCount, upsertedId}
379
+ */
380
+ updateMany(filter, update, options = {})
381
+ {
382
+ return this._update(filter, update, options, true);
383
+ }
384
+
385
+ /**
386
+ * Replaces the first matching document, keeping its _id
387
+ * @param object filter
388
+ * @param object replacement a document without update operators
389
+ * @param object options {upsert}
390
+ * @return object {matchedCount, modifiedCount, upsertedId}
391
+ */
392
+ replaceOne(filter, replacement, options = {})
393
+ {
394
+ this._requireFilter(filter);
395
+ if(!this._isDocument(replacement))
396
+ throw new Error('The replacement must be a document');
397
+
398
+ if(Object.keys(replacement).some(key => key.startsWith('$')))
399
+ throw new Error('The replacement cannot contain update operators, use updateOne()');
400
+
401
+ if(!this._validateKeys(replacement))
402
+ throw new Error('Invalid document keys');
403
+
404
+ const index = this._documents.findIndex(doc => this._operators.matches(doc, filter));
405
+ if(index === -1)
406
+ {
407
+ if(!options.upsert)
408
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: null };
409
+
410
+ const id = this._insertDocument(replacement);
411
+ this._emitChange('insert', { ids: [id], documents: [this._copy(this._documents.at(-1)._data)] });
412
+ return { matchedCount: 0, modifiedCount: 0, upsertedId: id };
413
+ }
414
+
415
+ const previous = this._documents[index]._data;
416
+ if(typeof replacement._id !== 'undefined' && replacement._id !== previous._id)
417
+ throw new Error('The _id field cannot be changed');
418
+
419
+ const next = { ...this._copy(replacement), _id: previous._id };
420
+ this._validate(next);
421
+ const modified = JSON.stringify(previous) !== JSON.stringify(next);
422
+ if(modified)
423
+ {
424
+ this._documents[index] = new documents(next);
425
+ this._emitChange('replace', { ids: [next._id], documents: [this._copy(next)], previous: [this._copy(previous)] });
426
+ }
427
+
428
+ return { matchedCount: 1, modifiedCount: modified ? 1 : 0, upsertedId: null };
429
+ }
430
+
431
+ /**
432
+ * Removes matching documents, keeping the documents array in place
433
+ * @param object filter
434
+ * @param boolean many
435
+ * @return object {deletedCount}
436
+ */
437
+ _delete(filter, many)
438
+ {
439
+ this._requireFilter(filter);
440
+ const removed = [];
441
+ let write = 0;
442
+ for(let read = 0; read < this._documents.length; read++)
443
+ {
444
+ const doc = this._documents[read];
445
+ if((many || !removed.length) && this._operators.matches(doc, filter))
446
+ removed.push(doc._data);
447
+ else
448
+ this._documents[write++] = doc;
449
+ }
450
+ this._documents.length = write;
451
+
452
+ if(removed.length)
453
+ {
454
+ for(const data of removed)
455
+ this._idSet?.delete(data._id);
456
+
457
+ this._emitChange('delete', { ids: removed.map(data => data._id), documents: removed });
458
+ }
459
+
460
+ return { deletedCount: removed.length };
461
+ }
462
+
463
+ /**
464
+ * Deletes the first matching document
465
+ * @param object filter
466
+ * @return object {deletedCount}
467
+ */
468
+ deleteOne(filter)
469
+ {
470
+ return this._delete(filter, false);
471
+ }
472
+
473
+ /**
474
+ * Deletes every matching document; deleteMany({}) empties the collection
475
+ * @param object filter
476
+ * @return object {deletedCount}
477
+ */
478
+ deleteMany(filter)
479
+ {
480
+ return this._delete(filter, true);
481
+ }
482
+
483
+ _requireFilter(filter)
484
+ {
485
+ if(!this._isDocument(filter))
486
+ throw new Error('The filter must be a query object, use {} to match every document');
192
487
  }
193
488
 
194
489
  //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
  /**