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.
- package/README.md +152 -9
- package/dist/sorla.min.js +1 -1
- package/index.d.ts +157 -0
- package/index.mjs +5 -0
- package/package.json +27 -3
- package/src/dbs/database.js +48 -0
- package/src/documents/cursor.js +141 -25
- package/src/documents/document.js +26 -30
- package/src/events.js +69 -0
- package/src/query_language/manipulation.js +321 -50
- package/src/query_language/operators.js +66 -1
- package/src/query_language/query.js +6 -2
- package/src/query_language/update.js +167 -0
- package/src/sorla.js +27 -0
- package/src/utils.js +9 -8
- package/Brainstorming.md +0 -22
- package/debug-not.js +0 -24
- package/example/simple_data_example.html +0 -196
- package/index.js +0 -62
- package/src/helper.js +0 -41
- package/test/feature_collection.test.js +0 -75
- package/test/feature_collection_queries.test.js +0 -224
- package/test/feature_cursor.test.js +0 -84
- package/test/feature_database.test.js +0 -62
- package/test/feature_insert.test.js +0 -116
- package/test/feature_nested_queries.test.js +0 -94
- package/test/feature_query_equivalence.test.js +0 -165
- package/test/unit_database.test.js +0 -36
- package/test/unit_document.test.js +0 -39
- package/test/unit_operators.test.js +0 -78
- package/test/unit_sorla.test.js +0 -42
- package/test/unit_test.test.js +0 -25
- package/webpack.config.js +0 -11
|
@@ -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
|
-
*
|
|
56
|
-
* @param
|
|
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
|
-
|
|
62
|
+
_isDocument(value)
|
|
60
63
|
{
|
|
61
|
-
|
|
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
|
|
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(
|
|
75
|
+
for(const key of Object.keys(document))
|
|
78
76
|
{
|
|
79
|
-
const hasProhibitedCharacters = this._prohibitedCharacters.
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
124
|
+
* @version 1.2.0
|
|
106
125
|
* @param object document
|
|
107
|
-
* @param function callback -
|
|
108
|
-
* @return
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
const data = this._copy(document);
|
|
154
|
+
if(typeof data._id === 'undefined')
|
|
119
155
|
{
|
|
120
|
-
let _idKey = '';
|
|
121
156
|
do {
|
|
122
|
-
|
|
123
|
-
} while(this.
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
165
|
+
if(!this._validate(data))
|
|
129
166
|
throw new Error('Invalid document');
|
|
130
167
|
|
|
131
|
-
this._documents.push(new documents(
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
177
|
+
* @version 1.2.0
|
|
141
178
|
* @param array documents
|
|
142
|
-
* @param function callback
|
|
143
|
-
* @return
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
/**
|