Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent mutable structures when merging Immutable objects using mergeDeep #1840

Merged
merged 23 commits into from Sep 13, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 58 additions & 1 deletion __tests__/issues.ts
@@ -1,4 +1,13 @@
import { List, OrderedMap, OrderedSet, Record, Seq, Set } from 'immutable';
import {
fromJS,
List,
Map,
OrderedMap,
OrderedSet,
Record,
Seq,
Set,
} from 'immutable';

describe('Issue #1175', () => {
it('invalid hashCode() response should not infinitly recurse', () => {
Expand Down Expand Up @@ -121,3 +130,51 @@ describe('Issue #1785', () => {

expect(emptyRecord.merge({ id: 1 })).toBe(emptyRecord);
});

describe('Issue #1475', () => {
it('complex case should return first value on mergeDeep when types are incompatible', () => {
const a = fromJS({
ch: [
{
code: 8,
},
],
}) as Map<unknown, unknown>;
const b = fromJS({
ch: {
code: 8,
},
});
expect(a.mergeDeep(b).equals(b)).toBe(true);
});

it('simple case should return first value on mergeDeep when types are incompatible', () => {
const a = fromJS({
ch: [],
}) as Map<unknown, unknown>;
const b = fromJS({
ch: { code: 8 },
});
expect(a.merge(b).equals(b)).toBe(true);
});
});

describe('Issue #1719', () => {
it('mergeDeep() should overwrite when types conflict', () => {
const objWithObj = fromJS({
items: {
'1': {
id: '1',
},
},
}) as Map<unknown, unknown>;
const objWithArray = fromJS({
items: [
{
id: '1',
},
],
});
expect(objWithObj.mergeDeep(objWithArray).equals(objWithArray)).toBe(true);
});
});
93 changes: 28 additions & 65 deletions __tests__/merge.ts
Expand Up @@ -210,73 +210,36 @@ describe('merge', () => {
expect(merge(a, [], [])).toBe(a);
});

it('mergeDeep with tuple Symbol keys', () => {
const a = Symbol('a');
const b = Symbol('b');
const c = Symbol('c');
const d = Symbol('d');
const e = Symbol('e');
const f = Symbol('f');
const g = Symbol('g');

// Note the use of nested Map constructors, Map() does not do a deep conversion!
const m1 = Map([
[
a,
Map([
[
b,
Map([
[c, 1],
[d, 2],
]),
],
]),
],
]);

// mergeDeep can be directly given a nested set of `Iterable<[K, V]>`
const merged = m1.mergeDeep([
// @ts-ignore (Type definition limitation)
[
a,
[
[
b,
[
[c, 10],
[e, 20],
[f, 30],
[g, 40],
],
],
],
],
]);

expect(merged).toEqual(
Map([
[
a,
Map([
[
b,
Map([
[c, 10],
[d, 2],
[e, 20],
[f, 30],
[g, 40],
]),
],
]),
],
])
);
});

it('merges records with a size property set to 0', () => {
const Sizable = Record({ size: 0 });
expect(Sizable().merge({ size: 123 }).size).toBe(123);
});

it('mergeDeep merges partial conflicts', () => {
const a = fromJS({
ch: [
{
code: 8,
},
],
banana: 'good',
}) as Map<unknown, unknown>;
const b = fromJS({
ch: {
code: 8,
},
apple: 'anti-doctor',
});
expect(
a.mergeDeep(b).equals(
fromJS({
ch: {
code: 8,
},
apple: 'anti-doctor',
banana: 'good',
})
)
).toBe(true);
});
});
18 changes: 17 additions & 1 deletion src/functional/merge.js
@@ -1,5 +1,9 @@
import { isImmutable } from '../predicates/isImmutable';
import { isIndexed } from '../predicates/isIndexed';
import { isKeyed } from '../predicates/isKeyed';
import { isSet } from '../predicates/isSet';
import { IndexedCollection, KeyedCollection } from '../Collection';
import { Seq } from '../Seq';
import hasOwnProperty from '../utils/hasOwnProperty';
import isDataStructure from '../utils/isDataStructure';
import shallowCopy from '../utils/shallowCopy';
Expand Down Expand Up @@ -68,11 +72,23 @@ export function mergeWithSources(collection, sources, merger) {

function deepMergerWith(merger) {
function deepMerger(oldValue, newValue, key) {
return isDataStructure(oldValue) && isDataStructure(newValue)
return isDataStructure(oldValue) &&
isDataStructure(newValue) &&
areMergeable(oldValue, newValue)
? mergeWithSources(oldValue, [newValue], deepMerger)
: merger
? merger(oldValue, newValue, key)
: newValue;
}
return deepMerger;
}

function areMergeable(oldDataStructure, newDataStructure) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it would still be nice to include a comment block above this function explaining why this implementation determines things are considered mergeable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Let me know if that's what you were thinking or if you were asking for a different explanation.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this looks good to me. It's the connection between the collection categorization and this somewhat nuanced boolean logic that will be helpful when we look at this function again after our collective memory is lost

const oldSeq = Seq(oldDataStructure);
const newSeq = Seq(newDataStructure);
return (
isIndexed(oldSeq) === isIndexed(newSeq) &&
isKeyed(oldSeq) === isKeyed(newSeq) &&
isSet(oldSeq) === isSet(newSeq)
Copy link
Collaborator

@leebyron leebyron Sep 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may need some test coverage to cover this, but I believe isSet only checks if something is actually Set or OrderedSet and doesn't include SetSeq. We don't have a great first class function for this, something is set-like if it is both not indexed and not keyed.

Suggested change
isSet(oldSeq) === isSet(newSeq)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered about that, I should've looked into that more, makes sense.

);
}
13 changes: 8 additions & 5 deletions type-definitions/immutable.d.ts
Expand Up @@ -1015,8 +1015,10 @@ declare namespace Immutable {
): this;

/**
* Like `merge()`, but when two Collections conflict, it merges them as well,
* recursing deeply through the nested data.
* Like `merge()`, but when two Collections of the same type conflict, it
* merges them as well, recursing deeply through the nested data. If two
* Collections of different types conflict, it replaces the existing
* Collection with the new one.
*
* Note: Values provided to `merge` are shallowly converted before being
* merged. No nested values are altered unless they will also be merged at
Expand All @@ -1042,8 +1044,9 @@ declare namespace Immutable {
): this;

/**
* Like `mergeDeep()`, but when two non-Collections conflict, it uses the
* `merger` function to determine the resulting value.
* Like `mergeDeep()`, but when two non-Collections or two Collections of
* different types conflict, it uses the `merger` function to determine the
* resulting value.
*
* <!-- runkit:activate -->
* ```js
Expand Down Expand Up @@ -5560,7 +5563,7 @@ declare namespace Immutable {
/**
* Returns a copy of the collection with the remaining collections merged in
* deeply (recursively), calling the `merger` function whenever an existing
* value is encountered.
* value or incompatible data structures are encountered.
*
* A functional alternative to `collection.mergeDeepWith()` which will also
* work with plain Objects and Arrays.
Expand Down