Skip to content

Commit

Permalink
feat: validation for doc mutations (#166)
Browse files Browse the repository at this point in the history
  • Loading branch information
mbad0la authored and gr2m committed Nov 26, 2017
1 parent 0bcb6c4 commit 5beae55
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 16 deletions.
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -98,7 +98,7 @@ new Store(dbName, options)
| **`options.remote`** | Object | PouchDB instance | Yes (ignores `remoteBaseUrl` from [Store.defaults](#storedefaults))
| **`options.remote`** | Promise | Resolves to either string or PouchDB instance | see above
| **`options.PouchDB`** | Constructor | [PouchDB custom builds](https://pouchdb.com/custom.html) | Yes (unless preset using [Store.defaults](#storedefaults)))
| **`options.validate`** | Function | Validation function to execute before DB operations (Can return promise for async validation) | No
| **`options.validate`** | Function(doc) | Validation function to execute before DB operations (Can return promise for async validation) | No

Returns `store` API.

Expand Down
7 changes: 4 additions & 3 deletions lib/helpers/update-many.js
Expand Up @@ -24,12 +24,13 @@ module.exports = function updateMany (state, array, change, prefix) {

.then(function (docs) {
if (change) {
return docs.map(function (doc) {
return Promise.all(docs.map(function (doc) {
if (doc instanceof Error) {
return doc
}
return changeObject(change, doc)
})

return changeObject(state, change, doc)
}))
}

return docs.map(function (doc, index) {
Expand Down
3 changes: 2 additions & 1 deletion lib/helpers/update-one.js
Expand Up @@ -21,7 +21,8 @@ module.exports = function updateOne (state, idOrDoc, change, prefix) {
if (!change) {
return assign(doc, idOrDoc, {_id: doc._id, _rev: doc._rev, hoodie: doc.hoodie})
}
return changeObject(change, doc)

return changeObject(state, change, doc)
})

.then(function (_doc) {
Expand Down
16 changes: 13 additions & 3 deletions lib/remove.js
Expand Up @@ -15,7 +15,17 @@ module.exports = remove
* @return {Promise}
*/
function remove (state, prefix, objectsOrIds, change) {
return Array.isArray(objectsOrIds)
? updateMany(state, objectsOrIds.map(markAsDeleted.bind(null, change)), null, prefix)
: updateOne(state, markAsDeleted(change, objectsOrIds), null, prefix)
if (Array.isArray(objectsOrIds)) {
return Promise.all(objectsOrIds.map(markAsDeleted.bind(null, state, change)))

.then(function (docs) {
return updateMany(state, docs, null, prefix)
})
}

return markAsDeleted(state, change, objectsOrIds)

.then(function (doc) {
return updateOne(state, doc, null, prefix)
})
}
16 changes: 12 additions & 4 deletions lib/utils/change-object.js
@@ -1,15 +1,23 @@
var assign = require('lodash/assign')
var validate = require('../validate')

/**
* change object either by passing changed properties
* as an object, or by passing a change function that
* manipulates the passed object directly
**/
module.exports = function changeObject (change, object) {
module.exports = function changeObject (state, change, object) {
var updatedObject = assign({}, object)

if (typeof change === 'object') {
return assign(object, change)
updatedObject = assign(object, change)
} else {
change(updatedObject)
}

change(object)
return object
return validate(state, updatedObject)

.then(function () {
return updatedObject
})
}
12 changes: 8 additions & 4 deletions lib/utils/mark-as-deleted.js
@@ -1,13 +1,17 @@
var assign = require('lodash/assign')
var changeObject = require('./change-object')

// Normalizes objectOrId, applies changes if any, and mark as deleted
module.exports = function markAsDeleted (change, objectOrId) {
// Normalizes objectOrId, validates changes if any, applies them, and mark as deleted
module.exports = function markAsDeleted (state, change, objectOrId) {
var object = typeof objectOrId === 'string' ? { _id: objectOrId } : objectOrId

if (change) {
changeObject(change, object)
return changeObject(state, change, object)

.then(function (doc) {
return assign({_deleted: true}, doc)
})
}

return assign({_deleted: true}, object)
return Promise.resolve(assign({_deleted: true}, object))
}
87 changes: 87 additions & 0 deletions tests/integration/remove.js
Expand Up @@ -293,6 +293,93 @@ test('remove(id, changeFunction) updates before removing', function (t) {
})
})

test('remove(id, changeFunction) fails modification validation', function (t) {
t.plan(3)

var validationCallCount = 0

var name = uniqueName()
var store = new Store(name, {
PouchDB: PouchDB,
remote: 'remote-' + name,
validate: function () {
if (validationCallCount) {
throw new Error('Could not modify object')
}

++validationCallCount
}
})

store.add({
_id: 'foo',
foo: 'bar'
})

.then(function () {
return store.remove('foo', function (doc) {
doc.foo = 'changed'
return doc
})
})

.catch(function (error) {
t.equals(validationCallCount, 1, 'Expecting Remove to fail validation')
t.is(error.name, 'ValidationError')
t.is(error.message, 'Could not modify object')
})
})

test('remove([ids], changeFunction) fails modification validation when one validation fails', function (t) {
t.plan(4)

var validationCallCount = 0

var name = uniqueName()
var store = new Store(name, {
PouchDB: PouchDB,
remote: 'remote-' + name,
validate: function (doc) {
if (validationCallCount > 1) {
if (doc._id === 'bar') {
throw new Error()
}
}

++validationCallCount
}
})

store.add([
{ _id: 'foo', foo: 'bar' },
{ _id: 'bar', foo: 'bar' }
])

.then(function () {
return store.remove(
['foo', 'bar'],
function (doc) {
doc.foo = 'changed'
return doc
}
)
})

.catch(function (error) {
t.equals(validationCallCount, 3, 'Expecting last remove to fail validation')
t.is(error.name, 'ValidationError')
t.is(error.message, 'document validation failed')

return null
})

.then(store.findAll)

.then(function (objects) {
t.is(objects.length, 2)
})
})

test('store.remove(object) creates deletedAt timestamp', function (t) {
t.plan(4)

Expand Down
100 changes: 100 additions & 0 deletions tests/integration/update.js
Expand Up @@ -117,6 +117,46 @@ test('store.update(id, updateFunction)', function (t) {
})
})

test('store.update(id, updateFunction) fails validation', function (t) {
t.plan(5)

var name = uniqueName()
var store = new Store(name, {
PouchDB: PouchDB,
remote: 'remote-' + name,
validate: function (doc) {
if (doc.foo) {
throw new Error()
}
}
})

store.add({ _id: 'exists' })

.then(function () {
return store.update('exists', function (object) {
object.foo = object._id + 'bar'
})
})

.catch(function (error) {
t.is(error.name, 'ValidationError')
t.is(error.message, 'document validation failed')

return null
})

.then(function () {
return store.find('exists')
})

.then(function (doc) {
t.is(doc._id, 'exists')
t.false(/^2-/.test(doc._rev))
t.is(doc.foo, undefined)
})
})

test('store.update(object)', function (t) {
t.plan(3)

Expand Down Expand Up @@ -485,3 +525,63 @@ test('store.update(array)', function (t) {
t.is(objects[1].bar, 'baz')
})
})

test('store.update([objects], change) fails to update as one doc fails validation', function (t) {
t.plan(12)

var validationCallCount = 0

var name = uniqueName()
var store = new Store(name, {
PouchDB: PouchDB,
remote: 'remote-' + name,
validate: function (doc) {
if (validationCallCount > 2) {
if (doc.foo === 'baz') {
throw new Error('document validation failed')
}
}

++validationCallCount
}
})

return store.add([
{ _id: '1', foo: 'foo' },
{ _id: '2', foo: 'bar' },
{ _id: '3', foo: 'baz', bar: 'foo' }
])

.then(function () {
return store.update(
[1, 2, 3],
{
bar: 'bar',
hoodie: {ignore: 'me'}
}
)
})

.catch(function (error) {
t.is(validationCallCount, 5, 'needs to fail the validation for last object update')
t.is(error.name, 'ValidationError')
t.is(error.message, 'document validation failed')

return null
})

.then(store.findAll)

.then(function (objects) {
objects.forEach(function (object, idx) {
t.ok(object.foo, 'old value remains')
t.false(/^2-/.test(object._rev))

if (idx === 2) {
t.is(object.bar, 'foo', 'object not updated')
} else {
t.is(object.bar, undefined)
}
})
})
})

0 comments on commit 5beae55

Please sign in to comment.