obj_diff
Version:
Identify differences between objects; assert permitted and mandatory differences
293 lines (210 loc) • 10.8 kB
Markdown
# Identify and assert differences betwen objects
obj_diff is for examining changes between Javascript and JSON objects. Use it to **see how data has changed** and to **decide whether that change is good or bad**. Thus obj_diff is useful for security and validation.
obj_diff comes from an internal [Iris Couch][ic] application used in production for two years. It works in the browser, in CouchDB, and as an NPM module.
npm install obj_diff
## Is it any good?
Yes.
## Usage
Diff two objects. Then use helper functions to see what's changed.
```javascript
var obj_diff = require("obj_diff");
var original = { hello:"world" , note: {"nice":"shoes"} };
var modified = { hello:"underworld", note: {"nice":"hat" } };
var diff = obj_diff(original, modified);
// Mandatory changes
if(diff.atleast("hello", "world", "underworld")) // true
console.log("That's kind of dark");
// Approved changes
if(diff.atmost("hello", "world", "underworld")) // false (.hello.note.nice also changed)
console.log("That's kind of dark");
if(diff.atmost("hello" , "world", /world/, // true
"hello.note.nice", "shoes", String))
console.log("Hooray!");
```
## Design
To work well with databases, obj_diff has these design goals:
* **Declarative**. Data validation is crucial. It must be correct. Validation rules must be easy to express clearly and easy to reason about.
* **JSON compatible**. Diffs and validation rules (containing regexes, functions, etc.) can be encoded and decoded as JSON, without losing functionality. You can store changes and validation policies as plain JSON.
## Mandatory vs. Approved changes
There is a symbiotic relationship between *atleast* and *atmost*:
* atleast() returns `true` only if **every rule matches a change**.
* atmost() returns `true` only if **every change matches a rule**.
```javascript
// Give a key name, an expected old value, and expected new value.
diff.atleast("some_key", "old_value", "new_value");
// Specify multiple rules simultaneously.
diff.atleast(
// Nested objects: just type them out in the string.
"options.production.log.level", "debug", "info",
// Regular expressions, e.g. first letter must change from "J" to "S".
"name", /^J/, /^S/,
// ANY matches any value.
"state", obj_diff.ANY, "run", // State must become "run".
"owner", null, obj_diff.ANY, // Owner must become non-null.
// GONE implies a missing value.
"error", "locked", obj_diff.GONE, // Error must be deleted.
"child", obj_diff.GONE, "Bob", // Child must be created.
// FALSY matches false, null, undefined, the empty string, 0, NaN, and a missing value.
"is_new", obj_diff.ANY, obj_diff.FALSY,
// "TRUTHY" matches anything not falsy.
"changed", obj_diff.GONE, obj_diff.TRUTHY,
// Javascript types
"ratio" , undefined , Number , // Numeric ratio, note undefined is not GONE
"age" , obj_diff.ANY , Number , // Age must change to something numeric.
"name" , obj_diff.GONE, String , // Must create a name string.
"deleted", obj_diff.ANY , Boolean, // Deleted flag must be true/false.
"config" , obj_diff.GONE, Object , // Must create a config object.
"backups", null , Array , // Null backups must become an array.
// TIMESTAMP matches ISO-8601 strings (what JSON.stringify makes from a Date)
"created_at", GONE, TIMESTAMP, // e.g. "2011-11-10T04:21:45.046Z"
// GREATER and LESSER compare a value to its counterpart.
"age", Number, GREATER, // Age must increase in number
"age", LESSER, Number, // (same as the previous test)
"weight", GREATER, LESSER , // Mandatory weight loss
"age" , 21 , GREATER, // Must increase from 21
"WRONG", GREATER, GREATER, // This always fails.
"WRONG", LESSER , LESSER , // This always fails.
// Use functions (predicates) for arbitrary data validation
"weapon", obj_diff.ANY, good_weapon
);
diff.atmost(
// Changing my weapon is fine.
"weapon", obj_diff.ANY, good_weapon,
// Changing my first name to something readable is fine.
"name.first", obj_diff.ANY, /^\w+$/,
// People named "Smith" may change their last name.
"name.last", "Smith", /^\w+$/,
// Middle must be just an initial.
"name.middle", obj_diff.ANY, /^\w$/
);
// Or as an assertion, with an extra "reason" argument.
try {
diff.assert_atleast(
"some_key" , "must become new new" , "old_value" , "new_value",
"options.log.level", "must upgrade to info", "debug" , "info",
"name" , "must start with 'S'" , obj_diff.ANY, /^S/,
"weapon" , "cannot be sharp" , obj_diff.ANY, good_weapon
);
} catch (er) {
if(!er.diff)
throw er; // Unknown error, not a policy failure, e.g. bad parameters, or a predicate error.
console.error("Hey! " + er.message); // e.g. Hey! options.log.level must upgrade to info
}
try {
diff.assert_atmost(
"weapon" , "cannot be sharp" , obj_diff.ANY, good_weapon,
"name.first" , "must be readable" , obj_diff.ANY, /^\w+$/,
"name.last" , "may no longer be Smith", "Smith" , /^\w+$/,
"name.middle", "must be one letter" , obj_diff.ANY, /^\w$/
);
} catch (er) {
if(!er.diff)
throw er; // Unknown error
// .reason, .key, .from, .to are available.
console.error(er.key + " is wrong because it " + er.reason); // detailed
}
function good_weapon(weapon) {
return weapon != process.env.sharp_weapon;
}
```
A useful trick with *atmost()* is to assert no changes.
```javascript
try {
diff2.assert_atmost(); // No rules given, i.e. "zero changes, at most"
diff2.assert_nochange(); // Same as atmost() but more readable.
} catch (er) {
console.error("Sorry, no changes allowed");
}
```
<a name="couchdb"></a>
## CouchDB validation
obj_diff excels (and was designed for) [Apache CouchDB][couchdb] `validate_doc_update()` functions. Combine *atleast()* and *atmost()* to make a sieve and sift out good and bad changes. obj_diff cannot replace all validation code, but it augments it well.
* *atleast()* confirms **required** changes.
* *atmost()* confirms **allowed** changes.
First of all, CouchDB changes document metadata under the hood, and you don't want that triggering false alarms. So the first thing is to set obj_diff's [defaults][def] for CouchDB mode, which modifies *atmost()* to allow normal document changes:
1. `null` is treated as an empty object, `{}`. This always works: `doc_diff(oldDoc, newDoc)`
2. *atmost()* allows normal changes:
* `_id` for document creation
* `_rev` may change appropriately.
* `_revisions.ids` and `_revisions.start` may change appropriately.
3. *assert_atleast()* and *assert_atmost()* throw `{"forbidden": <reason>}` objects that Couch likes.
Thus, this is your typical `validate_doc_update` function:
```javascript
function(newDoc, oldDoc, userCtx, secObj) {
var doc_diff = require("obj_diff").defaults({"couchdb":true}) // Relaxed diff.
, ANY = doc_diff.ANY
, GONE = doc_diff.GONE
;
var diff = doc_diff(oldDoc, newDoc);
// Start validating!
}
```
### Valid data vs. valid changes
obj_diff validates *changes*, not *data*. What happens if you GET a document and PUT it back unmodified? There will be zero changes in the diff. Any *atleast()* checks will necessarily fail. Therefore, the best practice is to check the data and then apply certain policies based on that.
Of course, sometimes you *want* changes in every update, such as timestamp validation:
```javascript
if(!oldDoc)
// Creation, require the timestamp fields.
diff.assert_atleast(
'created_at', 'timestamp required', GONE, TIMESTAMP,
'updated_at', 'timestamp required', GONE, newDoc.created_at // Must match created_at
);
else
// Update, exact() will reject changes to .created_at (and all other fields)
diff.assert_exactly(
'updated_at', 'Must be a timestamp' , TIMESTAMP, TIMESTAMP,
'updated_at', 'Must be later in time', TIMESTAMP, GREATER
);
```
### Example: User Documents
TODO
## JSON Support
obj_diff supports regular expressions and function callbacks in its rules. Yet it can be nice to store them as JSON, and to load them later. For example, you could store a few rules in a CouchDB `_security` object, and do database-specific data validation with an identical `validate_doc_update()` function.
Both Diff and Rule obejcts behave the same after a JSON round-trip. They have a `.toJSON` function to handle things, so just `JSON.stringify()` them and store them in a file or database. Later, `JSON.parse()` them and pass the object to the constructors.
```javascript
var obj_diff = require("obj_diff");
function good_guy(guy) { return guy.good || guy.awesome }
var diffs =
[ obj_diff({some_key: "old_value"}, {some_key: "new_value"})
, obj_diff({log: {level: "Anything!"}}, {log: {level: "info"}})
, obj_diff({guy: {"good":true}}, {guy:"Fawkes"})
];
var rules =
[ new obj_diff.Rule("some_key", "old_value", "new_value")
, new obj_diff.Rule("log.level", obj_diff.ANY, /^(debug|info|error)$/)
, new obj_diff.Rule("guy", good_guy, obj_diff.ANY)
];
console.log("Diffs: " + JSON.stringify(diffs));
console.log("Rules: " + JSON.stringify(rules));
```
Note, functions are stored using their source code, so be careful about global or closed variables they depend on.
## Development
obj_diff uses [node-tap][tap] unit tests. Install it globally (`npm -g install node-tap`) and run `tap t`. Or for a more robust local install:
$ npm install --dev
tap@0.0.10 ./node_modules/tap
└── tap-runner@0.0.7
$ ./node_modules/.bin/tap t
ok api.js ......................... 82/82
ok diffs.js ....................... 60/60
ok policy.js .................... 123/123
ok rules.js ..................... 774/774
total ......................... 1043/1043
ok
Finally, you can use the diff object yourself. Here's what it looks like:
> obj_diff({x:"hi"}, {x:"bye"})
{ x: { from: 'hi', to: 'bye' } }
> obj_diff({name:"Joe", word:"hi"},
... {name:"Joe", word:"bye"})
{ word: { from: 'hi', to: 'bye' } }
> obj_diff({name:"Joe", contact: {email:"doe@example.com"}},
... {name:"Joe", contact: {email:"doe@example.com", cell:"555-1212"}})
{ contact: { cell: { from: ['gone'], to: '555-1212' } } }
> obj_diff({name:"Joe", contact: {email:"doe@example.com", cell:null }},
... {name:"Joe", contact: {email:"doe@example.com", cell:"555-1212"}})
{ contact: { cell: { from: null, to: '555-1212' } } }
## License
obj_diff is licensed under the Apache License, version 2.0
[def]: https://github.com/iriscouch/defaultable
[ic]: http://www.iriscouch.com/
[couchdb]: http://couchdb.apache.org/
[tap]: https://github.com/isaacs/node-tap