Skip to content

Commit

Permalink
Fix circular references in iterable equality
Browse files Browse the repository at this point in the history
  • Loading branch information
mattphillips committed Mar 20, 2019
1 parent ce65aac commit b70586f
Show file tree
Hide file tree
Showing 2 changed files with 131 additions and 13 deletions.
81 changes: 81 additions & 0 deletions packages/expect/src/__tests__/utils.test.js
Expand Up @@ -15,6 +15,7 @@ const {
getPath,
hasOwnProperty,
subsetEquality,
iterableEquality,
} = require('../utils');

describe('getPath()', () => {
Expand Down Expand Up @@ -202,3 +203,83 @@ describe('subsetEquality()', () => {
expect(subsetEquality(undefined, {foo: 'bar'})).not.toBeTruthy();
});
});

describe('iterableEquality', () => {
test('returns true when given circular iterators', () => {
class Iter {
*[Symbol.iterator]() {
yield this;
}
}

const a = new Iter();
const b = new Iter();

expect(iterableEquality(a, b)).toBe(true);
});

test('returns true when given circular Set', () => {
const a = new Set();
a.add(a);
const b = new Set();
b.add(b);
expect(iterableEquality(a, b)).toBe(true);
});

test('returns true when given nested Sets', () => {
expect(
iterableEquality(
new Set([new Set([[1]]), new Set([[2]])]),
new Set([new Set([[2]]), new Set([[1]])]),
),
).toBe(true);
expect(
iterableEquality(
new Set([new Set([[1]]), new Set([[2]])]),
new Set([new Set([[3]]), new Set([[1]])]),
),
).toBe(false);
});

test('returns true when given circular key in Map', () => {
const a = new Map();
a.set(a, 'a');
const b = new Map();
b.set(b, 'a');

expect(iterableEquality(a, b)).toBe(true);
});

test('returns true when given nested Maps', () => {
expect(
iterableEquality(
new Map([['hello', new Map([['world', 'foobar']])]]),
new Map([['hello', new Map([['world', 'qux']])]]),
),
).toBe(false);
expect(
iterableEquality(
new Map([['hello', new Map([['world', 'foobar']])]]),
new Map([['hello', new Map([['world', 'foobar']])]]),
),
).toBe(true);
});

test('returns true when given circular key and value in Map', () => {
const a = new Map();
a.set(a, a);
const b = new Map();
b.set(b, b);

expect(iterableEquality(a, b)).toBe(true);
});

test('returns true when given circular value in Map', () => {
const a = new Map();
a.set('a', a);
const b = new Map();
b.set('a', b);

expect(iterableEquality(a, b)).toBe(true);
});
});
63 changes: 50 additions & 13 deletions packages/expect/src/utils.ts
Expand Up @@ -137,7 +137,13 @@ const IteratorSymbol = Symbol.iterator;

const hasIterator = (object: any) =>
!!(object != null && object[IteratorSymbol]);
export const iterableEquality = (a: any, b: any) => {

export const iterableEquality = (
a: any,
b: any,
aStack: Array<any> = [],
bStack: Array<any> = [],
) => {
if (
typeof a !== 'object' ||
typeof b !== 'object' ||
Expand All @@ -152,6 +158,24 @@ export const iterableEquality = (a: any, b: any) => {
return false;
}

let length = aStack.length;
while (length--) {
// Linear search. Performance is inversely proportional to the number of
// unique nested structures.
// circular references at same depth are equal
// circular reference is not equal to non-circular one
if (aStack[length] === a) {
return bStack[length] === b;
} else if (bStack[length] === b) {
return false;
}
}
aStack.push(a);
bStack.push(b);

const iterableEqualityWithStack = (a: any, b: any) =>
iterableEquality(a, b, aStack, bStack);

if (a.size !== undefined) {
if (a.size !== b.size) {
return false;
Expand All @@ -161,7 +185,7 @@ export const iterableEquality = (a: any, b: any) => {
if (!b.has(aValue)) {
let has = false;
for (const bValue of b) {
const isEqual = equals(aValue, bValue, [iterableEquality]);
const isEqual = equals(aValue, bValue, [iterableEqualityWithStack]);
if (isEqual === true) {
has = true;
}
Expand All @@ -173,25 +197,30 @@ export const iterableEquality = (a: any, b: any) => {
}
}
}
if (allFound) {
return true;
}

aStack.pop();
bStack.pop();

return allFound;
} else if (isA('Map', a) || isImmutableUnorderedKeyed(a)) {
let allFound = true;
for (const aEntry of a) {
if (
!b.has(aEntry[0]) ||
!equals(aEntry[1], b.get(aEntry[0]), [iterableEquality])
!equals(aEntry[1], b.get(aEntry[0]), [iterableEqualityWithStack])
) {
let has = false;
for (const bEntry of b) {
const matchedKey = equals(aEntry[0], bEntry[0], [iterableEquality]);
const matchedKey = equals(aEntry[0], bEntry[0], [
iterableEqualityWithStack,
]);

let matchedValue = false;
if (matchedKey === true) {
matchedValue = equals(aEntry[1], bEntry[1], [iterableEquality]);
matchedValue = equals(aEntry[1], bEntry[1], [
iterableEqualityWithStack,
]);
}

if (matchedValue === true) {
has = true;
}
Expand All @@ -203,23 +232,31 @@ export const iterableEquality = (a: any, b: any) => {
}
}
}
if (allFound) {
return true;
}
aStack.pop();
bStack.pop();
return allFound;
}
}

const bIterator = b[IteratorSymbol]();

for (const aValue of a) {
const nextB = bIterator.next();
if (nextB.done || !equals(aValue, nextB.value, [iterableEquality])) {
if (
nextB.done ||
!equals(aValue, nextB.value, [
(a: any, b: any) => iterableEquality(a, b, aStack, bStack),
])
) {
return false;
}
}
if (!bIterator.next().done) {
return false;
}

aStack.pop();
bStack.pop();
return true;
};

Expand Down

0 comments on commit b70586f

Please sign in to comment.