Comparing version 0.2.0 to 0.3.0
123
lib/model.js
@@ -9,2 +9,6 @@ /** | ||
, originalDateToJSON = Date.prototype.toJSON | ||
, util = require('util') | ||
, _ = require('underscore') | ||
, modifierFunctions = {} | ||
; | ||
@@ -22,4 +26,8 @@ | ||
if (k[0] === '$' && !(k === '$$date' && typeof v === 'number')) { | ||
throw 'Keys cannot begin with the $ character'; | ||
throw 'Field names cannot begin with the $ character'; | ||
} | ||
if (k.indexOf('.') !== -1) { | ||
throw 'Field names cannot contain a .'; | ||
} | ||
} | ||
@@ -29,2 +37,22 @@ | ||
/** | ||
* Check a DB object and throw an error if it's not valid | ||
* Works by applying the above checkKey function to all fields recursively | ||
*/ | ||
function checkObject (obj) { | ||
if (util.isArray(obj)) { | ||
obj.forEach(function (o) { | ||
checkObject(o); | ||
}); | ||
} | ||
if (typeof obj === 'object') { | ||
Object.keys(obj).forEach(function (k) { | ||
checkKey(k, obj[k]); | ||
checkObject(obj[k]); | ||
}); | ||
} | ||
} | ||
/** | ||
* Serialize an object to be persisted to a one-line string | ||
@@ -94,3 +122,2 @@ * Accepted primitive types: Number, String, Boolean, Date, null | ||
Object.keys(obj).forEach(function (k) { | ||
checkKey(k, obj[k]); | ||
res[k] = deepCopy(obj[k]); | ||
@@ -106,2 +133,50 @@ }); | ||
/** | ||
* Set field to value in a model | ||
* Create it if it doesn't exist | ||
* @param {Object} obj The model to set a field for | ||
* @param {String} field Can contain dots, in that case that means we will set a subfield recursively | ||
* @param {Model} value | ||
*/ | ||
modifierFunctions.$set = function (obj, field, value) { | ||
var fieldParts = field.split('.'); | ||
if (fieldParts.length === 1) { | ||
obj[field] = value; | ||
} else { | ||
obj[fieldParts[0]] = obj[fieldParts[0]] || {}; | ||
modifierFunctions.$set(obj[fieldParts[0]], fieldParts.slice(1).join('.'), value); | ||
} | ||
}; | ||
/** | ||
* Increase (or decrease) a 'number' field | ||
* Create and initialize it if needed | ||
* @param {Object} obj The model to set a field for | ||
* @param {String} field Can contain dots, in that case that means we will set a subfield recursively | ||
* @param {Model} value | ||
*/ | ||
modifierFunctions.$inc = function (obj, field, value) { | ||
var fieldParts = field.split('.'); | ||
if (typeof value !== 'number') { throw value + " must be a number"; } | ||
if (fieldParts.length === 1) { | ||
if (typeof obj[field] !== 'number') { | ||
if (!_.has(obj, field)) { | ||
obj[field] = value; | ||
} else { | ||
throw "Don't use the $inc modifier on non-number fields"; | ||
} | ||
} else { | ||
obj[field] += value; | ||
} | ||
} else { | ||
obj[fieldParts[0]] = obj[fieldParts[0]] || {}; | ||
modifierFunctions.$inc(obj[fieldParts[0]], fieldParts.slice(1).join('.'), value); | ||
} | ||
}; | ||
/** | ||
* Modify a DB object according to an update query | ||
@@ -111,5 +186,42 @@ * For now the updateQuery only replaces the object | ||
function modify (obj, updateQuery) { | ||
updateQuery = deepCopy(updateQuery); | ||
updateQuery._id = obj._id; | ||
return updateQuery; | ||
var keys = Object.keys(updateQuery) | ||
, firstChars = _.map(keys, function (item) { return item[0]; }) | ||
, dollarFirstChars = _.filter(firstChars, function (c) { return c === '$'; }) | ||
, newDoc, modifiers | ||
; | ||
if (keys.indexOf('_id') !== -1) { throw "You cannot change a document's _id"; } | ||
if (dollarFirstChars.length !== 0 && dollarFirstChars.length !== firstChars.length) { | ||
throw "You cannot mix modifiers and normal fields"; | ||
} | ||
if (dollarFirstChars.length === 0) { | ||
// Simply replace the object with the update query contents | ||
newDoc = deepCopy(updateQuery); | ||
newDoc._id = obj._id; | ||
} else { | ||
// Apply modifiers | ||
modifiers = _.uniq(keys); | ||
newDoc = deepCopy(obj); | ||
modifiers.forEach(function (m) { | ||
var keys; | ||
if (!modifierFunctions[m]) { throw "Unknown modifier " + m; } | ||
try { | ||
keys = Object.keys(updateQuery[m]); | ||
} catch (e) { | ||
throw "Modifier " + m + "'s argument must be an object"; | ||
} | ||
keys.forEach(function (k) { | ||
modifierFunctions[m](newDoc, k, updateQuery[m][k]); | ||
}); | ||
}); | ||
} | ||
// Check result is valid and return it | ||
checkObject(newDoc); | ||
return newDoc; | ||
}; | ||
@@ -122,2 +234,3 @@ | ||
module.exports.deepCopy = deepCopy; | ||
module.exports.checkObject = checkObject; | ||
module.exports.modify = modify; |
{ | ||
"name": "nedb", | ||
"version": "0.2.0", | ||
"version": "0.3.0", | ||
"author": { | ||
@@ -5,0 +5,0 @@ "name": "tldr.io", |
@@ -425,7 +425,20 @@ var Datastore = require('../lib/datastore') | ||
it('Cannot perform update if the new document contains a field beginning by $', function (done) { | ||
it('Cannot perform update if the update query is not either registered-modifiers-only or copy-only, or contain badly formatted fields', function (done) { | ||
d.insert({ something: 'yup' }, function () { | ||
d.update({}, { boom: { $badfield: 5 } }, { multi: false }, function (err) { | ||
assert.isDefined(err); | ||
done(); | ||
d.update({}, { boom: { "bad.field": 5 } }, { multi: false }, function (err) { | ||
assert.isDefined(err); | ||
d.update({}, { $inc: { test: 5 }, mixed: 'rrr' }, { multi: false }, function (err) { | ||
assert.isDefined(err); | ||
d.update({}, { $inexistent: { test: 5 } }, { multi: false }, function (err) { | ||
assert.isDefined(err); | ||
done(); | ||
}); | ||
}); | ||
}); | ||
}); | ||
@@ -435,2 +448,25 @@ }); | ||
it('Can update documents using multiple modifiers', function (done) { | ||
var id; | ||
d.insert({ something: 'yup', other: 40 }, function (err, newDoc) { | ||
id = newDoc._id; | ||
d.update({}, { $set: { something: 'changed' }, $inc: { other: 10 } }, { multi: false }, function (err, nr) { | ||
assert.isNull(err); | ||
nr.should.equal(1); | ||
d.findOne({ _id: id }, function (err, doc) { | ||
Object.keys(doc).length.should.equal(3); | ||
doc._id.should.equal(id); | ||
doc.something.should.equal('changed'); | ||
doc.other.should.equal(50); | ||
done(); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); // ==== End of 'Update' ==== // | ||
@@ -437,0 +473,0 @@ |
@@ -130,2 +130,36 @@ var model = require('../lib/model') | ||
describe('Object checking', function () { | ||
it('Field names beginning with a $ sign are forbidden', function () { | ||
assert.isDefined(model.checkObject); | ||
(function () { | ||
model.checkObject({ $bad: true }); | ||
}).should.throw(); | ||
(function () { | ||
model.checkObject({ some: 42, nested: { again: "no", $worse: true } }); | ||
}).should.throw(); | ||
// This shouldn't throw since "$actuallyok" is not a field name | ||
model.checkObject({ some: 42, nested: [ 5, "no", "$actuallyok", true ] }); | ||
(function () { | ||
model.checkObject({ some: 42, nested: [ 5, "no", "$actuallyok", true, { $hidden: "useless" } ] }); | ||
}).should.throw(); | ||
}); | ||
it('Field names cannot contain a .', function () { | ||
assert.isDefined(model.checkObject); | ||
(function () { | ||
model.checkObject({ "so.bad": true }); | ||
}).should.throw(); | ||
// Recursive behaviour testing done in the above test on $ signs | ||
}); | ||
}); // ==== End of 'Object checking' ==== // | ||
describe('Deep copying', function () { | ||
@@ -161,14 +195,135 @@ | ||
it('Will throw an error if obj contains a field beginning by the $ sign', function () { | ||
}); // ==== End of 'Deep copying' ==== // | ||
describe('Modifying documents', function () { | ||
it('Queries not containing any modifier just replace the document by the contents of the query but keep its _id', function () { | ||
var obj = { some: 'thing', _id: 'keepit' } | ||
, updateQuery = { replace: 'done', bloup: [ 1, 8] } | ||
, t | ||
; | ||
t = model.modify(obj, updateQuery); | ||
t.replace.should.equal('done'); | ||
t.bloup.length.should.equal(2); | ||
t.bloup[0].should.equal(1); | ||
t.bloup[1].should.equal(8); | ||
assert.isUndefined(t.some); | ||
t._id.should.equal('keepit'); | ||
}); | ||
it('Throw an error if trying to replace the _id field in a copy-type modification', function () { | ||
var obj = { some: 'thing', _id: 'keepit' } | ||
, updateQuery = { replace: 'done', bloup: [ 1, 8], _id: 'donttryit' } | ||
; | ||
(function () { | ||
model.deepCopy({ $something: true }); | ||
model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
}); | ||
it('Throw an error if trying to use modify in a mixed copy+modify way', function () { | ||
var obj = { some: 'thing' } | ||
, updateQuery = { replace: 'me', $modify: 'metoo' }; | ||
(function () { | ||
model.deepCopy({ something: true, another: { $badfield: 'rrr' } }); | ||
model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
}); | ||
}); // ==== End of 'Deep copying' ==== // | ||
it('Throw an error if trying to use an inexistent modifier', function () { | ||
var obj = { some: 'thing' } | ||
, updateQuery = { $set: 'this exists', $modify: 'not this one' }; | ||
(function () { | ||
model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
}); | ||
it('Throw an error if a modifier is used with a non-object argument', function () { | ||
var obj = { some: 'thing' } | ||
, updateQuery = { $set: 'this exists' }; | ||
(function () { | ||
model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
}); | ||
describe('$set modifier', function () { | ||
it('Can change already set fields without modfifying the underlying object', function () { | ||
var obj = { some: 'thing', yup: 'yes', nay: 'noes' } | ||
, updateQuery = { $set: { some: 'changed', nay: 'yes indeed' } } | ||
, modified = model.modify(obj, updateQuery); | ||
Object.keys(modified).length.should.equal(3); | ||
modified.some.should.equal('changed'); | ||
modified.yup.should.equal('yes'); | ||
modified.nay.should.equal('yes indeed'); | ||
Object.keys(obj).length.should.equal(3); | ||
obj.some.should.equal('thing'); | ||
obj.yup.should.equal('yes'); | ||
obj.nay.should.equal('noes'); | ||
}); | ||
it('Creates fields to set if they dont exist yet', function () { | ||
var obj = { yup: 'yes' } | ||
, updateQuery = { $set: { some: 'changed', nay: 'yes indeed' } } | ||
, modified = model.modify(obj, updateQuery); | ||
Object.keys(modified).length.should.equal(3); | ||
modified.some.should.equal('changed'); | ||
modified.yup.should.equal('yes'); | ||
modified.nay.should.equal('yes indeed'); | ||
}); | ||
it('Can set sub-fields and create them if necessary', function () { | ||
var obj = { yup: { subfield: 'bloup' } } | ||
, updateQuery = { $set: { "yup.subfield": 'changed', "yup.yop": 'yes indeed', "totally.doesnt.exist": 'now it does' } } | ||
, modified = model.modify(obj, updateQuery); | ||
_.isEqual(modified, { yup: { subfield: 'changed', yop: 'yes indeed' }, totally: { doesnt: { exist: 'now it does' } } }).should.equal(true); | ||
}); | ||
}); | ||
describe('$inc modifier', function () { | ||
it('Throw an error if you try to use it with a non-number or on a non number field', function () { | ||
(function () { | ||
var obj = { some: 'thing', yup: 'yes', nay: 2 } | ||
, updateQuery = { $inc: { nay: 'notanumber' } } | ||
, modified = model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
(function () { | ||
var obj = { some: 'thing', yup: 'yes', nay: 'nope' } | ||
, updateQuery = { $inc: { nay: 1 } } | ||
, modified = model.modify(obj, updateQuery); | ||
}).should.throw(); | ||
}); | ||
it('Can increment number fields or create and initialize them if needed', function () { | ||
var obj = { some: 'thing', nay: 40 } | ||
, modified; | ||
modified = model.modify(obj, { $inc: { nay: 2 } }); | ||
_.isEqual(modified, { some: 'thing', nay: 42 }).should.equal(true); | ||
// Incidentally, this tests that obj was not modified | ||
modified = model.modify(obj, { $inc: { inexistent: -6 } }); | ||
_.isEqual(modified, { some: 'thing', nay: 40, inexistent: -6 }).should.equal(true); | ||
}); | ||
it('Works recursively', function () { | ||
var obj = { some: 'thing', nay: { nope: 40 } } | ||
, modified; | ||
modified = model.modify(obj, { $inc: { "nay.nope": -2, "blip.blop": 123 } }); | ||
_.isEqual(modified, { some: 'thing', nay: { nope: 38 }, blip: { blop: 123 } }).should.equal(true); | ||
}); | ||
}); | ||
}); // ==== End of 'Modifying documents' ==== // | ||
}); |
55318
1426