Repository: rtfeldman/seamless-immutable (js)

Issue:
Custom merger function
I have a use case where I would like to override the normal merge behaviour for specific types or conditions. 

I was thinking that you could send a custom merger function as a parameter with the config to `merge`. If that function returns `undefined` the merge would proceed as normal. If it returns something else then that value will be used instead of the normal rules.

A simple example that combines arrays instead of replacing them:
```
var one = {
  id: 3,
  list: [1, 3, 5]
};

var two = {
  id: 3,
  list: [2, 4, 6]
};

var arrayConcatMerger = function(current, other) {
  if (current instanceof Array && other instanceof Array) {
    return current.concat(other);
  }

  return;
}

var result = one.merge(two, {merger: arrayConcatMerger});
```

And the result would be:
```
 {
  id: 3,
  list: [1, 3, 5, 2, 4, 6]
};
```

This would allow you to do more complex stuff like merging objects in an array if there exists an object with the same id in both arrays and so on.

Pull request description:
Custom merger function
This allows you to pass a custom merger function that can override the merge of a property if the conditions are right. See #26 for details.

Any thoughts or optimizations I can do?

This fixes #26.

Patch:
diff --git a/seamless-immutable.js b/seamless-immutable.js
index a05315d..fc368ff 100644
--- a/seamless-immutable.js
+++ b/seamless-immutable.js
@@ -220,7 +220,8 @@
    * values whenever the same key is present in both objects.
    *
    * @param {object} other - The other object to merge. Multiple objects can be passed as an array. In such a case, the later an object appears in that list, the higher its priority.
-   * @param {object} config - Optional config object that contains settings. Right now only {deep: true} is supported for a merge deep.
+   * @param {object} config - Optional config object that contains settings. Supported settings are: {deep: true} for deep merge and {merger: mergerFunc} where mergerFunc is a function
+   *                          that takes a property from both objects. If anything is returned it overrides the normal merge behaviour.
    */
   function merge(other, config) {
     // Calling .merge() with no arguments is a no-op. Don't bother cloning.
@@ -236,6 +237,7 @@
         result        = quickCopy(this, {}), // A shallow clone of this object.
         receivedArray = (other instanceof Array),
         deep          = config && config.deep,
+        merger        = config && config.merger,
         key;
 
     // Use the given key to extract a value from the given object, then place
@@ -243,14 +245,18 @@
     // in a change from this object's value at that key, set anyChanges = true.
     function addToResult(currentObj, otherObj, key) {
       var immutableValue = Immutable(otherObj[key]);
+      var mergerResult = merger && merger(currentObj[key], immutableValue);
 
       anyChanges = anyChanges ||
+        mergerResult !== undefined ||
         (!currentObj.hasOwnProperty(key) ||
         ((immutableValue !== currentObj[key]) &&
           // Avoid false positives due to (NaN !== NaN) evaluating to true
           (immutableValue === immutableValue)));
 
-      if (deep && isObject(currentObj[key]) && isObject(immutableValue)) {
+      if (mergerResult) {
+        result[key] = mergerResult;
+      } else if (deep && isObject(currentObj[key]) && isObject(immutableValue)) {
         result[key] = currentObj[key].merge(immutableValue, config);
       } else {
         result[key] = immutableValue;


Test patch:
diff --git a/test/ImmutableObject/test-merge.js b/test/ImmutableObject/test-merge.js
index 38bced8..304763f 100644
--- a/test/ImmutableObject/test-merge.js
+++ b/test/ImmutableObject/test-merge.js
@@ -202,6 +202,29 @@ module.exports = function() {
       assert.deepEqual(actual, expected);
     });
 
+    function arrayMerger(thisValue, providedValue) {
+      if (thisValue instanceof Array && providedValue instanceof Array) {
+        return thisValue.concat(providedValue);
+      }
+    }
+
+    it("merges with a custom merger when the config tells it to", function() {
+      var expected = Immutable({all: "your base", are: {belong: "to us"}, you: ['have', 'no', 'chance', 'to', 'survive']});
+      var actual = Immutable({all: "your base", are: {belong: "to us"}, you: ['have', 'no']}).merge({you: ['chance', 'to', 'survive']}, {merger: arrayMerger});
+
+      assert.deepEqual(actual, expected);
+    });
+
+    it("merges deep with a custom merger when the config tells it to", function() {
+      var original = Immutable({id: 3, name: "three", valid: true, a: {id: 2}, b: [50], x: [1, 2], sub: {z: [100]}});
+      var toMerge = {id: 3, name: "three", valid: false, a: [1000], b: {id: 4}, x: [3, 4], sub: {y: [10, 11], z: [101, 102]}};
+
+      var expected = Immutable({id: 3, name: "three", valid: false, a: [1000], b: {id: 4}, x: [1, 2, 3, 4], sub: {z: [100, 101, 102], y: [10, 11]}});
+      var actual   = original.merge(toMerge, {deep: true, merger: arrayMerger});
+
+      assert.deepEqual(actual, expected);
+    });
+
     describe("when passed a single object", function() {
       generateMergeTestsFor([TestUtils.ComplexObjectSpecifier()]);
     });
@@ -210,6 +233,10 @@ module.exports = function() {
       generateMergeTestsFor([TestUtils.ComplexObjectSpecifier()], {deep: true});
     });
 
+    describe("when passed a single object with a custom merger", function() {
+      generateMergeTestsFor([TestUtils.ComplexObjectSpecifier()], {merger: arrayMerger()});
+    });
+
     describe("when passed an array of objects", function() {
       generateMergeTestsFor([generateArrayOfObjects]);
     });
@@ -217,5 +244,9 @@ module.exports = function() {
     describe("when passed an array of objects with deep set to true", function() {
       generateMergeTestsFor([generateArrayOfObjects], {deep: true});
     });
+
+    describe("when passed an array of objects with a custom merger", function() {
+      generateMergeTestsFor([generateArrayOfObjects], {merger: arrayMerger()});
+    });
   });
 };