-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
We've moved to a new diff engine (codename Omnom) which is so much better than the original diff algorithm as to make any comparison laughable. The biggest reason for the move was to allow us to support array diffs in an intelligent way while still allowing single queries to function correctly. To achieve this, there are a few important implications which you need to take into account - namely that using the built in algorithm for combinations of pushes and pulls on arrays is a TERRIBLE IDEA. This is due to the way that arrays are handled to support single queries - since MongoDB doesn't allow combinations of $push/$pull/$set on an array within a single query for consistency reasons. USE IT FOR: - Changing the values of array elements (and their child values) without changing their position in the array - Adding array elements and making changes to existing ones (at the same time if you wish) BIG DON'TS: - Removing array elements while adding/modifying others - Insertion at the front of an array (Forces the array to be replaced)
- Loading branch information
1 parent
d8134a9
commit ab9eb07
Showing
6 changed files
with
527 additions
and
206 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,199 @@ | ||
var _ = require('lodash'); | ||
|
||
module.exports = diff; | ||
|
||
function diff(original, modified) { | ||
var omnom = new Omnom({ | ||
cautious: true, | ||
orderedArrays: true | ||
}); | ||
|
||
omnom.diff(original, modified); | ||
|
||
return omnom.changes; | ||
} | ||
|
||
function Omnom(options) { | ||
this.options = options; | ||
this.changes = {}; | ||
} | ||
|
||
Omnom.prototype.diff = function(original, modified) { | ||
this.onObject(original, modified); | ||
}; | ||
|
||
Omnom.prototype.onObject = function(original, modified, changePath) { | ||
if(original === undefined || original === null) | ||
return (original !== modified) && this.set(changePath, modified); | ||
|
||
if(typeof original == 'number' && typeof modified == 'number' && original !== modified) | ||
return this.inc(changePath, modified - original); | ||
|
||
if(Array.isArray(original) && Array.isArray(modified)) | ||
return this.onArray(original, modified, changePath); | ||
|
||
if(!_.isPlainObject(original) || !_.isPlainObject(modified)) | ||
return (original != modified) && this.set(changePath, modified); | ||
|
||
_.each(modified, function(value, key) { | ||
// Handle array diffs in their own special way | ||
if(Array.isArray(value) && Array.isArray(original[key])) this.onArray(original[key], value, resolve(changePath, key)); | ||
|
||
// Otherwise, just keep going | ||
else this.onObject(original[key], value, resolve(changePath, key)); | ||
}, this); | ||
|
||
// Unset removed properties | ||
_.each(original, function(value, key) { | ||
if(modified[key] === undefined || modified[key] === null) return this.unset(resolve(changePath, key)); | ||
}, this); | ||
}; | ||
|
||
Omnom.prototype.onArray = function(original, modified, changePath) { | ||
var i,j; | ||
|
||
// Check if we can get from original => modified using just pulls | ||
if(original.length > modified.length) { | ||
var pulls = []; | ||
for(i = 0, j = 0; i < original.length && j < modified.length; i++) { | ||
if(almostEqual(original[i], modified[j])) j++; | ||
else pulls.push(original[i]); | ||
} | ||
|
||
for(; i < original.length; i++) | ||
pulls.push(original[i]); | ||
|
||
if(j === modified.length) { | ||
if(pulls.length === 1) return this.pull(changePath, pulls[0]); | ||
// We can complete using just pulls | ||
return this.pullAll(changePath, pulls); | ||
} | ||
|
||
// If we have a smaller target array than our source, we will need to re-create it | ||
// regardless (if we want to do so in a single operation anyway) | ||
else return this.set(changePath, modified); | ||
} | ||
|
||
// Check if we can get from original => modified using just pushes | ||
if(original.length < modified.length) { | ||
var canPush = true; | ||
for(i = 0; i < original.length; i++) | ||
if(almostEqual(original[i], modified[i]) < 1) { | ||
canPush = false; | ||
break; | ||
} | ||
|
||
if(canPush) { | ||
for(i = original.length; i < modified.length; i++) | ||
this.push(changePath, modified[i]); | ||
return; | ||
} | ||
} | ||
|
||
// Otherwise, we need to use $set to generate the new array | ||
|
||
// Check how many manipulations would need to be performed, if it's more than half the array size | ||
// then rather re-create the array | ||
|
||
var sets = []; | ||
var partials = []; | ||
for(i = 0; i < modified.length; i++) { | ||
var equality = almostEqual(original[i], modified[i]); | ||
if(equality === 0) sets.push(i); | ||
else if(equality < 1) partials.push(i); | ||
} | ||
|
||
if(sets.length > modified.length / 2) | ||
return this.set(changePath, modified); | ||
|
||
for(i = 0; i < sets.length; i++) | ||
this.set(resolve(changePath, sets[i].toString()), modified[sets[i]]); | ||
|
||
for(i = 0; i < partials.length; i++) | ||
this.onObject(original[partials[i]], modified[partials[i]], resolve(changePath, partials[i].toString())); | ||
}; | ||
|
||
Omnom.prototype.set = function(path, value) { | ||
if(!this.changes.$set) | ||
this.changes.$set = {}; | ||
|
||
this.changes.$set[path] = value; | ||
}; | ||
|
||
Omnom.prototype.unset = function(path, value) { | ||
if(!this.changes.$unset) | ||
this.changes.$unset = {}; | ||
|
||
this.changes.$unset[path] = 1; | ||
}; | ||
|
||
Omnom.prototype.inc = function(path, value) { | ||
if(!this.changes.$inc) | ||
this.changes.$inc = {}; | ||
|
||
this.changes.$inc[path] = value; | ||
}; | ||
|
||
Omnom.prototype.push = function(path, value) { | ||
if(!this.changes.$push) | ||
this.changes.$push = {}; | ||
|
||
if(this.changes.$push[path]) { | ||
if(this.changes.$push[path].$each) | ||
this.changes.$push[path].$each.push(value); | ||
else | ||
this.changes.$push[path] = { $each: [this.changes.$push[path], value] }; | ||
} else this.changes.$push[path] = value; | ||
}; | ||
|
||
Omnom.prototype.pull = function(path, value) { | ||
if(!this.changes.$pull) | ||
this.changes.$pull = {}; | ||
|
||
if(this.changes.$pullAll && this.changes.$pullAll[path]) { | ||
return this.changes.$pullAll[path].push(value); | ||
} | ||
|
||
if(this.changes.$pull[path]) { | ||
this.pullAll(path, [this.changes.$pull[path], value]); | ||
delete this.changes.$pull[path]; | ||
return; | ||
} | ||
|
||
this.changes.$pull[path] = value; | ||
}; | ||
|
||
Omnom.prototype.pullAll = function(path, values) { | ||
if(!this.changes.$pullAll) | ||
this.changes.$pullAll = {}; | ||
|
||
this.changes.$pullAll[path] = values; | ||
}; | ||
|
||
function resolve() { | ||
var validArguments = []; | ||
Array.prototype.forEach.call(arguments, function(arg) { | ||
if(arg) validArguments.push(arg); | ||
}); | ||
return validArguments.join('.'); | ||
} | ||
|
||
var almostEqual = function (o1, o2) { | ||
if(!_.isPlainObject(o1) || !_.isPlainObject(o2)) return o1 == o2 ? 1 : 0; | ||
|
||
var o1i, o1k = Object.keys(o1); | ||
var o2i, o2k = Object.keys(o2); | ||
|
||
var commonKeys = []; | ||
for(o1i = 0; o1i < o1k.length; o1i++) | ||
if(~o2k.indexOf(o1k[o1i])) commonKeys.push(o1k[o1i]); | ||
|
||
var totalKeys = o1k.length + o2k.length - commonKeys.length; | ||
var keysDifference = totalKeys - commonKeys.length; | ||
|
||
var requiredChanges = 0; | ||
for(var i = 0; i < commonKeys.length; i++) | ||
if(almostEqual(o1[commonKeys[i]], o2[commonKeys[i]]) < 1) requiredChanges++; | ||
|
||
return 1 - (keysDifference / totalKeys) - (requiredChanges / commonKeys.length); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
var diff = require('../lib/utils/diff'); | ||
|
||
describe('diff', function() { | ||
it('should correctly diff basic objects', function() { | ||
var o1 = { | ||
a: 1, | ||
b: 'test', | ||
c: 2, | ||
d: 'constant', | ||
e: 'old' | ||
}; | ||
|
||
var o2 = { | ||
a: 3, | ||
b: 'tested', | ||
c: 2, | ||
d: 'constant', | ||
f: 'new' | ||
}; | ||
|
||
var expected = { | ||
$inc: { a: 2 }, | ||
$set: { b: 'tested', f: 'new' }, | ||
$unset: { e: 1 } | ||
}; | ||
|
||
diff(o1, o2).should.eql(expected); | ||
}); | ||
|
||
it('should correctly diff complex objects', function() { | ||
var o1 = { | ||
a: { value: 1 }, | ||
b: { value1: 1, value2: 1 }, | ||
c: { value: 2 }, | ||
d: { value: {} }, | ||
e: { value: true } | ||
}; | ||
|
||
var o2 = { | ||
a: { value: 3 }, | ||
b: { value1: 'tested', value2: 2 }, | ||
c: { value: 2 }, | ||
d: { value: {} }, | ||
e: { value2: false } | ||
}; | ||
|
||
var expected = { | ||
$inc: { 'a.value': 2, 'b.value2': 1 }, | ||
$set: { 'b.value1': 'tested', 'e.value2': false }, | ||
$unset: { 'e.value': 1 } | ||
}; | ||
|
||
diff(o1, o2).should.eql(expected); | ||
}); | ||
|
||
describe('arrays', function() { | ||
it('should correctly handle arrays which can be pulled', function() { | ||
var a1 = { a: [1,2,3,4], b: [1,2,3,4] }; | ||
var a2 = { a: [1,3,4], b: [1,3] }; | ||
var expected = { | ||
$pull: { a: 2 }, | ||
$pullAll: { b: [2,4] } | ||
}; | ||
|
||
diff(a1, a2).should.eql(expected); | ||
}); | ||
|
||
it('should correctly handle arrays which can be pushed', function() { | ||
var a1 = { a: [1,2,3,4], b: [1,2,3,4] }; | ||
var a2 = { a: [1,2,3,4,5], b: [1,2,3,4,5,6] }; | ||
var expected = { | ||
$push: { a: 5, b: { $each: [5,6] }} | ||
}; | ||
|
||
diff(a1, a2).should.eql(expected); | ||
}); | ||
|
||
it('should correctly handle arrays which should be replaced', function() { | ||
var a1 = { a: [1,2], b: [1,2,3] }; | ||
var a2 = { a: [5,4,3], b: [5,4,3,2] }; | ||
var expected = { | ||
$set: { | ||
a: [5,4,3], | ||
b: [5,4,3,2] | ||
} | ||
}; | ||
|
||
diff(a1, a2).should.eql(expected); | ||
}); | ||
|
||
it("should correctly handle arrays which can be partially modified", function() { | ||
var a1 = { a: [1,2,3,4], b: [1,2,3,4] }; | ||
var a2 = { a: [1,2,5,4,5], b: [1,2,5,4,5,6] }; | ||
var expected = { | ||
$set: { | ||
'a.2': 5, | ||
'a.4': 5, | ||
'b.2': 5, | ||
'b.4': 5, | ||
'b.5': 6 | ||
} | ||
}; | ||
|
||
diff(a1, a2).should.eql(expected); | ||
}); | ||
|
||
it("should correctly diff array elements as objects", function() { | ||
var postDate = new Date(); | ||
var a1 = { comments: [ | ||
{ id: 1, title: 'Title 1', text: 'test text 1', posted: postDate }, | ||
{ id: 2, title: 'Title 2', text: 'test text 2', posted: postDate }, | ||
{ id: 3, title: 'Title 3', text: 'test text 3', posted: postDate } | ||
]}; | ||
|
||
var newDate = new Date(postDate.getTime() + 50); | ||
var a2 = { comments: [ | ||
{ id: 1, title: 'Title 1', text: 'tested text 1', posted: postDate }, | ||
{ id: 2, title: 'Title 2', text: 'tested text 2', posted: postDate }, | ||
{ id: 3, title: 'Title 3', text: 'test text 3', posted: newDate } | ||
]}; | ||
|
||
var expected = { | ||
$set: { | ||
'comments.0.text': 'tested text 1', | ||
'comments.1.text': 'tested text 2', | ||
'comments.2.posted': newDate | ||
} | ||
}; | ||
|
||
diff(a1, a2).should.eql(expected); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.