sorla-db 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Document validation with a subset of JSON Schema.
3
+ *
4
+ * {
5
+ * type: 'object',
6
+ * required: ['name', 'email'],
7
+ * additionalProperties: false,
8
+ * properties: {
9
+ * name: { type: 'string', minLength: 2, maxLength: 80 },
10
+ * email: { type: 'string', format: 'email' },
11
+ * age: { type: ['integer', 'null'], minimum: 0 },
12
+ * role: { enum: ['admin', 'user'], default: 'user' },
13
+ * tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
14
+ * address: { type: 'object', properties: { zip: { type: 'string', pattern: '^\\d{5}-\\d{3}$' } } }
15
+ * }
16
+ * }
17
+ *
18
+ * Extension: type 'date' accepts Date objects (like MongoDB's bsonType 'date').
19
+ * A function schema is also accepted: doc => true | 'message' | ['message', ...] | false.
20
+ */
21
+ const TYPES = ['string', 'number', 'integer', 'boolean', 'object', 'array', 'null', 'date'];
22
+ const FORMATS = ['email', 'date', 'date-time', 'uri'];
23
+ const KEYWORDS = new Set([
24
+ 'type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'default',
25
+ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'pattern',
26
+ 'format', 'minItems', 'maxItems', 'uniqueItems', 'title', 'description', '$schema', '$id', 'examples'
27
+ ]);
28
+
29
+ class ValidationError extends Error
30
+ {
31
+ /**
32
+ * @param array errors [{path, rule, message}]
33
+ */
34
+ constructor(errors)
35
+ {
36
+ const first = errors[0];
37
+ super(errors.length === 1
38
+ ? `Invalid document: ${first.message}`
39
+ : `Invalid document: ${first.message} (and ${errors.length - 1} more)`);
40
+ this.name = 'ValidationError';
41
+ this.errors = errors;
42
+ }
43
+ }
44
+
45
+ const isPlainObject = value => Object.prototype.toString.call(value) === '[object Object]';
46
+ const isDate = value => Object.prototype.toString.call(value) === '[object Date]';
47
+
48
+ function typeOf(value)
49
+ {
50
+ if(value === null) return 'null';
51
+ if(Array.isArray(value)) return 'array';
52
+ if(isDate(value)) return 'date';
53
+ if(typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
54
+ if(isPlainObject(value)) return 'object';
55
+ return typeof value;
56
+ }
57
+
58
+ function matchesType(value, type)
59
+ {
60
+ const actual = typeOf(value);
61
+ if(type === 'number') return actual === 'number' || actual === 'integer';
62
+ return actual === type;
63
+ }
64
+
65
+ const FORMAT_TESTS = {
66
+ email: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
67
+ date: value => {
68
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
69
+ if(!match) return false;
70
+ const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
71
+ return date.getUTCMonth() === +match[2] - 1 && date.getUTCDate() === +match[3];
72
+ },
73
+ 'date-time': value => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test(value) && !isNaN(Date.parse(value)),
74
+ uri: value => {
75
+ try {
76
+ return Boolean(new URL(value).protocol);
77
+ } catch(error) {
78
+ return false;
79
+ }
80
+ }
81
+ };
82
+
83
+ /**
84
+ * Checks the schema itself, so typos fail when the schema is set instead of being ignored
85
+ * @param object schema
86
+ * @param string path
87
+ * @return void
88
+ */
89
+ function checkSchema(schema, path = 'schema')
90
+ {
91
+ if(!isPlainObject(schema))
92
+ throw new Error(`${path} must be an object`);
93
+
94
+ for(const key of Object.keys(schema))
95
+ {
96
+ if(!KEYWORDS.has(key))
97
+ throw new Error(`${path}: unknown keyword "${key}"`);
98
+ }
99
+
100
+ const types = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
101
+ for(const type of types)
102
+ {
103
+ if(!TYPES.includes(type))
104
+ throw new Error(`${path}: unknown type "${type}" (use ${TYPES.join(', ')})`);
105
+ }
106
+
107
+ if(schema.format !== undefined && !FORMATS.includes(schema.format))
108
+ throw new Error(`${path}: unknown format "${schema.format}" (use ${FORMATS.join(', ')})`);
109
+
110
+ if(schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some(name => typeof name !== 'string')))
111
+ throw new Error(`${path}: required must be an array of field names`);
112
+
113
+ if(schema.enum !== undefined && !Array.isArray(schema.enum))
114
+ throw new Error(`${path}: enum must be an array`);
115
+
116
+ if(schema.pattern !== undefined)
117
+ {
118
+ try {
119
+ new RegExp(schema.pattern);
120
+ } catch(error) {
121
+ throw new Error(`${path}: invalid pattern (${error.message})`);
122
+ }
123
+ }
124
+
125
+ for(const key of ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minItems', 'maxItems'])
126
+ {
127
+ if(schema[key] !== undefined && (typeof schema[key] !== 'number' || !Number.isFinite(schema[key])))
128
+ throw new Error(`${path}: ${key} must be a number`);
129
+ }
130
+
131
+ if(schema.properties !== undefined)
132
+ {
133
+ if(!isPlainObject(schema.properties))
134
+ throw new Error(`${path}: properties must be an object`);
135
+
136
+ for(const [name, child] of Object.entries(schema.properties))
137
+ checkSchema(child, `${path}.properties.${name}`);
138
+ }
139
+
140
+ if(schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean')
141
+ checkSchema(schema.additionalProperties, `${path}.additionalProperties`);
142
+
143
+ if(schema.items !== undefined)
144
+ checkSchema(schema.items, `${path}.items`);
145
+ }
146
+
147
+ const describe = value => {
148
+ if(typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
149
+ if(isDate(value)) return value.toISOString();
150
+ return JSON.stringify(value);
151
+ };
152
+
153
+ const same = (a, b) => {
154
+ if(isDate(a) && isDate(b)) return a.getTime() === b.getTime();
155
+ return JSON.stringify(a) === JSON.stringify(b);
156
+ };
157
+
158
+ /**
159
+ * Validates a value against a schema
160
+ * @param mixed value
161
+ * @param object schema
162
+ * @param string path field path for error messages ('' for the document)
163
+ * @param array errors collected errors
164
+ * @return array errors
165
+ */
166
+ function validateValue(value, schema, path = '', errors = [])
167
+ {
168
+ const label = path || 'document';
169
+ const add = (rule, message) => errors.push({ path, rule, message: `${label} ${message}` });
170
+
171
+ if(schema.type !== undefined)
172
+ {
173
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
174
+ if(!types.some(type => matchesType(value, type)))
175
+ {
176
+ add('type', `must be ${types.join(' or ')} (got ${typeOf(value)})`);
177
+ return errors;
178
+ }
179
+ }
180
+
181
+ if(schema.enum !== undefined && !schema.enum.some(option => same(option, value)))
182
+ add('enum', `must be one of ${schema.enum.map(describe).join(', ')}`);
183
+
184
+ if(schema.const !== undefined && !same(schema.const, value))
185
+ add('const', `must be ${describe(schema.const)}`);
186
+
187
+ if(typeof value === 'number')
188
+ {
189
+ if(schema.minimum !== undefined && value < schema.minimum) add('minimum', `must be >= ${schema.minimum}`);
190
+ if(schema.maximum !== undefined && value > schema.maximum) add('maximum', `must be <= ${schema.maximum}`);
191
+ if(schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) add('exclusiveMinimum', `must be > ${schema.exclusiveMinimum}`);
192
+ if(schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) add('exclusiveMaximum', `must be < ${schema.exclusiveMaximum}`);
193
+ }
194
+
195
+ if(typeof value === 'string')
196
+ {
197
+ // count characters, not UTF-16 units, so emoji and accents count once
198
+ const length = [...value].length;
199
+ if(schema.minLength !== undefined && length < schema.minLength) add('minLength', `must have at least ${schema.minLength} characters`);
200
+ if(schema.maxLength !== undefined && length > schema.maxLength) add('maxLength', `must have at most ${schema.maxLength} characters`);
201
+ if(schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value)) add('pattern', `must match ${schema.pattern}`);
202
+ if(schema.format !== undefined && !FORMAT_TESTS[schema.format](value)) add('format', `must be a valid ${schema.format}`);
203
+ }
204
+
205
+ if(Array.isArray(value))
206
+ {
207
+ if(schema.minItems !== undefined && value.length < schema.minItems) add('minItems', `must have at least ${schema.minItems} items`);
208
+ if(schema.maxItems !== undefined && value.length > schema.maxItems) add('maxItems', `must have at most ${schema.maxItems} items`);
209
+ if(schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) add('uniqueItems', 'must not have repeated items');
210
+ if(schema.items !== undefined)
211
+ value.forEach((item, index) => validateValue(item, schema.items, `${path}[${index}]`, errors));
212
+ }
213
+
214
+ if(isPlainObject(value))
215
+ {
216
+ const properties = schema.properties ?? {};
217
+ const child = name => path ? `${path}.${name}` : name;
218
+
219
+ for(const name of schema.required ?? [])
220
+ {
221
+ if(value[name] === undefined)
222
+ errors.push({ path: child(name), rule: 'required', message: `${child(name)} is required` });
223
+ }
224
+
225
+ for(const [name, fieldValue] of Object.entries(value))
226
+ {
227
+ if(fieldValue === undefined)
228
+ continue;
229
+
230
+ if(properties[name] !== undefined)
231
+ {
232
+ validateValue(fieldValue, properties[name], child(name), errors);
233
+ }
234
+ else if(!(path === '' && name === '_id'))
235
+ {
236
+ // _id is always allowed on documents
237
+ if(schema.additionalProperties === false)
238
+ errors.push({ path: child(name), rule: 'additionalProperties', message: `${child(name)} is not allowed` });
239
+ else if(isPlainObject(schema.additionalProperties))
240
+ validateValue(fieldValue, schema.additionalProperties, child(name), errors);
241
+ }
242
+ }
243
+ }
244
+
245
+ return errors;
246
+ }
247
+
248
+ /**
249
+ * Fills missing fields that declare a default (recursively for objects present in the document)
250
+ * @param mixed value
251
+ * @param object schema
252
+ * @param function copy
253
+ * @return void
254
+ */
255
+ function applyDefaults(value, schema, copy)
256
+ {
257
+ if(!isPlainObject(value) || !isPlainObject(schema.properties))
258
+ return;
259
+
260
+ for(const [name, child] of Object.entries(schema.properties))
261
+ {
262
+ if(value[name] === undefined && child.default !== undefined)
263
+ value[name] = copy(child.default);
264
+
265
+ if(value[name] !== undefined)
266
+ applyDefaults(value[name], child, copy);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Compiles a schema (JSON Schema subset or a function) into a validator
272
+ * @param object|function schema
273
+ * @return object {validate(doc): errors, applyDefaults(doc, copy), source}
274
+ */
275
+ function compileSchema(schema)
276
+ {
277
+ if(typeof schema === 'function')
278
+ {
279
+ return {
280
+ source: schema,
281
+ applyDefaults: () => {},
282
+ validate: doc => {
283
+ const result = schema(doc);
284
+ if(result === true || result === undefined || result === null) return [];
285
+ if(result === false) return [{ path: '', rule: 'custom', message: 'document is invalid' }];
286
+ const messages = Array.isArray(result) ? result : [result];
287
+ return messages.map(message => typeof message === 'string'
288
+ ? { path: '', rule: 'custom', message }
289
+ : { path: '', rule: 'custom', ...message });
290
+ }
291
+ };
292
+ }
293
+
294
+ const root = { type: 'object', ...schema };
295
+ checkSchema(root);
296
+ if(root.type !== 'object')
297
+ throw new Error('schema: a document schema must be of type "object"');
298
+
299
+ return {
300
+ source: schema,
301
+ validate: doc => validateValue(doc, root),
302
+ applyDefaults: (doc, copy) => applyDefaults(doc, root, copy)
303
+ };
304
+ }
305
+
306
+ module.exports = { compileSchema, ValidationError, validateValue, checkSchema };
package/src/sorla.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const database = require('./dbs/database.js');
2
+ const { ValidationError } = require('./schema/schema.js');
2
3
 
3
4
  class sorla{
4
5
  /**
@@ -103,6 +104,33 @@ class sorla{
103
104
  return this._dbs.length;
104
105
  }
105
106
 
107
+ /**
108
+ * Names of the databases, in creation order
109
+ * @return array
110
+ */
111
+ listDatabases()
112
+ {
113
+ return this._dbs.map(db => db.name);
114
+ }
115
+
116
+ /**
117
+ * Removes a database and its collections; when it was selected, no database stays selected
118
+ * @param string name
119
+ * @return boolean false when it did not exist
120
+ */
121
+ dropDb(name)
122
+ {
123
+ const pos = this._findDbIndex(name);
124
+ if(pos === -1)
125
+ return false;
126
+
127
+ if(this._selectedDb === this._dbs[pos])
128
+ this._selectedDb = null;
129
+
130
+ this._dbs.splice(pos, 1);
131
+ return true;
132
+ }
133
+
106
134
  /**
107
135
  * Return selected db
108
136
  * @author Anderson Arruda < anderson@sysborg.com.br >
@@ -116,6 +144,9 @@ class sorla{
116
144
  }
117
145
  }
118
146
 
147
+ // thrown when a document does not satisfy the collection schema: error.errors = [{path, rule, message}]
148
+ sorla.ValidationError = ValidationError;
149
+
119
150
  module.exports = sorla;
120
151
  if(typeof window !== 'undefined')
121
152
  {