sorla-db 1.2.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 +85 -0
- package/dist/sorla.min.js +1 -1
- package/index.d.ts +72 -4
- package/package.json +1 -1
- package/src/dbs/database.js +48 -0
- package/src/documents/cursor.js +61 -9
- package/src/events.js +69 -0
- package/src/query_language/manipulation.js +240 -6
- 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/README.md
CHANGED
|
@@ -100,6 +100,75 @@ using the `Intl.Collator` options:
|
|
|
100
100
|
cursor.sort({ name: 1 }, { collation: { locale: 'pt-BR', sensitivity: 'base', numeric: true } });
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
## Paging and projection
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
collection.find({}).sort({ age: 1 }).skip(20).limit(10).toArray();
|
|
107
|
+
collection.find({}, { name: 1, age: 1 }); // only name, age and _id
|
|
108
|
+
collection.find({}, { name: 1, _id: 0 }); // only name
|
|
109
|
+
collection.find({}, { password: 0 }); // everything except password
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`limit(0)` means no limit. Inclusion and exclusion cannot be mixed (except `_id`), and projection
|
|
113
|
+
works on top level fields.
|
|
114
|
+
|
|
115
|
+
## Updating documents
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
collection.updateOne({ _id: id }, { $set: { 'address.city': 'Recife' }, $inc: { visits: 1 } });
|
|
119
|
+
collection.updateMany({ active: false }, { $unset: { token: '' } });
|
|
120
|
+
collection.updateOne({ email: 'a@b.c' }, { $set: { name: 'Ana' } }, { upsert: true });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Operator | Effect |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `$set` | sets fields, creating missing objects of `"a.b"` paths |
|
|
126
|
+
| `$unset` | removes fields |
|
|
127
|
+
| `$inc` | adds a number (missing fields start at 0) |
|
|
128
|
+
| `$push` | appends to an array, `{ $push: { tags: { $each: ['a', 'b'] } } }` appends several |
|
|
129
|
+
| `$pull` | removes array items equal to a value, matching a condition (`{ $in: [...] }`) or a sub document (`{ ok: false }`) |
|
|
130
|
+
|
|
131
|
+
They return `{ matchedCount, modifiedCount, upsertedId }`. Updates are applied to a copy first: if an
|
|
132
|
+
operator fails (for example `$inc` on a text field) no document changes. `_id` cannot change. With
|
|
133
|
+
`{ upsert: true }`, a document is inserted from the equality conditions of the filter when nothing matches.
|
|
134
|
+
|
|
135
|
+
`replaceOne(filter, document)` replaces the whole document and keeps its `_id`.
|
|
136
|
+
|
|
137
|
+
## Deleting and counting
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
collection.deleteOne({ _id: id }); // { deletedCount: 1 }
|
|
141
|
+
collection.deleteMany({ age: { $lt: 18 } });
|
|
142
|
+
collection.deleteMany({}); // empties the collection
|
|
143
|
+
collection.count(); // every document
|
|
144
|
+
collection.count({ active: true }); // matching documents, without copying them
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Change events
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
const off = collection.on('change', event => {
|
|
151
|
+
// event.type: 'insert' | 'update' | 'replace' | 'delete'
|
|
152
|
+
// event.ids, event.documents (copies), event.previous (before update / replace)
|
|
153
|
+
});
|
|
154
|
+
collection.on('delete', event => console.log(event.ids));
|
|
155
|
+
off();
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`insertMany` and `updateMany` emit one event for the whole operation, and nothing is emitted when nothing
|
|
159
|
+
changed. A listener that throws does not undo or interrupt the write.
|
|
160
|
+
|
|
161
|
+
## Databases and collections
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
sorla.listDatabases(); // ['tags', ...]
|
|
165
|
+
sorla.dropDb('tags');
|
|
166
|
+
sorla.db.listCollections(); // ['updatedTags', ...]
|
|
167
|
+
sorla.db.getCollection('stats'); // any collection, also when its name shadows a method
|
|
168
|
+
sorla.db.dropCollection('updatedTags');
|
|
169
|
+
sorla.db.getStats(); // { name, documents, collections: [{ name, documents }] }
|
|
170
|
+
```
|
|
171
|
+
|
|
103
172
|
## Find Operators
|
|
104
173
|
|
|
105
174
|
### $and
|
|
@@ -196,6 +265,22 @@ A document without the queried field does not match `$eq`, `$gt`, `$in`... but d
|
|
|
196
265
|
|
|
197
266
|
Unknown operators and logical operators that do not receive an array throw an error.
|
|
198
267
|
|
|
268
|
+
### $regex
|
|
269
|
+
Matches strings with a regular expression. `$options` takes the flags `i` (case insensitive), `m` and `s`.
|
|
270
|
+
A RegExp can be used directly as the value. Array fields match when one of their strings does.
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
const results = window.sorla.db.updatedTags.find({ name: { $regex: '^an', $options: 'i' } });
|
|
274
|
+
const same = window.sorla.db.updatedTags.find({ name: /^an/i });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### $exists
|
|
278
|
+
`{ field: { $exists: true } }` matches documents that have the field, even when it is `null`;
|
|
279
|
+
`false` matches documents without it.
|
|
280
|
+
|
|
281
|
+
### Arrays
|
|
282
|
+
Equality is strict: `{ tags: 'dev' }` does not match `tags: ['dev']`. Use `{ tags: { $in: ['dev'] } }`.
|
|
283
|
+
|
|
199
284
|
## Comparison operators
|
|
200
285
|
|
|
201
286
|
### $eq
|
package/dist/sorla.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
!function r(t,e,n){function o(i,a){if(!e[i]){if(!t[i]){var c="function"==typeof require&&require;if(!a&&c)return c(i,!0);if(s)return s(i,!0);var u=new Error("Cannot find module '"+i+"'");throw u.code="MODULE_NOT_FOUND",u}var l=e[i]={exports:{}};t[i][0].call(l.exports,(function(e){return o(t[i][1][e]||e)}),l,l.exports,r,t,e,n)}return e[i].exports}for(var s="function"==typeof require&&require,i=0;i<n.length;i++)o(n[i]);return o}({1:[function(t,e,n){const s=t("../query_language/query.js");e.exports=class extends s{constructor(t){super(t),this._documents=[],this._indexes=[]}get first(){return structuredClone(this._documents[0]._data)}get last(){return structuredClone(this._documents[this._documents.length-1]._data)}get all(){return this._documents}}},{"../query_language/query.js":7}],2:[function(t,e,n){const s=t("../collections/collection.js");e.exports=class{constructor(t){return this._collections={},this._name=t,this._collectionCount=0,new Proxy(this,{get:(t,e,n)=>e in t._collections?t._collections[e]:t[e]})}get name(){return this._name}createCollection(t,e){if(this._collections.hasOwnProperty(t)||void 0!==this[t])throw new Error("Collection already exists");this._collections[t]=new s(e),this._collectionCount++}countCollections(){return this._collectionCount}}},{"../collections/collection.js":1}],3:[function(t,e,n){function typeRank(t){return null==t?0:"number"==typeof t?1:"string"==typeof t?2:"[object Date]"===Object.prototype.toString.call(t)?6:Array.isArray(t)?4:"object"==typeof t?3:"boolean"==typeof t?5:7}function compareValues(t,e,n){const s=typeRank(t),i=typeRank(e);if(s!==i)return s<i?-1:1;switch(s){case 0:return 0;case 2:if(n)return Math.sign(n.compare(t,e));break;case 3:case 4:t=JSON.stringify(t),e=JSON.stringify(e);break;case 6:t=t.getTime(),e=e.getTime()}return t<e?-1:t>e?1:0}e.exports=class{constructor(t,e){this._data=void 0===t?[]:t,this.currIndex=0,"object"==typeof e&&this.projection(e)}hasNext(){return this.currIndex<this._data.length}next(){if(!this.hasNext())throw new Error("No more item to the cursor");return this._data[this.currIndex++]}toArray(){return this._data.map((t=>structuredClone(t._data)))}reset(){this.currIndex=0}first(){return this._data.length?this._data[0]._data:null}last(){return this._data.length?this._data[this._data.length-1]._data:null}sort(t,e={}){if(null===t||"object"!=typeof t||Array.isArray(t))throw new Error("The sort query must be an object like {field: 1}");const n=Object.keys(t);for(const e of n)if(1!==t[e]&&-1!==t[e])throw new Error(`Sort direction of "${e}" must be 1 or -1`);const s=e.collation?new Intl.Collator(e.collation.locale,e.collation):null;return this._data.sort(((e,i)=>{for(const a of n){const n=compareValues(e[a],i[a],s);if(0!==n)return n*t[a]}return 0})),this}get length(){return this._data.length}projection(t){if(null===t||"object"!=typeof t)return;const e=this;this._data.forEach((n=>{for(const s of Object.keys(t))0===t[s]&&n.deleteProp(s,e)}))}push(t){if("object"!=typeof t)throw new Error("The element must be an object");this._data.push(t)}}},{}],4:[function(t,e,n){const s=t("../utils");class documents{constructor(t){return this._data=t,this._utils=new s,this._cloned=!1,new Proxy(this,{deleteProperty:(t,e)=>!(!t._cloned||!(e in t._data))&&(delete t._data[e],!0),get:(t,e)=>"string"!=typeof e?t[e]:e.indexOf(".")>-1?t._utils.getDotData(t._data,e):e in t._data?t._data[e]:t[e],set:(t,e,n)=>{if("_cloned"===e)return t._cloned=n,!0;if("string"==typeof e&&e in t._data)throw new Error(`Document fields are read-only ("${e}"), change documents through the collection`);return!1}})}clone(){const t=new documents(structuredClone(this._data));return t._cloned=!0,t}deleteProp(t){return!!this._cloned&&Reflect.deleteProperty(this,t)}}e.exports=documents},{"../utils":9}],5:[function(t,e,n){const s=t("../documents/document.js"),i=t("./operators.js");e.exports=class{constructor(t=null){this.validationSchema=t,this._index={},this._prohibitedCharacters=["$","."]}_generateKey(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){var e=16*Math.random()|0;return("x"===t?e:3&e|8).toString(16)}))}set validationSchema(t){if("undefined"!=typeof schemaValidation&&null!==schemaValidation){if(-1==["object","function"].indexOf(typeof schemaValidation))throw new Error("Invalid schema validation");this._validationSchema=t}}_validate(t){return!0}_isDocument(t){return null!==t&&"object"==typeof t&&!Array.isArray(t)}_validateKeys(t){const e=new i;for(const n of Object.keys(t)){if(this._prohibitedCharacters.some((t=>n.indexOf(t)>-1))&&!e.isOperator(n))return!1;const s=t[n],i=Array.isArray(s)?s:[s];for(const t of i)if(null!==t&&"object"==typeof t&&!this._validateKeys(t))return!1}return!0}_ids(){return this._idSet&&this._idSet.size===this._documents.length||(this._idSet=new Set(this._documents.map((t=>t._data._id)))),this._idSet}_copy(t){try{return structuredClone(t)}catch(t){throw new Error("Documents can only contain cloneable values (no functions or DOM nodes)")}}insertOne(t,e){if(!this._isDocument(t))throw new Error("Invalid document type");if(!this._validateKeys(t))throw new Error("Invalid document keys");const n=this._copy(t);if(void 0===n._id)do{n._id=this._generateKey()}while(this._ids().has(n._id));else if(this._ids().has(n._id))throw new Error(`Duplicate _id "${n._id}"`);if(!this._validate(n))throw new Error("Invalid document");return this._documents.push(new s(n)),this._ids().add(n._id),"function"==typeof e&&e(n._id),n._id}insertMany(t,e){if(!Array.isArray(t)||!t.every((t=>this._isDocument(t))))throw new Error("Documents must be an array of objects");const n=new Set;for(const e of t){if(!this._validateKeys(e))throw new Error("Invalid document keys");if(void 0!==e._id){if(n.has(e._id)||this._ids().has(e._id))throw new Error(`Duplicate _id "${e._id}"`);n.add(e._id)}}const s=t.map((t=>this.insertOne(t)));return"function"==typeof e&&e(s),s}}},{"../documents/document.js":4,"./operators.js":6}],6:[function(t,e,n){e.exports=class{constructor(){this._logicalOperators=["$or","$and","$not","$nor"],this._comparisonOperators=["$lt","$lte","$gt","$gte","$eq","$ne","$in","$nin"]}isAnyOperator(t){return this.isLogicalOperator(t)||this.isOperator(t)}isLogicalOperator(t){return this._logicalOperators.indexOf(t)>-1}isOperator(t){return this._comparisonOperators.indexOf(t)>-1}matches(t,e){if(null===e||"object"!=typeof e||Array.isArray(e))throw new Error("The query must be an object");for(const n of Object.keys(e))if(this.isLogicalOperator(n)){if(!this.matchesLogical(n,e[n],t))return!1}else if(!this.matchesCondition(t[n],e[n]))return!1;return!0}matchesLogical(t,e,n){if("$not"===t)throw new Error("The $not operator must be used inside a field condition, like {age: {$not: {$gt: 30}}}");if(!Array.isArray(e))throw new Error(`The ${t} operator must be an array`);switch(t){case"$and":return e.every((t=>this.matches(n,t)));case"$or":return e.some((t=>this.matches(n,t)));case"$nor":return!e.some((t=>this.matches(n,t)))}return!1}matchesCondition(t,e){if(!this.isOperatorObject(e))return this.$eq(t,e);for(const n of Object.keys(e))if("$not"!==n){if(!this.isOperator(n))throw new Error(`Unknown operator ${n}`);if(!this[n](t,e[n]))return!1}else if(this.matchesCondition(t,e[n]))return!1;return!0}isOperatorObject(t){if(null===t||"object"!=typeof t||Array.isArray(t))return!1;const e=Object.keys(t);return e.length>0&&e.every((t=>t.startsWith("$")))}get $nin(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $nin operator must be an array");return!this.$in(t,e)}}get $in(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $in operator must be an array");if(Array.isArray(t))for(const n of e)if(t.indexOf(n)>-1)return!0;return e.indexOf(t)>-1}}get $lt(){return(t,e)=>!Array.isArray(e)&&t<e}get $lte(){return(t,e)=>!Array.isArray(e)&&t<=e}get $gt(){return(t,e)=>!Array.isArray(e)&&t>e}get $gte(){return(t,e)=>!Array.isArray(e)&&t>=e}get $ne(){return(t,e)=>!Array.isArray(e)&&t!==e}get $eq(){return(t,e)=>!Array.isArray(e)&&t===e}}},{}],7:[function(t,e,n){const s=t("./manipulation.js"),i=t("./operators.js"),a=t("../documents/cursor.js");e.exports=class extends s{constructor(t){super(t),this._operators=new i}count(){return this._documents.length}_deepFind(t,e,n=!1){const s=[];for(const e of this._documents)if(this._operators.matches(e,t)&&(s.push(e.clone()),n))break;return new a(s,e)}findOne(t,e=null){return this._deepFind(t,e,!0).first()}find(t,e=null){return this._deepFind(t,e)}}},{"../documents/cursor.js":3,"./manipulation.js":5,"./operators.js":6}],8:[function(t,e,n){const s=t("./dbs/database.js");class sorla{constructor(){this._dbs=[],this._selectedDb=null}_findDbIndex(t){return this._dbs.findIndex((e=>e.name==t))}dbExists(t){return this._findDbIndex(t)>-1}_selectDb(t){if(!this._dbs.hasOwnProperty(t))throw new Error("Database does not exists");this._selectedDb=this._dbs[t]}createDb(t){if(this.dbExists(t))return!1;let e=this._dbs.length;return this._dbs.push(new s(t)),e}useDb(t){let e=null;this.dbExists(t)||(e=this.createDb(t)),this._selectDb(null===e?this._findDbIndex(t):e)}countDb(){return this._dbs.length}get db(){return this._selectedDb}}e.exports=sorla,"undefined"!=typeof window&&(window.sorla=sorla)},{"./dbs/database.js":2}],9:[function(t,e,n){e.exports=class{get uuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){let e=16*Math.random()|0;return("x"==t?e:3&e|8).toString(16)}))}getDotData(t,e){let n=t;for(const t of e.split(".")){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}}},{}]},{},[8]);
|
|
1
|
+
!function r(t,e,n){function o(i,a){if(!e[i]){if(!t[i]){var c="function"==typeof require&&require;if(!a&&c)return c(i,!0);if(s)return s(i,!0);var u=new Error("Cannot find module '"+i+"'");throw u.code="MODULE_NOT_FOUND",u}var h=e[i]={exports:{}};t[i][0].call(h.exports,(function(e){return o(t[i][1][e]||e)}),h,h.exports,r,t,e,n)}return e[i].exports}for(var s="function"==typeof require&&require,i=0;i<n.length;i++)o(n[i]);return o}({1:[function(t,e,n){const s=t("../query_language/query.js");e.exports=class extends s{constructor(t){super(t),this._documents=[],this._indexes=[]}get first(){return structuredClone(this._documents[0]._data)}get last(){return structuredClone(this._documents[this._documents.length-1]._data)}get all(){return this._documents}}},{"../query_language/query.js":8}],2:[function(t,e,n){const s=t("../collections/collection.js");e.exports=class{constructor(t){return this._collections={},this._name=t,this._collectionCount=0,new Proxy(this,{get:(t,e,n)=>e in t._collections?t._collections[e]:t[e]})}get name(){return this._name}createCollection(t,e){if(this._collections.hasOwnProperty(t)||void 0!==this[t])throw new Error("Collection already exists");this._collections[t]=new s(e),this._collectionCount++}countCollections(){return this._collectionCount}listCollections(){return Object.keys(this._collections)}getCollection(t){return this._collections.hasOwnProperty(t)?this._collections[t]:void 0}dropCollection(t){return!!this._collections.hasOwnProperty(t)&&(delete this._collections[t],this._collectionCount--,!0)}getStats(){const t=this.listCollections().map((t=>({name:t,documents:this._collections[t].count()})));return{name:this._name,documents:t.reduce(((t,e)=>t+e.documents),0),collections:t}}}},{"../collections/collection.js":1}],3:[function(t,e,n){function typeRank(t){return null==t?0:"number"==typeof t?1:"string"==typeof t?2:"[object Date]"===Object.prototype.toString.call(t)?6:Array.isArray(t)?4:"object"==typeof t?3:"boolean"==typeof t?5:7}function compareValues(t,e,n){const s=typeRank(t),i=typeRank(e);if(s!==i)return s<i?-1:1;switch(s){case 0:return 0;case 2:if(n)return Math.sign(n.compare(t,e));break;case 3:case 4:t=JSON.stringify(t),e=JSON.stringify(e);break;case 6:t=t.getTime(),e=e.getTime()}return t<e?-1:t>e?1:0}e.exports=class{constructor(t,e){this._data=void 0===t?[]:t,this.currIndex=0,"object"==typeof e&&this.projection(e)}hasNext(){return this.currIndex<this._data.length}next(){if(!this.hasNext())throw new Error("No more item to the cursor");return this._data[this.currIndex++]}toArray(){return this._data.map((t=>structuredClone(t._data)))}reset(){this.currIndex=0}first(){return this._data.length?this._data[0]._data:null}last(){return this._data.length?this._data[this._data.length-1]._data:null}sort(t,e={}){if(null===t||"object"!=typeof t||Array.isArray(t))throw new Error("The sort query must be an object like {field: 1}");const n=Object.keys(t);for(const e of n)if(1!==t[e]&&-1!==t[e])throw new Error(`Sort direction of "${e}" must be 1 or -1`);const s=e.collation?new Intl.Collator(e.collation.locale,e.collation):null;return this._data.sort(((e,i)=>{for(const a of n){const n=compareValues(e[a],i[a],s);if(0!==n)return n*t[a]}return 0})),this}get length(){return this._data.length}projection(t){if(null===t||"object"!=typeof t)return this;const e=Object.keys(t);for(const n of e){if(0!==t[n]&&1!==t[n])throw new Error(`Projection of "${n}" must be 0 or 1`);if(n.includes("."))throw new Error(`Projection supports top level fields only ("${n}")`)}const n=e.filter((e=>1===t[e]&&"_id"!==e)),s=e.filter((e=>0===t[e]&&"_id"!==e));if(n.length&&s.length)throw new Error("Projection cannot mix inclusion and exclusion (except for _id)");const i=new Set(n);return 0!==t._id&&i.add("_id"),this._data.forEach((e=>{const a=n.length?Object.keys(e._data).filter((t=>!i.has(t))):[...s,...0===t._id?["_id"]:[]];for(const t of a)e.deleteProp(t)})),this}skip(t){if(!Number.isInteger(t)||t<0)throw new Error("skip() needs a non negative integer");return this._data=this._data.slice(t),this.currIndex=0,this}limit(t){if(!Number.isInteger(t)||t<0)throw new Error("limit() needs a non negative integer");return t>0&&(this._data=this._data.slice(0,t)),this.currIndex=0,this}push(t){if("object"!=typeof t)throw new Error("The element must be an object");this._data.push(t)}}},{}],4:[function(t,e,n){const s=t("../utils");class documents{constructor(t){return this._data=t,this._utils=new s,this._cloned=!1,new Proxy(this,{deleteProperty:(t,e)=>!(!t._cloned||!(e in t._data))&&(delete t._data[e],!0),get:(t,e)=>"string"!=typeof e?t[e]:e.indexOf(".")>-1?t._utils.getDotData(t._data,e):e in t._data?t._data[e]:t[e],set:(t,e,n)=>{if("_cloned"===e)return t._cloned=n,!0;if("string"==typeof e&&e in t._data)throw new Error(`Document fields are read-only ("${e}"), change documents through the collection`);return!1}})}clone(){const t=new documents(structuredClone(this._data));return t._cloned=!0,t}deleteProp(t){return!!this._cloned&&Reflect.deleteProperty(this,t)}}e.exports=documents},{"../utils":11}],5:[function(t,e,n){e.exports=class{constructor(){this._listeners=new Map}on(t,e){if("function"!=typeof e)throw new Error("The listener must be a function");return this._listeners.has(t)||this._listeners.set(t,new Set),this._listeners.get(t).add(e),()=>this.off(t,e)}once(t,e){const n=this.on(t,(t=>{n(),e(t)}));return n}off(t,e){void 0===e?this._listeners.delete(t):this._listeners.get(t)?.delete(e)}_emitChange(t,e){const n={type:t,...e};for(const e of[t,"change"])for(const t of[...this._listeners.get(e)??[]])try{t(n)}catch(t){setTimeout((()=>{throw t}))}}}},{}],6:[function(t,e,n){const s=t("../documents/document.js"),i=t("./operators.js"),a=t("../events.js"),{applyUpdate:c}=t("./update.js");e.exports=class extends a{constructor(t=null){super(),this.validationSchema=t,this._index={},this._prohibitedCharacters=["$","."]}_generateKey(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){var e=16*Math.random()|0;return("x"===t?e:3&e|8).toString(16)}))}set validationSchema(t){if("undefined"!=typeof schemaValidation&&null!==schemaValidation){if(-1==["object","function"].indexOf(typeof schemaValidation))throw new Error("Invalid schema validation");this._validationSchema=t}}_validate(t){return!0}_isDocument(t){return null!==t&&"object"==typeof t&&!Array.isArray(t)}_validateKeys(t){const e=new i;for(const n of Object.keys(t)){if(this._prohibitedCharacters.some((t=>n.indexOf(t)>-1))&&!e.isOperator(n))return!1;const s=t[n],i=Array.isArray(s)?s:[s];for(const t of i)if(null!==t&&"object"==typeof t&&!this._validateKeys(t))return!1}return!0}_ids(){return this._idSet&&this._idSet.size===this._documents.length||(this._idSet=new Set(this._documents.map((t=>t._data._id)))),this._idSet}_copy(t){try{return structuredClone(t)}catch(t){throw new Error("Documents can only contain cloneable values (no functions or DOM nodes)")}}insertOne(t,e){const n=this._insertDocument(t);return this._emitChange("insert",{ids:[n],documents:[this._copy(this._documents.at(-1)._data)]}),"function"==typeof e&&e(n),n}_insertDocument(t){if(!this._isDocument(t))throw new Error("Invalid document type");if(!this._validateKeys(t))throw new Error("Invalid document keys");const e=this._copy(t);if(void 0===e._id)do{e._id=this._generateKey()}while(this._ids().has(e._id));else if(this._ids().has(e._id))throw new Error(`Duplicate _id "${e._id}"`);if(!this._validate(e))throw new Error("Invalid document");return this._documents.push(new s(e)),this._ids().add(e._id),e._id}insertMany(t,e){if(!Array.isArray(t)||!t.every((t=>this._isDocument(t))))throw new Error("Documents must be an array of objects");const n=new Set;for(const e of t){if(!this._validateKeys(e))throw new Error("Invalid document keys");if(void 0!==e._id){if(n.has(e._id)||this._ids().has(e._id))throw new Error(`Duplicate _id "${e._id}"`);n.add(e._id)}}const s=this._documents.length,i=t.map((t=>this._insertDocument(t)));return this._emitChange("insert",{ids:i,documents:this._documents.slice(s).map((t=>this._copy(t._data)))}),"function"==typeof e&&e(i),i}_updateHelpers(){return{copy:t=>this._copy(t),validateKeys:t=>this._validateKeys(t),operators:this._operators}}_upsertBase(t){const e={};for(const[n,s]of Object.entries(t))n.startsWith("$")||n.includes(".")||this._operators.isRegExp(s)||(this._operators.isOperatorObject(s)?"$eq"in s&&(e[n]=s.$eq):e[n]=s);return e}_update(t,e,n,i){this._requireFilter(t);const a=this._updateHelpers(),u=[];for(let n=0;n<this._documents.length;n++){const s=this._documents[n];if(this._operators.matches(s,t)&&(u.push({index:n,previous:s._data,next:c(s._data,e,a)}),!i))break}if(!u.length&&n.upsert){const n=c(this._upsertBase(t),e,a),s=this._insertDocument(n);return this._emitChange("insert",{ids:[s],documents:[this._copy(this._documents.at(-1)._data)]}),{matchedCount:0,modifiedCount:0,upsertedId:s}}const h=u.filter((t=>JSON.stringify(t.previous)!==JSON.stringify(t.next)));for(const t of h)this._documents[t.index]=new s(t.next);return h.length&&this._emitChange("update",{ids:h.map((t=>t.next._id)),documents:h.map((t=>this._copy(t.next))),previous:h.map((t=>this._copy(t.previous)))}),{matchedCount:u.length,modifiedCount:h.length,upsertedId:null}}updateOne(t,e,n={}){return this._update(t,e,n,!1)}updateMany(t,e,n={}){return this._update(t,e,n,!0)}replaceOne(t,e,n={}){if(this._requireFilter(t),!this._isDocument(e))throw new Error("The replacement must be a document");if(Object.keys(e).some((t=>t.startsWith("$"))))throw new Error("The replacement cannot contain update operators, use updateOne()");if(!this._validateKeys(e))throw new Error("Invalid document keys");const i=this._documents.findIndex((e=>this._operators.matches(e,t)));if(-1===i){if(!n.upsert)return{matchedCount:0,modifiedCount:0,upsertedId:null};const t=this._insertDocument(e);return this._emitChange("insert",{ids:[t],documents:[this._copy(this._documents.at(-1)._data)]}),{matchedCount:0,modifiedCount:0,upsertedId:t}}const a=this._documents[i]._data;if(void 0!==e._id&&e._id!==a._id)throw new Error("The _id field cannot be changed");const c={...this._copy(e),_id:a._id},u=JSON.stringify(a)!==JSON.stringify(c);return u&&(this._documents[i]=new s(c),this._emitChange("replace",{ids:[c._id],documents:[this._copy(c)],previous:[this._copy(a)]})),{matchedCount:1,modifiedCount:u?1:0,upsertedId:null}}_delete(t,e){this._requireFilter(t);const n=[];let s=0;for(let i=0;i<this._documents.length;i++){const a=this._documents[i];!e&&n.length||!this._operators.matches(a,t)?this._documents[s++]=a:n.push(a._data)}if(this._documents.length=s,n.length){for(const t of n)this._idSet?.delete(t._id);this._emitChange("delete",{ids:n.map((t=>t._id)),documents:n})}return{deletedCount:n.length}}deleteOne(t){return this._delete(t,!1)}deleteMany(t){return this._delete(t,!0)}_requireFilter(t){if(!this._isDocument(t))throw new Error("The filter must be a query object, use {} to match every document")}}},{"../documents/document.js":4,"../events.js":5,"./operators.js":7,"./update.js":9}],7:[function(t,e,n){e.exports=class{constructor(){this._logicalOperators=["$or","$and","$not","$nor"],this._comparisonOperators=["$lt","$lte","$gt","$gte","$eq","$ne","$in","$nin","$exists","$regex","$options"]}isAnyOperator(t){return this.isLogicalOperator(t)||this.isOperator(t)}isLogicalOperator(t){return this._logicalOperators.indexOf(t)>-1}isOperator(t){return this._comparisonOperators.indexOf(t)>-1}matches(t,e){if(null===e||"object"!=typeof e||Array.isArray(e))throw new Error("The query must be an object");for(const n of Object.keys(e))if(this.isLogicalOperator(n)){if(!this.matchesLogical(n,e[n],t))return!1}else if(!this.matchesCondition(t[n],e[n]))return!1;return!0}matchesLogical(t,e,n){if("$not"===t)throw new Error("The $not operator must be used inside a field condition, like {age: {$not: {$gt: 30}}}");if(!Array.isArray(e))throw new Error(`The ${t} operator must be an array`);switch(t){case"$and":return e.every((t=>this.matches(n,t)));case"$or":return e.some((t=>this.matches(n,t)));case"$nor":return!e.some((t=>this.matches(n,t)))}return!1}matchesCondition(t,e){if(this.isRegExp(e))return this.matchesRegex(t,e);if(!this.isOperatorObject(e))return this.$eq(t,e);if("$options"in e&&!("$regex"in e))throw new Error("The $options operator must be used with $regex");for(const n of Object.keys(e))if("$not"!==n){if("$options"!==n)if("$regex"!==n){if(!this.isOperator(n))throw new Error(`Unknown operator ${n}`);if(!this[n](t,e[n]))return!1}else if(!this.matchesRegex(t,this.toRegExp(e.$regex,e.$options)))return!1}else if(this.matchesCondition(t,e[n]))return!1;return!0}isRegExp(t){return"[object RegExp]"===Object.prototype.toString.call(t)}toRegExp(t,e=""){if("string"!=typeof e||/[^ims]/.test(e))throw new Error("The $options operator accepts only the flags i, m and s");if(this.isRegExp(t))return new RegExp(t.source,[...new Set((t.flags+e).replace(/[gy]/g,""))].join(""));if("string"!=typeof t)throw new Error("The $regex operator must be a string or a RegExp");return new RegExp(t,e)}matchesRegex(t,e){const test=t=>"string"==typeof t&&(e.lastIndex=0,e.test(t));return Array.isArray(t)?t.some(test):test(t)}isOperatorObject(t){if(null===t||"object"!=typeof t||Array.isArray(t))return!1;const e=Object.keys(t);return e.length>0&&e.every((t=>t.startsWith("$")))}get $exists(){return(t,e)=>void 0!==t==!!e}get $nin(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $nin operator must be an array");return!this.$in(t,e)}}get $in(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $in operator must be an array");if(Array.isArray(t))for(const n of e)if(t.indexOf(n)>-1)return!0;return e.indexOf(t)>-1}}get $lt(){return(t,e)=>!Array.isArray(e)&&t<e}get $lte(){return(t,e)=>!Array.isArray(e)&&t<=e}get $gt(){return(t,e)=>!Array.isArray(e)&&t>e}get $gte(){return(t,e)=>!Array.isArray(e)&&t>=e}get $ne(){return(t,e)=>!Array.isArray(e)&&t!==e}get $eq(){return(t,e)=>!Array.isArray(e)&&t===e}}},{}],8:[function(t,e,n){const s=t("./manipulation.js"),i=t("./operators.js"),a=t("../documents/cursor.js");e.exports=class extends s{constructor(t){super(t),this._operators=new i}count(t){return void 0===t?this._documents.length:this._documents.reduce(((e,n)=>e+(this._operators.matches(n,t)?1:0)),0)}_deepFind(t,e,n=!1){const s=[];for(const e of this._documents)if(this._operators.matches(e,t)&&(s.push(e.clone()),n))break;return new a(s,e)}findOne(t,e=null){return this._deepFind(t,e,!0).first()}find(t,e=null){return this._deepFind(t,e)}}},{"../documents/cursor.js":3,"./manipulation.js":6,"./operators.js":7}],9:[function(t,e,n){const s=["$set","$unset","$inc","$push","$pull"],isPlainObject=t=>null!==t&&"object"==typeof t&&!Array.isArray(t)&&"[object Object]"===Object.prototype.toString.call(t);function splitPath(t){if("string"!=typeof t||""===t||t.split(".").some((t=>""===t)))throw new Error(`Invalid field path "${t}"`);if("_id"===t||t.startsWith("_id."))throw new Error("The _id field cannot be updated");return t.split(".")}function getPath(t,e){let n=t;for(const t of e){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}function parentOf(t,e,n){let s=t;for(const t of e.slice(0,-1)){if(!(t in s)){if(!n)return null;s[t]={}}if(null===s[t]||"object"!=typeof s[t])throw new Error(`Cannot create the field "${e.join(".")}": "${t}" is not an object`);s=s[t]}return s}function validateUpdate(t){if(!isPlainObject(t))throw new Error("The update must be an object like {$set: {field: value}}");const e=Object.keys(t);if(!e.length)throw new Error("The update must contain at least one operator");for(const n of e){if(!n.startsWith("$"))throw new Error(`The update can only contain operators, use replaceOne() to replace a document (found "${n}")`);if(!s.includes(n))throw new Error(`Unknown update operator ${n}`);if(!isPlainObject(t[n]))throw new Error(`The ${n} operator must be an object like {${n}: {field: value}}`)}}e.exports={applyUpdate:function(t,e,n){validateUpdate(e);const s=n.copy(t);for(const[t,i]of Object.entries(e.$set??{})){const e=splitPath(t);if(null!==i&&"object"==typeof i&&!n.validateKeys(Array.isArray(i)?{value:i}:i))throw new Error("Invalid document keys");parentOf(s,e,!0)[e.at(-1)]=n.copy(i)}for(const t of Object.keys(e.$unset??{})){const e=splitPath(t),n=parentOf(s,e,!1);n&&!Array.isArray(n)&&delete n[e.at(-1)]}for(const[t,n]of Object.entries(e.$inc??{})){const e=splitPath(t);if("number"!=typeof n||!Number.isFinite(n))throw new Error(`$inc needs a number for "${t}"`);const i=getPath(s,e);if(void 0!==i&&"number"!=typeof i)throw new Error(`Cannot $inc "${t}": it is not a number`);parentOf(s,e,!0)[e.at(-1)]=(i??0)+n}for(const[t,i]of Object.entries(e.$push??{})){const e=splitPath(t),a=getPath(s,e);if(void 0!==a&&!Array.isArray(a))throw new Error(`Cannot $push to "${t}": it is not an array`);const c=isPlainObject(i)&&Array.isArray(i.$each)?i.$each:[i];for(const t of c)if(null!==t&&"object"==typeof t&&!n.validateKeys(Array.isArray(t)?{item:t}:t))throw new Error("Invalid document keys");parentOf(s,e,!0)[e.at(-1)]=[...a??[],...c.map(n.copy)]}for(const[t,i]of Object.entries(e.$pull??{})){const e=splitPath(t),a=getPath(s,e);if(void 0===a)continue;if(!Array.isArray(a))throw new Error(`Cannot $pull from "${t}": it is not an array`);const{operators:c}=n,matches=t=>c.isRegExp(i)||c.isOperatorObject(i)?c.matchesCondition(t,i):isPlainObject(i)?isPlainObject(t)&&c.matches(t,i):t===i;parentOf(s,e,!1)[e.at(-1)]=a.filter((t=>!matches(t)))}return s},validateUpdate:validateUpdate,UPDATE_OPERATORS:s}},{}],10:[function(t,e,n){const s=t("./dbs/database.js");class sorla{constructor(){this._dbs=[],this._selectedDb=null}_findDbIndex(t){return this._dbs.findIndex((e=>e.name==t))}dbExists(t){return this._findDbIndex(t)>-1}_selectDb(t){if(!this._dbs.hasOwnProperty(t))throw new Error("Database does not exists");this._selectedDb=this._dbs[t]}createDb(t){if(this.dbExists(t))return!1;let e=this._dbs.length;return this._dbs.push(new s(t)),e}useDb(t){let e=null;this.dbExists(t)||(e=this.createDb(t)),this._selectDb(null===e?this._findDbIndex(t):e)}countDb(){return this._dbs.length}listDatabases(){return this._dbs.map((t=>t.name))}dropDb(t){const e=this._findDbIndex(t);return-1!==e&&(this._selectedDb===this._dbs[e]&&(this._selectedDb=null),this._dbs.splice(e,1),!0)}get db(){return this._selectedDb}}e.exports=sorla,"undefined"!=typeof window&&(window.sorla=sorla)},{"./dbs/database.js":2}],11:[function(t,e,n){e.exports=class{get uuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){let e=16*Math.random()|0;return("x"==t?e:3&e|8).toString(16)}))}getDotData(t,e){let n=t;for(const t of e.split(".")){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}}},{}]},{},[10]);
|
package/index.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ export type ComparisonOperators = {
|
|
|
11
11
|
$lte?: unknown;
|
|
12
12
|
$in?: unknown[];
|
|
13
13
|
$nin?: unknown[];
|
|
14
|
-
$
|
|
14
|
+
$exists?: boolean;
|
|
15
|
+
$regex?: string | RegExp;
|
|
16
|
+
/** Flags for $regex: i, m, s */
|
|
17
|
+
$options?: string;
|
|
18
|
+
$not?: ComparisonOperators | RegExp;
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
/** A query: field conditions (value or operators, "a.b" paths allowed) and nested $and / $or / $nor. */
|
|
@@ -22,7 +26,42 @@ export type Query = {
|
|
|
22
26
|
[field: string]: unknown;
|
|
23
27
|
};
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** Exclusion ({ email: 0 }) or inclusion ({ name: 1 }); only _id can mix both. */
|
|
30
|
+
export type Projection = Record<string, 0 | 1>;
|
|
31
|
+
|
|
32
|
+
export type UpdateFilter = {
|
|
33
|
+
$set?: Record<string, unknown>;
|
|
34
|
+
$unset?: Record<string, unknown>;
|
|
35
|
+
$inc?: Record<string, number>;
|
|
36
|
+
$push?: Record<string, unknown | { $each: unknown[] }>;
|
|
37
|
+
$pull?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface UpdateOptions {
|
|
41
|
+
/** Insert a document built from the filter when nothing matches. */
|
|
42
|
+
upsert?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UpdateResult {
|
|
46
|
+
matchedCount: number;
|
|
47
|
+
modifiedCount: number;
|
|
48
|
+
upsertedId: unknown | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeleteResult {
|
|
52
|
+
deletedCount: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ChangeType = 'insert' | 'update' | 'replace' | 'delete';
|
|
56
|
+
|
|
57
|
+
export interface ChangeEvent {
|
|
58
|
+
type: ChangeType;
|
|
59
|
+
ids: unknown[];
|
|
60
|
+
/** Copies of the documents (after the change; the removed ones for delete). */
|
|
61
|
+
documents: Document[];
|
|
62
|
+
/** Copies of the documents before an update or replace. */
|
|
63
|
+
previous?: Document[];
|
|
64
|
+
}
|
|
26
65
|
export type SortSpec = Record<string, 1 | -1>;
|
|
27
66
|
|
|
28
67
|
export interface SortOptions {
|
|
@@ -48,7 +87,10 @@ export declare class Cursor {
|
|
|
48
87
|
/** Plain copies of the documents. */
|
|
49
88
|
toArray(): Document[];
|
|
50
89
|
sort(spec: SortSpec, options?: SortOptions): this;
|
|
51
|
-
|
|
90
|
+
skip(count: number): this;
|
|
91
|
+
/** 0 means no limit. */
|
|
92
|
+
limit(count: number): this;
|
|
93
|
+
projection(projection: Projection): this;
|
|
52
94
|
push(element: object): void;
|
|
53
95
|
}
|
|
54
96
|
|
|
@@ -56,7 +98,8 @@ export declare class Collection {
|
|
|
56
98
|
readonly first: Document;
|
|
57
99
|
readonly last: Document;
|
|
58
100
|
readonly all: StoredDocument[];
|
|
59
|
-
|
|
101
|
+
/** All documents, or the ones matching the query. */
|
|
102
|
+
count(query?: Query): number;
|
|
60
103
|
/** Inserts a copy of the document and returns its _id. */
|
|
61
104
|
insertOne(document: Document, callback?: (id: unknown) => void): unknown;
|
|
62
105
|
/** Validates every document first, then inserts them and returns their ids. */
|
|
@@ -64,12 +107,34 @@ export declare class Collection {
|
|
|
64
107
|
find(query: Query, projection?: Projection | null): Cursor;
|
|
65
108
|
/** Data of the first matching document, null when nothing matches. */
|
|
66
109
|
findOne(query: Query, projection?: Projection | null): Document | null;
|
|
110
|
+
updateOne(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
|
|
111
|
+
updateMany(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
|
|
112
|
+
/** Replaces the document, keeping its _id. */
|
|
113
|
+
replaceOne(filter: Query, replacement: Document, options?: UpdateOptions): UpdateResult;
|
|
114
|
+
deleteOne(filter: Query): DeleteResult;
|
|
115
|
+
/** deleteMany({}) empties the collection. */
|
|
116
|
+
deleteMany(filter: Query): DeleteResult;
|
|
117
|
+
/** Returns a function that unsubscribes. "change" receives every type. */
|
|
118
|
+
on(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
|
|
119
|
+
once(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
|
|
120
|
+
off(event: ChangeType | 'change', listener?: (event: ChangeEvent) => void): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface DatabaseStats {
|
|
124
|
+
name: string;
|
|
125
|
+
documents: number;
|
|
126
|
+
collections: Array<{ name: string; documents: number }>;
|
|
67
127
|
}
|
|
68
128
|
|
|
69
129
|
export declare class Database {
|
|
70
130
|
readonly name: string;
|
|
71
131
|
createCollection(name: string, validationSchema?: unknown): void;
|
|
72
132
|
countCollections(): number;
|
|
133
|
+
listCollections(): string[];
|
|
134
|
+
/** Any collection by name, also when the name shadows a method. */
|
|
135
|
+
getCollection(name: string): Collection | undefined;
|
|
136
|
+
dropCollection(name: string): boolean;
|
|
137
|
+
getStats(): DatabaseStats;
|
|
73
138
|
[collection: string]: unknown;
|
|
74
139
|
}
|
|
75
140
|
|
|
@@ -83,6 +148,9 @@ declare class sorla {
|
|
|
83
148
|
/** Selects a database, creating it when needed. */
|
|
84
149
|
useDb(name: string): void;
|
|
85
150
|
countDb(): number;
|
|
151
|
+
listDatabases(): string[];
|
|
152
|
+
/** When the dropped database was selected, db becomes null. */
|
|
153
|
+
dropDb(name: string): boolean;
|
|
86
154
|
}
|
|
87
155
|
|
|
88
156
|
export { sorla };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sorla-db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A simple and temporary database focused to use in browser environment that will bring capabilities for other tools that will be launched as soon as possible.",
|
|
5
5
|
"main": "src/sorla.js",
|
|
6
6
|
"module": "index.mjs",
|
package/src/dbs/database.js
CHANGED
|
@@ -67,6 +67,54 @@ class database
|
|
|
67
67
|
{
|
|
68
68
|
return this._collectionCount;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Names of the collections, in creation order
|
|
73
|
+
* @return array
|
|
74
|
+
*/
|
|
75
|
+
listCollections()
|
|
76
|
+
{
|
|
77
|
+
return Object.keys(this._collections);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A collection by name, also when the name is not a valid property (or shadows a method)
|
|
82
|
+
* @param string name
|
|
83
|
+
* @return collection|undefined
|
|
84
|
+
*/
|
|
85
|
+
getCollection(name)
|
|
86
|
+
{
|
|
87
|
+
return this._collections.hasOwnProperty(name) ? this._collections[name] : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Removes a collection and its documents
|
|
92
|
+
* @param string name
|
|
93
|
+
* @return boolean false when it did not exist
|
|
94
|
+
*/
|
|
95
|
+
dropCollection(name)
|
|
96
|
+
{
|
|
97
|
+
if(!this._collections.hasOwnProperty(name))
|
|
98
|
+
return false;
|
|
99
|
+
|
|
100
|
+
delete this._collections[name];
|
|
101
|
+
this._collectionCount--;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Number of documents per collection
|
|
107
|
+
* @return object {name, documents, collections: [{name, documents}]}
|
|
108
|
+
*/
|
|
109
|
+
getStats()
|
|
110
|
+
{
|
|
111
|
+
const collections = this.listCollections().map(name => ({ name, documents: this._collections[name].count() }));
|
|
112
|
+
return {
|
|
113
|
+
name: this._name,
|
|
114
|
+
documents: collections.reduce((total, item) => total + item.documents, 0),
|
|
115
|
+
collections
|
|
116
|
+
};
|
|
117
|
+
}
|
|
70
118
|
}
|
|
71
119
|
|
|
72
120
|
module.exports = database;
|
package/src/documents/cursor.js
CHANGED
|
@@ -123,23 +123,75 @@ class cursor {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* Projection of the cursor
|
|
126
|
+
* Projection of the cursor, like MongoDB:
|
|
127
|
+
* {email: 0} every field except email
|
|
128
|
+
* {name: 1, age: 1} only name and age, plus _id unless {_id: 0}
|
|
129
|
+
* Inclusion and exclusion cannot be mixed, except for _id. Top level fields only.
|
|
127
130
|
* @param json projection
|
|
128
131
|
* @return cursor
|
|
129
132
|
*/
|
|
130
133
|
projection(projection) {
|
|
131
134
|
if(projection === null || typeof projection !== 'object')
|
|
132
|
-
return;
|
|
135
|
+
return this;
|
|
136
|
+
|
|
137
|
+
const fields = Object.keys(projection);
|
|
138
|
+
for(const field of fields)
|
|
139
|
+
{
|
|
140
|
+
if(projection[field] !== 0 && projection[field] !== 1)
|
|
141
|
+
throw new Error(`Projection of "${field}" must be 0 or 1`);
|
|
142
|
+
|
|
143
|
+
if(field.includes('.'))
|
|
144
|
+
throw new Error(`Projection supports top level fields only ("${field}")`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const included = fields.filter(field => projection[field] === 1 && field !== '_id');
|
|
148
|
+
const excluded = fields.filter(field => projection[field] === 0 && field !== '_id');
|
|
149
|
+
if(included.length && excluded.length)
|
|
150
|
+
throw new Error('Projection cannot mix inclusion and exclusion (except for _id)');
|
|
151
|
+
|
|
152
|
+
const keep = new Set(included);
|
|
153
|
+
if(projection._id !== 0) keep.add('_id');
|
|
133
154
|
|
|
134
|
-
const self = this;
|
|
135
155
|
this._data.forEach(doc => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
const remove = included.length
|
|
157
|
+
? Object.keys(doc._data).filter(field => !keep.has(field))
|
|
158
|
+
: [...excluded, ...(projection._id === 0 ? ['_id'] : [])];
|
|
159
|
+
|
|
160
|
+
for(const field of remove)
|
|
161
|
+
doc.deleteProp(field);
|
|
142
162
|
});
|
|
163
|
+
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Skips the first documents (apply after sort, like MongoDB applies it)
|
|
169
|
+
* @param int count
|
|
170
|
+
* @return cursor
|
|
171
|
+
*/
|
|
172
|
+
skip(count) {
|
|
173
|
+
if(!Number.isInteger(count) || count < 0)
|
|
174
|
+
throw new Error('skip() needs a non negative integer');
|
|
175
|
+
|
|
176
|
+
this._data = this._data.slice(count);
|
|
177
|
+
this.currIndex = 0;
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Keeps at most `count` documents; 0 means no limit, like MongoDB
|
|
183
|
+
* @param int count
|
|
184
|
+
* @return cursor
|
|
185
|
+
*/
|
|
186
|
+
limit(count) {
|
|
187
|
+
if(!Number.isInteger(count) || count < 0)
|
|
188
|
+
throw new Error('limit() needs a non negative integer');
|
|
189
|
+
|
|
190
|
+
if(count > 0)
|
|
191
|
+
this._data = this._data.slice(0, count);
|
|
192
|
+
|
|
193
|
+
this.currIndex = 0;
|
|
194
|
+
return this;
|
|
143
195
|
}
|
|
144
196
|
|
|
145
197
|
/**
|
package/src/events.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal event emitter for collections: insert, update, replace, delete and change (all of them).
|
|
3
|
+
* A failing listener never breaks the write that triggered it; its error is rethrown asynchronously.
|
|
4
|
+
*/
|
|
5
|
+
class emitter
|
|
6
|
+
{
|
|
7
|
+
constructor()
|
|
8
|
+
{
|
|
9
|
+
this._listeners = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param string event
|
|
14
|
+
* @param function listener receives the event payload
|
|
15
|
+
* @return function unsubscribe
|
|
16
|
+
*/
|
|
17
|
+
on(event, listener)
|
|
18
|
+
{
|
|
19
|
+
if(typeof listener !== 'function')
|
|
20
|
+
throw new Error('The listener must be a function');
|
|
21
|
+
|
|
22
|
+
if(!this._listeners.has(event))
|
|
23
|
+
this._listeners.set(event, new Set());
|
|
24
|
+
|
|
25
|
+
this._listeners.get(event).add(listener);
|
|
26
|
+
return () => this.off(event, listener);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
once(event, listener)
|
|
30
|
+
{
|
|
31
|
+
const off = this.on(event, payload => {
|
|
32
|
+
off();
|
|
33
|
+
listener(payload);
|
|
34
|
+
});
|
|
35
|
+
return off;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
off(event, listener)
|
|
39
|
+
{
|
|
40
|
+
if(typeof listener === 'undefined')
|
|
41
|
+
this._listeners.delete(event);
|
|
42
|
+
else
|
|
43
|
+
this._listeners.get(event)?.delete(listener);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Emits the event and a generic "change" event with the same payload plus its type
|
|
48
|
+
* @param string type
|
|
49
|
+
* @param object payload
|
|
50
|
+
* @return void
|
|
51
|
+
*/
|
|
52
|
+
_emitChange(type, payload)
|
|
53
|
+
{
|
|
54
|
+
const event = { type, ...payload };
|
|
55
|
+
for(const name of [type, 'change'])
|
|
56
|
+
{
|
|
57
|
+
for(const listener of [...(this._listeners.get(name) ?? [])])
|
|
58
|
+
{
|
|
59
|
+
try {
|
|
60
|
+
listener(event);
|
|
61
|
+
} catch(error) {
|
|
62
|
+
setTimeout(() => { throw error; });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = emitter;
|
|
@@ -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 = ['$', '.'];
|
|
@@ -124,6 +127,22 @@ class manipulation {
|
|
|
124
127
|
* @return mixed the _id of the inserted document
|
|
125
128
|
**/
|
|
126
129
|
insertOne(document, callback)
|
|
130
|
+
{
|
|
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)
|
|
127
146
|
{
|
|
128
147
|
if(!this._isDocument(document))
|
|
129
148
|
throw new Error('Invalid document type');
|
|
@@ -148,10 +167,6 @@ class manipulation {
|
|
|
148
167
|
|
|
149
168
|
this._documents.push(new documents(data));
|
|
150
169
|
this._ids().add(data._id);
|
|
151
|
-
|
|
152
|
-
if(typeof callback === 'function')
|
|
153
|
-
callback(data._id);
|
|
154
|
-
|
|
155
170
|
return data._id;
|
|
156
171
|
}
|
|
157
172
|
|
|
@@ -183,7 +198,9 @@ class manipulation {
|
|
|
183
198
|
given.add(document._id);
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
const
|
|
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)) });
|
|
187
204
|
|
|
188
205
|
if(typeof callback === 'function')
|
|
189
206
|
callback(ids);
|
|
@@ -191,6 +208,223 @@ class manipulation {
|
|
|
191
208
|
return ids;
|
|
192
209
|
}
|
|
193
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');
|
|
426
|
+
}
|
|
427
|
+
|
|
194
428
|
//create index
|
|
195
429
|
//create indexes
|
|
196
430
|
}
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update operators: $set, $unset, $inc, $push and $pull, with "a.b" paths.
|
|
3
|
+
* Updates are applied to a copy of the document, so a failing operator never leaves it half updated.
|
|
4
|
+
*/
|
|
5
|
+
const UPDATE_OPERATORS = ['$set', '$unset', '$inc', '$push', '$pull'];
|
|
6
|
+
|
|
7
|
+
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
&& Object.prototype.toString.call(value) === '[object Object]';
|
|
9
|
+
|
|
10
|
+
function splitPath(path)
|
|
11
|
+
{
|
|
12
|
+
if(typeof path !== 'string' || path === '' || path.split('.').some(part => part === ''))
|
|
13
|
+
throw new Error(`Invalid field path "${path}"`);
|
|
14
|
+
|
|
15
|
+
if(path === '_id' || path.startsWith('_id.'))
|
|
16
|
+
throw new Error('The _id field cannot be updated');
|
|
17
|
+
|
|
18
|
+
return path.split('.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getPath(data, parts)
|
|
22
|
+
{
|
|
23
|
+
let value = data;
|
|
24
|
+
for(const part of parts)
|
|
25
|
+
{
|
|
26
|
+
if(value === null || typeof value !== 'object' || !(part in value))
|
|
27
|
+
return undefined;
|
|
28
|
+
|
|
29
|
+
value = value[part];
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parent object of a path, creating missing objects on the way
|
|
36
|
+
*/
|
|
37
|
+
function parentOf(data, parts, create)
|
|
38
|
+
{
|
|
39
|
+
let target = data;
|
|
40
|
+
for(const part of parts.slice(0, -1))
|
|
41
|
+
{
|
|
42
|
+
if(!(part in target))
|
|
43
|
+
{
|
|
44
|
+
if(!create) return null;
|
|
45
|
+
target[part] = {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if(target[part] === null || typeof target[part] !== 'object')
|
|
49
|
+
throw new Error(`Cannot create the field "${parts.join('.')}": "${part}" is not an object`);
|
|
50
|
+
|
|
51
|
+
target = target[part];
|
|
52
|
+
}
|
|
53
|
+
return target;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates an update document
|
|
58
|
+
* @param object update
|
|
59
|
+
* @return void
|
|
60
|
+
*/
|
|
61
|
+
function validateUpdate(update)
|
|
62
|
+
{
|
|
63
|
+
if(!isPlainObject(update))
|
|
64
|
+
throw new Error('The update must be an object like {$set: {field: value}}');
|
|
65
|
+
|
|
66
|
+
const keys = Object.keys(update);
|
|
67
|
+
if(!keys.length)
|
|
68
|
+
throw new Error('The update must contain at least one operator');
|
|
69
|
+
|
|
70
|
+
for(const key of keys)
|
|
71
|
+
{
|
|
72
|
+
if(!key.startsWith('$'))
|
|
73
|
+
throw new Error(`The update can only contain operators, use replaceOne() to replace a document (found "${key}")`);
|
|
74
|
+
|
|
75
|
+
if(!UPDATE_OPERATORS.includes(key))
|
|
76
|
+
throw new Error(`Unknown update operator ${key}`);
|
|
77
|
+
|
|
78
|
+
if(!isPlainObject(update[key]))
|
|
79
|
+
throw new Error(`The ${key} operator must be an object like {${key}: {field: value}}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Applies an update to a copy of the data
|
|
85
|
+
* @param object data stored document data
|
|
86
|
+
* @param object update
|
|
87
|
+
* @param object helpers {copy(value), validateKeys(object), operators}
|
|
88
|
+
* @return object the updated copy
|
|
89
|
+
*/
|
|
90
|
+
function applyUpdate(data, update, helpers)
|
|
91
|
+
{
|
|
92
|
+
validateUpdate(update);
|
|
93
|
+
const result = helpers.copy(data);
|
|
94
|
+
|
|
95
|
+
for(const [path, value] of Object.entries(update.$set ?? {}))
|
|
96
|
+
{
|
|
97
|
+
const parts = splitPath(path);
|
|
98
|
+
if(value !== null && typeof value === 'object' && !helpers.validateKeys(Array.isArray(value) ? { value } : value))
|
|
99
|
+
throw new Error('Invalid document keys');
|
|
100
|
+
|
|
101
|
+
parentOf(result, parts, true)[parts.at(-1)] = helpers.copy(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for(const path of Object.keys(update.$unset ?? {}))
|
|
105
|
+
{
|
|
106
|
+
const parts = splitPath(path);
|
|
107
|
+
const parent = parentOf(result, parts, false);
|
|
108
|
+
if(parent && !Array.isArray(parent))
|
|
109
|
+
delete parent[parts.at(-1)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for(const [path, amount] of Object.entries(update.$inc ?? {}))
|
|
113
|
+
{
|
|
114
|
+
const parts = splitPath(path);
|
|
115
|
+
if(typeof amount !== 'number' || !Number.isFinite(amount))
|
|
116
|
+
throw new Error(`$inc needs a number for "${path}"`);
|
|
117
|
+
|
|
118
|
+
const current = getPath(result, parts);
|
|
119
|
+
if(current !== undefined && typeof current !== 'number')
|
|
120
|
+
throw new Error(`Cannot $inc "${path}": it is not a number`);
|
|
121
|
+
|
|
122
|
+
parentOf(result, parts, true)[parts.at(-1)] = (current ?? 0) + amount;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for(const [path, value] of Object.entries(update.$push ?? {}))
|
|
126
|
+
{
|
|
127
|
+
const parts = splitPath(path);
|
|
128
|
+
const current = getPath(result, parts);
|
|
129
|
+
if(current !== undefined && !Array.isArray(current))
|
|
130
|
+
throw new Error(`Cannot $push to "${path}": it is not an array`);
|
|
131
|
+
|
|
132
|
+
const items = isPlainObject(value) && Array.isArray(value.$each) ? value.$each : [value];
|
|
133
|
+
for(const item of items)
|
|
134
|
+
{
|
|
135
|
+
if(item !== null && typeof item === 'object' && !helpers.validateKeys(Array.isArray(item) ? { item } : item))
|
|
136
|
+
throw new Error('Invalid document keys');
|
|
137
|
+
}
|
|
138
|
+
parentOf(result, parts, true)[parts.at(-1)] = [...(current ?? []), ...items.map(helpers.copy)];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for(const [path, condition] of Object.entries(update.$pull ?? {}))
|
|
142
|
+
{
|
|
143
|
+
const parts = splitPath(path);
|
|
144
|
+
const current = getPath(result, parts);
|
|
145
|
+
if(current === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
|
|
148
|
+
if(!Array.isArray(current))
|
|
149
|
+
throw new Error(`Cannot $pull from "${path}": it is not an array`);
|
|
150
|
+
|
|
151
|
+
const { operators } = helpers;
|
|
152
|
+
const matches = item => {
|
|
153
|
+
if(operators.isRegExp(condition) || operators.isOperatorObject(condition))
|
|
154
|
+
return operators.matchesCondition(item, condition);
|
|
155
|
+
|
|
156
|
+
if(isPlainObject(condition))
|
|
157
|
+
return isPlainObject(item) && operators.matches(item, condition);
|
|
158
|
+
|
|
159
|
+
return item === condition;
|
|
160
|
+
};
|
|
161
|
+
parentOf(result, parts, false)[parts.at(-1)] = current.filter(item => !matches(item));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { applyUpdate, validateUpdate, UPDATE_OPERATORS };
|
package/src/sorla.js
CHANGED
|
@@ -103,6 +103,33 @@ class sorla{
|
|
|
103
103
|
return this._dbs.length;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Names of the databases, in creation order
|
|
108
|
+
* @return array
|
|
109
|
+
*/
|
|
110
|
+
listDatabases()
|
|
111
|
+
{
|
|
112
|
+
return this._dbs.map(db => db.name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Removes a database and its collections; when it was selected, no database stays selected
|
|
117
|
+
* @param string name
|
|
118
|
+
* @return boolean false when it did not exist
|
|
119
|
+
*/
|
|
120
|
+
dropDb(name)
|
|
121
|
+
{
|
|
122
|
+
const pos = this._findDbIndex(name);
|
|
123
|
+
if(pos === -1)
|
|
124
|
+
return false;
|
|
125
|
+
|
|
126
|
+
if(this._selectedDb === this._dbs[pos])
|
|
127
|
+
this._selectedDb = null;
|
|
128
|
+
|
|
129
|
+
this._dbs.splice(pos, 1);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
/**
|
|
107
134
|
* Return selected db
|
|
108
135
|
* @author Anderson Arruda < anderson@sysborg.com.br >
|