TehShrike / deepmerge

A library for deep (recursive) merging of Javascript objects
MIT License
2.75k stars 216 forks source link

customMerge should probably be called when cloning mergeable values #176

Open seva-luchianov opened 4 years ago

seva-luchianov commented 4 years ago

Hello, back with a similar bug to my previous issue. I updated to 4.2.1 and the boolean merge was indeed fixed, but it is broken when applied alongside other object types. For example:

function booleanMerge(a, b) {
    return merge(a, b, {
        isMergeableObject: function(value) {
            console.log("value:", value);
            return typeof value === "boolean" || isMergeableObject(value);
        },
        customMerge: function(key) {
            console.log("key:", key);
            return function(a, b) {
                console.log("merge:", a, b);
                if (typeof a === "boolean" && typeof b === "boolean") {
                    return a || b;
                }
                return booleanMerge(a, b, options);
            };
        }
    });
}
console.log("result:", booleanMerge({
    list: [1, 2, 3],
    bool: true
}, {
    list: [2, 3, 4]
}));

will log:

value: {list: [1, 2, 3], bool: true}
value: [1, 2, 3]
value: 1
value: 2
value: 3
value: true
value: {}
value: [2, 3, 4]
key: list
merge: [1, 2, 3] [2, 3, 4]
value: 1
value: 2
value: 3
value: 2
value: 3
value: 4
result: { list: (6) [1, 2, 3, 2, 3, 4], bool: {} }

seems like the boolean keys aren't getting logged in the custom merge function. This is the base case, though it also doesn't work in tandem with arrayMerge, which is my current use-case:

function booleanMerge(a, b) {
    return merge(a, b, {
        isMergeableObject: function(value) {
            console.log("value:", value);
            return typeof value === "boolean" || isMergeableObject(value);
        },
        customMerge: function(key) {
            console.log("key:", key);
            return function(a, b) {
                console.log("merge:", a, b);
                if (typeof a === "boolean" && typeof b === "boolean") {
                    return a || b;
                }
                return booleanMerge(a, b, options);
            };
        },
        arrayMerge: function(target, source) {
            const destination = target.slice();
            source.forEach(function(e) {
                if (!target.includes(e)) {
                    destination.push(e);
                }
            });
            return destination;
        }
    });
}
console.log("result:", booleanMerge({
    list: [1, 2, 3],
    bool: true
}, {
    list: [2, 3, 4]
}));

will log:

value: {list: [1, 2, 3], bool: true}
value: [1, 2, 3]
value: true
value: {}
value: [2, 3, 4]
key: list
merge: [1, 2, 3] [2, 3, 4]
result: { list: (6) [1, 2, 3, 4], bool: {} }

Thanks for the quick response to that previous issue! Using deepmerge 4.2.1 and is-mergeable-object 1.1.1

TehShrike commented 4 years ago

This is expected behavior. The merge function is only called if isMergeableObject returns true for the source value and the property exists on the target.

https://github.com/TehShrike/deepmerge/blob/d1ae60fb01f5c6b49a0e6b483bb2fb35c0995467/index.js#L66

TehShrike commented 4 years ago

I just published 4.2.2 which makes it so that isMergeableObject is only called when there is a target value that could be merged: a34dd4d2

https://github.com/TehShrike/deepmerge/blob/e9c9fec24764837dd1ca178f86e8a5125cb93653/index.js#L66

I also flipped around the if/else code blocks to make the conditional a lot easier to read.

seva-luchianov commented 4 years ago

I see, thanks for clearing that up. If I was to get the behavior that I want, I assume that I should traverse the target object after the merge is finished and just directly copy over keys that do not exist on it? This feels like a nice feature to have in this library. Possibly a flag like mergeNewKeys that defaults to false.

TehShrike commented 4 years ago

I assume that I should traverse the target object after the merge is finished and just directly copy over keys that do not exist on it?

That's what deepmerge does now: https://github.com/TehShrike/deepmerge/blob/e9c9fec24764837dd1ca178f86e8a5125cb93653/index.js#L66-L70

seva-luchianov commented 4 years ago

I'm not sure I understand. In my example, the bool key of the target object was overwritten from true to {} when said key was not present in the object getting merged in. Why is this expected behavior?

TehShrike commented 4 years ago

Oh I'm sorry, I misread – you have the attribute on the target, but not on the source. So, in your case, it is actually going down the cloneUnlessOtherwiseSpecified flow: https://github.com/TehShrike/deepmerge/blob/e9c9fec24764837dd1ca178f86e8a5125cb93653/index.js#L7-L11

And since isMergeableObject is returning true, emptyTarget is being called: https://github.com/TehShrike/deepmerge/blob/e9c9fec24764837dd1ca178f86e8a5125cb93653/index.js#L3-L5

Maybe that should be

getMergeFunction(key, options)(emptyTarget(value), value, options)

instead of

deepmerge(emptyTarget(value), value, options)

?

seva-luchianov commented 4 years ago

Right, so it's overwriting the target boolean key with {} since it is not present on the source object. I do believe that is a bug

TehShrike commented 4 years ago

(Sorry for the early comment/accidental close there, I accidentally hit Cmd+Enter. I've edited that comment)

seva-luchianov commented 4 years ago

gotcha, i just reread that comment. I guess emptyTarget should be dependent on the type stored in the key? Maybe false for booleans, and "" for strings?

TehShrike commented 4 years ago

Out of the box, deepmerge only copies properties/elements of objects and arrays. And if an empty something isn't an array, it must be an object.

We could talk about making it possible to pass in a custom emptyTarget option. Or, maybe your case could be worked around if the custom merge function was used inside the cloneUnlessOtherwiseSpecified function.

seva-luchianov commented 4 years ago

Both seem like good options! Thanks for taking the time to consider this issue, I know I'm pushing the boundaries of what deepmerge is originally designed to offer.

TehShrike commented 4 years ago

Calling the customMerge function inside cloneUnlessOtherwiseSpecified would be the smallest stretch from the current functionality.

If you want to open a PR with a failing test, that would be appreciated.

seva-luchianov commented 4 years ago

Just made the PR