From 3462a8425c069d48251d18280f738f022007fc37 Mon Sep 17 00:00:00 2001 From: Sojin Park Date: Tue, 1 Oct 2024 22:30:50 +0900 Subject: [PATCH] feat(isEqualWith): Implement isEqualWith --- benchmarks/performance/isEqual.bench.ts | 100 +++--- docs/ja/reference/function/flow.md | 6 +- docs/ja/reference/predicate/isEqualWith.md | 57 +++ docs/ko/reference/function/flow.md | 6 +- docs/ko/reference/predicate/isEqual.md | 8 +- docs/ko/reference/predicate/isEqualWith.md | 58 +++ docs/reference/predicate/isEqualWith.md | 57 +++ .../reference/predicate/isEqualWith.md | 60 ++++ src/compat/_internal/stubA.ts | 3 + src/compat/_internal/stubB.ts | 3 + src/compat/_internal/stubC.ts | 3 + src/compat/predicate/isEqualWith.spec.ts | 144 ++++++++ src/compat/predicate/isEqualWith.ts | 81 +++++ src/object/merge.spec.ts | 4 +- src/predicate/index.ts | 1 + src/predicate/isEqual.ts | 251 +------------ src/predicate/isEqualWith.spec.ts | 58 +++ src/predicate/isEqualWith.ts | 335 ++++++++++++++++++ 18 files changed, 925 insertions(+), 310 deletions(-) create mode 100644 docs/ja/reference/predicate/isEqualWith.md create mode 100644 docs/ko/reference/predicate/isEqualWith.md create mode 100644 docs/reference/predicate/isEqualWith.md create mode 100644 docs/zh_hans/reference/predicate/isEqualWith.md create mode 100644 src/compat/_internal/stubA.ts create mode 100644 src/compat/_internal/stubB.ts create mode 100644 src/compat/_internal/stubC.ts create mode 100644 src/compat/predicate/isEqualWith.spec.ts create mode 100644 src/compat/predicate/isEqualWith.ts create mode 100644 src/predicate/isEqualWith.spec.ts create mode 100644 src/predicate/isEqualWith.ts diff --git a/benchmarks/performance/isEqual.bench.ts b/benchmarks/performance/isEqual.bench.ts index 540be52c..61d6fda1 100644 --- a/benchmarks/performance/isEqual.bench.ts +++ b/benchmarks/performance/isEqual.bench.ts @@ -5,55 +5,55 @@ import { isEqual as isEqualLodash_ } from 'lodash'; const isEqualToolkit = isEqualToolkit_; const isEqualLodash = isEqualLodash_; -// describe('isEqual primitives', () => { -// bench('es-toolkit/isEqual', () => { -// isEqualToolkit(1, 1); -// isEqualToolkit(NaN, NaN); -// isEqualToolkit(+0, -0); +describe('isEqual primitives', () => { + bench('es-toolkit/isEqual', () => { + isEqualToolkit(1, 1); + isEqualToolkit(NaN, NaN); + isEqualToolkit(+0, -0); -// isEqualToolkit(true, true); -// isEqualToolkit(true, false); + isEqualToolkit(true, true); + isEqualToolkit(true, false); -// isEqualToolkit('hello', 'hello'); -// isEqualToolkit('hello', 'world'); -// }); + isEqualToolkit('hello', 'hello'); + isEqualToolkit('hello', 'world'); + }); -// bench('lodash/isEqual', () => { -// isEqualLodash(1, 1); -// isEqualLodash(NaN, NaN); -// isEqualLodash(+0, -0); + bench('lodash/isEqual', () => { + isEqualLodash(1, 1); + isEqualLodash(NaN, NaN); + isEqualLodash(+0, -0); -// isEqualLodash(true, true); -// isEqualLodash(true, false); + isEqualLodash(true, true); + isEqualLodash(true, false); -// isEqualLodash('hello', 'hello'); -// isEqualLodash('hello', 'world'); -// }); -// }); + isEqualLodash('hello', 'hello'); + isEqualLodash('hello', 'world'); + }); +}); -// describe('isEqual dates', () => { -// bench('es-toolkit/isEqual', () => { -// isEqualToolkit(new Date('2020-01-01'), new Date('2020-01-01')); -// isEqualToolkit(new Date('2020-01-01'), new Date('2021-01-01')); -// }); +describe('isEqual dates', () => { + bench('es-toolkit/isEqual', () => { + isEqualToolkit(new Date('2020-01-01'), new Date('2020-01-01')); + isEqualToolkit(new Date('2020-01-01'), new Date('2021-01-01')); + }); -// bench('lodash', () => { -// isEqualLodash(new Date('2020-01-01'), new Date('2020-01-01')); -// isEqualLodash(new Date('2020-01-01'), new Date('2021-01-01')); -// }); -// }); + bench('lodash', () => { + isEqualLodash(new Date('2020-01-01'), new Date('2020-01-01')); + isEqualLodash(new Date('2020-01-01'), new Date('2021-01-01')); + }); +}); -// describe('isEqual RegExps', () => { -// bench('es-toolkit/isEqual', () => { -// isEqualToolkit(/hello/g, /hello/g); -// isEqualToolkit(/hello/g, /hello/i); -// }); +describe('isEqual RegExps', () => { + bench('es-toolkit/isEqual', () => { + isEqualToolkit(/hello/g, /hello/g); + isEqualToolkit(/hello/g, /hello/i); + }); -// bench('lodash', () => { -// isEqualLodash(/hello/g, /hello/g); -// isEqualLodash(/hello/g, /hello/i); -// }) -// }) + bench('lodash', () => { + isEqualLodash(/hello/g, /hello/g); + isEqualLodash(/hello/g, /hello/i); + }); +}); describe('isEqual objects', () => { bench('es-toolkit/isEqual', () => { @@ -69,14 +69,14 @@ describe('isEqual objects', () => { }); }); -// describe('isEqual arrays', () => { -// bench('es-toolkit/isEqual', () => { -// isEqualToolkit([1, 2, 3], [1, 2, 3]); -// isEqualToolkit([1, 2, 3], [1, 2, 4]); -// }); +describe('isEqual arrays', () => { + bench('es-toolkit/isEqual', () => { + isEqualToolkit([1, 2, 3], [1, 2, 3]); + isEqualToolkit([1, 2, 3], [1, 2, 4]); + }); -// bench('lodash', () => { -// isEqualLodash([1, 2, 3], [1, 2, 3]); -// isEqualLodash([1, 2, 3], [1, 2, 4]); -// }); -// }) + bench('lodash', () => { + isEqualLodash([1, 2, 3], [1, 2, 3]); + isEqualLodash([1, 2, 3], [1, 2, 4]); + }); +}); diff --git a/docs/ja/reference/function/flow.md b/docs/ja/reference/function/flow.md index 31fa3e38..36ff5425 100644 --- a/docs/ja/reference/function/flow.md +++ b/docs/ja/reference/function/flow.md @@ -49,11 +49,11 @@ const combined = flow(add, square); console.log(combined(1, 2)); // 9 ``` -## Lodash 互換性 +## Lodash 互換性 `es-toolkit/compat` から `flow` をインポートすると、Lodash と互換になります。 -- `flow` は関数の配列と個別の関数の両方を引数として受け入れます。 +- `flow` は関数の配列と個別の関数の両方を引数として受け入れます。 - 提供された関数が関数でない場合、`flow` はエラーをスローします。 ```typescript @@ -65,4 +65,4 @@ const double = (n: number) => n * 2; const combined = flow([add, square], double); console.log(combined(1, 2)); // => 18 -``` \ No newline at end of file +``` diff --git a/docs/ja/reference/predicate/isEqualWith.md b/docs/ja/reference/predicate/isEqualWith.md new file mode 100644 index 00000000..13d9abef --- /dev/null +++ b/docs/ja/reference/predicate/isEqualWith.md @@ -0,0 +1,57 @@ +# isEqualWith + +二つの値が与えられた比較関数を使って等しいかどうかを比較します。 + +比較関数を提供することで、二つの値が等しいかどうかを検証する方法を細かく調整できます。 +与えられた比較関数が `true` を返すと、二つの値は等しいと見なされます。 `false` を返すと、二つの値は異なると見なされます。 +`undefined` を返すと、[isEqual](./isEqual.md) が提供するデフォルトの方法で二つの値を比較します。 + +オブジェクト、配列、`Map`、`Set` のように複数の要素を持つ場合でも、与えられた比較関数を使って要素間の値を比較します。 + +基本的な比較方法の上に、複雑な比較を処理するための方法を定義できるため、柔軟に二つの値を比較できます。 + +## インターフェース + +```typescript +function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean; +``` + +### パラメータ + +- `a` (`unknown`): 比較する最初の値。 +- `b` (`unknown`): 比較する2番目の値。 +- `areValuesEqual` (`(x: any, y: any, property?: PropertyKey, xParent?: any, yParent?: any, stack?: Map) => boolean | void`): 2つの値を比較する方法を示す比較関数。2つの値が等しいかどうかを示すブール値を返すことができます。`undefined`を返すと、デフォルトの方法で2つの値を比較します。 + - `x`: 最初のオブジェクト `a` に属する値。 + - `y`: 2番目のオブジェクト `b` に属する値。 + - `property`: `x` と `y` を取得するために使用されたプロパティキー。 + - `xParent`: 最初の値 `x` の親。 + - `yParent`: 2番目の値 `y` の親。 + - `stack`: 循環参照を処理するための内部スタック(`Map`)。 + +### 戻り値 + +(`boolean`): 値がカスタマイザーに従って等しい場合は `true`、それ以外の場合は `false`。 + +## 例 + +```typescript +const customizer = (a, b) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.toLowerCase() === b.toLowerCase(); + } +}; +isEqualWith('Hello', 'hello', customizer); // true +isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true +isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true +``` diff --git a/docs/ko/reference/function/flow.md b/docs/ko/reference/function/flow.md index 986666f7..d364aa2c 100644 --- a/docs/ko/reference/function/flow.md +++ b/docs/ko/reference/function/flow.md @@ -49,11 +49,11 @@ const combined = flow(add, square); console.log(combined(1, 2)); // 9 ``` -## Lodash와 호환성 +## Lodash와 호환성 `es-toolkit/compat`에서 `flow`를 가져오면 lodash와 완전히 호환돼요. -- `flow`는 파라미터로 개별 함수뿐만 아니라 함수들의 배열도 받을 수 있어요. +- `flow`는 파라미터로 개별 함수뿐만 아니라 함수들의 배열도 받을 수 있어요. - 파라미터로 함수가 아닌 값이 주어지면 `flow`는 오류를 발생시켜요. ```typescript @@ -65,4 +65,4 @@ const double = (n: number) => n * 2; const combined = flow([add, square], double); console.log(combined(1, 2)); // => 18 -``` \ No newline at end of file +``` diff --git a/docs/ko/reference/predicate/isEqual.md b/docs/ko/reference/predicate/isEqual.md index d3db728f..c5cf13da 100644 --- a/docs/ko/reference/predicate/isEqual.md +++ b/docs/ko/reference/predicate/isEqual.md @@ -1,6 +1,6 @@ # isEqual -`isEqual` 함수는 두 값이 동일한지 확인하며, `Date`, `RegExp`, 깊은 객체 비교도 지원해요. +두 값이 동일한지 확인해요. `Date`, `RegExp` 같은 깊은 객체 비교도 지원해요. ## 인터페이스 @@ -10,12 +10,12 @@ function isEqual(a: unknown, b: unknown): boolean; ## 파라미터 -- **`a`**: `unknown` - 비교할 첫 번째 값. -- **`b`**: `unknown` - 비교할 두 번째 값. +- `a` (`unknown`): 비교할 첫 번째 값. +- `b` (`unknown`): 비교할 두 번째 값. ## 반환 값 -- **`boolean`** - 두 값이 동일하면 `true`, 그렇지 않으면 `false`를 반환해요. +(`boolean`): 두 값이 동일하면 `true`, 그렇지 않으면 `false`를 반환해요. ## 예시 diff --git a/docs/ko/reference/predicate/isEqualWith.md b/docs/ko/reference/predicate/isEqualWith.md new file mode 100644 index 00000000..465d18c6 --- /dev/null +++ b/docs/ko/reference/predicate/isEqualWith.md @@ -0,0 +1,58 @@ +# isEqualWith + +두 값이 동일한지를 주어진 비교 함수를 이용해서 비교해요. + +비교 함수를 제공함으로써 두 값이 동일한지를 검증하는 방법을 세세하게 조정할 수 있어요. +주어진 비교 함수가 `true`를 반환하면, 두 값은 동일하게 취급돼요. `false`를 반환하면, 두 값은 다르게 취급돼요. +`undefined`를 반환하면, [isEqual](./isEqual.md)이 제공하는 기본 방법으로 두 값을 비교해요. + +객체, 배열, `Map`, `Set`처럼 여러 요소를 가지고 있을 때도, 주어진 비교 함수로 요소 사이의 값들을 비교해요. + +기본적인 비교 방법 위에서, 복잡한 비교를 처리하기 위한 방법을 정의할 수 있어서, 유연하게 두 값을 비교할 수 있어요. + +## 인터페이스 + +```typescript +function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean; +``` + +### 파라미터 + +- `a` (`unknown`): 비교할 첫 번째 값. +- `b` (`unknown`): 비교할 두 번째 값. +- `areValuesEqual` (`(x: any, y: any, property?: PropertyKey, xParent?: any, yParent?: any, stack?: Map) => boolean | void`): 두 값을 비교하는 방법을 나타내는 비교 함수. 두 값이 같은지를 나타내는 불리언 값을 반환할 수 있어요. `undefined`를 반환하면, 기본 방법으로 두 값을 비교해요. + - `x`: 첫 번째 객체 `a`에 속한 값. + - `y`: 두 번째 객체 `b`에 속한 값. + - `property`: `x`와 `y`를 가져오기 위해 사용한 프로퍼티 키. + - `xParent`: 첫 번째 값 `x`의 부모. + - `yParent`: 두 번째 값 `y`의 부모. + - `stack`: 순환 참조를 처리하기 위한 내부 스택(`Map`). + +### 반환 값 + +(`boolean`): 값이 사용자 지정 기준에 따라 동등하면 `true`를 반환하고, 그렇지 않으면 `false`를 반환합니다. +문자열. + +## 예시 + +```typescript +const customizer = (a, b) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.toLowerCase() === b.toLowerCase(); + } +}; +isEqualWith('Hello', 'hello', customizer); // true +isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true +isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true +``` diff --git a/docs/reference/predicate/isEqualWith.md b/docs/reference/predicate/isEqualWith.md new file mode 100644 index 00000000..c0701552 --- /dev/null +++ b/docs/reference/predicate/isEqualWith.md @@ -0,0 +1,57 @@ +# isEqualWith + +Compares two values for equality using a custom comparison function. + +The custom function allows for fine-tuned control over the comparison process. If it returns a boolean, that result determines the equality. If it returns undefined, the function falls back to the default equality comparison in [isEqual](./isEqual.md). + +This function also uses the custom equality function to compare values inside objects, +arrays, `Map`s, `Set`s, and other complex structures, ensuring a deep comparison. + +This approach provides flexibility in handling complex comparisons while maintaining efficient default behavior for simpler cases. + +## Signature + +```typescript +function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean; +``` + +### Parameters + +- `a` (`unknown`): The first value to compare. +- `b` (`unknown`): The second value to compare. +- `areValuesEqual` (`(x: any, y: any, property?: PropertyKey, xParent?: any, yParent?: any, stack?: Map) => boolean | void`): A function to customize the comparison. If it returns a boolean, that result will be used. If it returns undefined, + the default equality comparison will be used. + - `x`: The value from the first object `a`. + - `y`: The value from the second object `b`. + - `property`: The property key used to get `x` and `y`. + - `xParent`: The parent of the first value `x`. + - `yParent`: The parent of the second value `y`. + - `stack`: An internal stack (Map) to handle circular references. + +### Returns + +(`boolean`): `true` if the values are equal according to the customizer, otherwise `false`. + +## Examples + +```typescript +const customizer = (a, b) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.toLowerCase() === b.toLowerCase(); + } +}; +isEqualWith('Hello', 'hello', customizer); // true +isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true +isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true +``` diff --git a/docs/zh_hans/reference/predicate/isEqualWith.md b/docs/zh_hans/reference/predicate/isEqualWith.md new file mode 100644 index 00000000..5f708545 --- /dev/null +++ b/docs/zh_hans/reference/predicate/isEqualWith.md @@ -0,0 +1,60 @@ +# isEqualWith + +使用自定义比较函数比较两个值是否相等。 + +自定义函数允许对比较过程进行细致的控制。如果它返回布尔值,则该结果决定相等性。如果返回 `undefined`,则该函数将回退到[isEqual](./isEqual.md)中的默认相等比较。 + +此函数还使用自定义相等函数比较对象、数组、`Map`、`Set`和其他复杂结构中的值,确保进行深度比较。 + +这种方法在处理复杂比较时提供了灵活性,同时保持了对简单情况的高效默认行为。 + +自定义比较函数最多可以接受六个参数: + +- `x`: 第一个对象`a`中的值。 +- `y`: 第二个对象`b`中的值。 +- `property`: 属性键(如果适用)。 +- `xParent`: 第一个值的父对象。 +- `yParent`: 第二个值的父对象。 +- `stack`: 用于处理循环引用的内部堆栈(映射)。 + +## 签名 + +```typescript +function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean; +``` + +### 参数 + +- `a` (`unknown`): 要比较的第一个值。 +- `b` (`unknown`): 要比较的第二个值。 +- `areValuesEqual` (`Function`): 自定义比较函数。 + 如果它返回布尔值,该结果将被使用。如果它返回undefined, + 将使用默认的相等比较。 + +### 返回值 + +(`boolean`): `true`如果根据自定义比较器值相等,否则为`false`。 + +## 示例 + +```typescript +const customizer = (a, b) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.toLowerCase() === b.toLowerCase(); + } +}; +isEqualWith('Hello', 'hello', customizer); // true +isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true +isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true +``` diff --git a/src/compat/_internal/stubA.ts b/src/compat/_internal/stubA.ts new file mode 100644 index 00000000..c2fbe704 --- /dev/null +++ b/src/compat/_internal/stubA.ts @@ -0,0 +1,3 @@ +export function stubA() { + return 'A'; +} diff --git a/src/compat/_internal/stubB.ts b/src/compat/_internal/stubB.ts new file mode 100644 index 00000000..7617f78e --- /dev/null +++ b/src/compat/_internal/stubB.ts @@ -0,0 +1,3 @@ +export function stubB() { + return 'B'; +} diff --git a/src/compat/_internal/stubC.ts b/src/compat/_internal/stubC.ts new file mode 100644 index 00000000..bde0d007 --- /dev/null +++ b/src/compat/_internal/stubC.ts @@ -0,0 +1,3 @@ +export function stubC() { + return 'C'; +} diff --git a/src/compat/predicate/isEqualWith.spec.ts b/src/compat/predicate/isEqualWith.spec.ts new file mode 100644 index 00000000..c7c40401 --- /dev/null +++ b/src/compat/predicate/isEqualWith.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { isEqualWith } from './isEqualWith'; +import { isString } from './isString'; +import { without } from '../../array/without'; +import { noop } from '../../function/noop'; +import { partial } from '../../function/partial'; +import { falsey } from '../_internal/falsey'; +import { slice } from '../_internal/slice'; +import { stubC } from '../_internal/stubC'; +import { stubFalse } from '../_internal/stubFalse'; + +describe('isEqualWith', () => { + it('should provide correct `customizer` arguments', () => { + const argsList: any = []; + const object1: any = { a: [1, 2], b: null }; + const object2: any = { a: [1, 2], b: null }; + + object1.b = object2; + object2.b = object1; + + const expected = [ + [object1, object2, undefined, undefined, undefined], + [object1.a, object2.a, 'a', object1, object2], + [object1.a[0], object2.a[0], 0, object1.a, object2.a], + [object1.a[1], object2.a[1], 1, object1.a, object2.a], + [object1.b, object2.b, 'b', object1.b, object2.b], + ]; + + isEqualWith(object1, object2, function () { + const length = arguments.length; + const args = slice.call(arguments, 0, length - (length > 2 ? 1 : 0)); + + console.log(Array.from(arguments)); + + argsList.push(args); + }); + + console.log(argsList); + + expect(argsList).toEqual(expected); + }); + + it('should handle comparisons when `customizer` returns `undefined`', () => { + expect(isEqualWith('a', 'a', noop)).toBe(true); + expect(isEqualWith(['a'], ['a'], noop)).toBe(true); + expect(isEqualWith({ 0: 'a' }, { 0: 'a' }, noop)).toBe(true); + }); + + it('should not handle comparisons when `customizer` returns `true`', () => { + const customizer = function (value: any) { + return isString(value) || undefined; + }; + + expect(isEqualWith('a', 'b', customizer)).toBe(true); + expect(isEqualWith(['a'], ['b'], customizer)).toBe(true); + expect(isEqualWith({ 0: 'a' }, { 0: 'b' }, customizer)).toBe(true); + }); + + it('should not handle comparisons when `customizer` returns `false`', () => { + const customizer = function (value: any) { + return isString(value) ? false : undefined; + }; + + expect(isEqualWith('a', 'a', customizer)).toBe(false); + expect(isEqualWith(['a'], ['a'], customizer)).toBe(false); + expect(isEqualWith({ 0: 'a' }, { 0: 'a' }, customizer)).toBe(false); + }); + + it('should return a boolean value even when `customizer` does not', () => { + // eslint-disable-next-line + // @ts-ignore + let actual: any = isEqualWith('a', 'b', stubC); + expect(actual).toBe(true); + + const values = without(falsey, undefined); + const expected = values.map(stubFalse); + + actual = []; + values.forEach(value => { + // eslint-disable-next-line + // @ts-ignore + actual.push(isEqualWith('a', 'a', () => value)); + }); + + expect(actual).toEqual(expected); + }); + + it('should ensure `customizer` is a function', () => { + const array = [1, 2, 3]; + // eslint-disable-next-line + // @ts-ignore + const eq = (...args: any[]) => isEqualWith(array, ...args); + // eslint-disable-next-line + // @ts-ignore + const actual = [array, [1, 0, 3]].map(eq); + + expect(actual).toEqual([true, false]); + }); + + it('should call `customizer` for values maps and sets', () => { + const value = { a: { b: 2 } }; + + var map1 = new Map(); + map1.set('a', value); + + var map2 = new Map(); + map2.set('a', value); + + var set1 = new Set(); + set1.add(value); + + var set2 = new Set(); + set2.add(value); + + [ + [map1, map2], + [set1, set2], + ].forEach((pair, index) => { + if (pair[0]) { + const argsList: any = []; + const array: any[] = Array.from(pair[0]); + + const expected: any = [ + [pair[0], pair[1], undefined, undefined, undefined], + [array[0], array[0], 0, array, array], + [array[0][0], array[0][0], 0, array[0], array[0]], + [array[0][1], array[0][1], 1, array[0], array[0]], + ]; + + if (index) { + expected.length = 2; + } + isEqualWith(pair[0], pair[1], function () { + const length = arguments.length; + const args = slice.call(arguments, 0, length - (length > 2 ? 1 : 0)); + + argsList.push(args); + }); + + expect(argsList).toEqual(expected); + } + }); + }); +}); diff --git a/src/compat/predicate/isEqualWith.ts b/src/compat/predicate/isEqualWith.ts new file mode 100644 index 00000000..34633dce --- /dev/null +++ b/src/compat/predicate/isEqualWith.ts @@ -0,0 +1,81 @@ +import { after } from '../../function/after.ts'; +import { noop } from '../../function/noop.ts'; +import { isEqualWith as isEqualWithToolkit } from '../../predicate/isEqualWith.ts'; + +/** + * Compares two values for equality using a custom comparison function. + * + * The custom function allows for fine-tuned control over the comparison process. If it returns a boolean, that result determines the equality. If it returns undefined, the function falls back to the default equality comparison. + * + * This function also uses the custom equality function to compare values inside objects, + * arrays, maps, sets, and other complex structures, ensuring a deep comparison. + * + * This approach provides flexibility in handling complex comparisons while maintaining efficient default behavior for simpler cases. + * + * The custom comparison function can take up to six parameters: + * - `x`: The value from the first object `a`. + * - `y`: The value from the second object `b`. + * - `property`: The property key used to get `x` and `y`. + * - `xParent`: The parent of the first value `x`. + * - `yParent`: The parent of the second value `y`. + * - `stack`: An internal stack (Map) to handle circular references. + * + * @param {unknown} a - The first value to compare. + * @param {unknown} b - The second value to compare. + * @param {(x: any, y: any, property?: PropertyKey, xParent?: any, yParent?: any, stack?: Map) => boolean | void} [areValuesEqual=noop] - A function to customize the comparison. + * If it returns a boolean, that result will be used. If it returns undefined, + * the default equality comparison will be used. + * @returns {boolean} `true` if the values are equal according to the customizer, otherwise `false`. + * + * @example + * const customizer = (a, b) => { + * if (typeof a === 'string' && typeof b === 'string') { + * return a.toLowerCase() === b.toLowerCase(); + * } + * }; + * isEqualWith('Hello', 'hello', customizer); // true + * isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true + * isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true + */ +export function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + a: any, + b: any, + property?: PropertyKey, + aParent?: any, + bParent?: any, + stack?: Map + ) => boolean | void = noop +) { + if (typeof areValuesEqual !== 'function') { + areValuesEqual = noop; + } + + return isEqualWithToolkit(a, b, (...args): boolean | void => { + const result = areValuesEqual(...args); + + if (result !== undefined) { + return !!result; + } + + if (a instanceof Map && b instanceof Map) { + return isEqualWith( + Array.from(a), + Array.from(b), + // areValuesEqual should not be called for converted values + after(2, areValuesEqual) + ); + } + + if (a instanceof Set && b instanceof Set) { + return isEqualWith( + Array.from(a), + Array.from(b), + // areValuesEqual should not be called for converted values + after(2, areValuesEqual) + ); + } + }); +} diff --git a/src/object/merge.spec.ts b/src/object/merge.spec.ts index 1fbe994f..795ad8e5 100644 --- a/src/object/merge.spec.ts +++ b/src/object/merge.spec.ts @@ -61,7 +61,7 @@ describe('merge', () => { it('should handle merging arrays into non-array target values', () => { const numbers = [1, 2, 3]; - + const target = { a: 1, b: {} }; const source = { b: numbers, c: 4 }; const result = merge(target, source); @@ -72,7 +72,7 @@ describe('merge', () => { it('should create new plain object when merged', () => { const plainObject = { b: 2 } as const; - + const target = {}; const source = { a: plainObject }; const result = merge(target, source); diff --git a/src/predicate/index.ts b/src/predicate/index.ts index 323b357e..bddd892a 100644 --- a/src/predicate/index.ts +++ b/src/predicate/index.ts @@ -1,6 +1,7 @@ export { isArrayBuffer } from './isArrayBuffer.ts'; export { isDate } from './isDate.ts'; export { isEqual } from './isEqual.ts'; +export { isEqualWith } from './isEqualWith.ts'; export { isError } from './isError.ts'; export { isMap } from './isMap.ts'; export { isNil } from './isNil.ts'; diff --git a/src/predicate/isEqual.ts b/src/predicate/isEqual.ts index 977a0769..a2727b98 100644 --- a/src/predicate/isEqual.ts +++ b/src/predicate/isEqual.ts @@ -1,40 +1,5 @@ -import { isPlainObject } from './isPlainObject.ts'; -import { getSymbols } from '../compat/_internal/getSymbols.ts'; -import { getTag } from '../compat/_internal/getTag.ts'; -import { - argumentsTag, - arrayBufferTag, - arrayTag, - bigInt64ArrayTag, - bigUint64ArrayTag, - booleanTag, - dataViewTag, - dateTag, - errorTag, - float32ArrayTag, - float64ArrayTag, - functionTag, - int8ArrayTag, - int16ArrayTag, - int32ArrayTag, - mapTag, - numberTag, - objectTag, - regexpTag, - setTag, - stringTag, - symbolTag, - uint8ArrayTag, - uint8ClampedArrayTag, - uint16ArrayTag, - uint32ArrayTag, -} from '../compat/_internal/tags.ts'; - -declare let Buffer: - | { - isBuffer: (a: any) => boolean; - } - | undefined; +import { isEqualWith } from './isEqualWith.ts'; +import { noop } from '../function/noop.ts'; /** * Checks if two values are equal, including support for `Date`, `RegExp`, and deep object comparison. @@ -51,215 +16,5 @@ declare let Buffer: * isEqual([1, 2, 3], [1, 2, 3]); // true */ export function isEqual(a: any, b: any): boolean { - if (typeof a === typeof b) { - switch (typeof a) { - case 'bigint': - case 'string': - case 'boolean': - case 'symbol': - case 'undefined': { - return a === b; - } - case 'number': { - return a === b || Object.is(a, b); - } - case 'function': { - return a === b; - } - case 'object': { - return areObjectsEqual(a, b); - } - } - } - - return areObjectsEqual(a, b); -} - -function areObjectsEqual(a: any, b: any, stack?: Map) { - if (Object.is(a, b)) { - return true; - } - - let aTag = getTag(a); - let bTag = getTag(b); - - if (aTag === argumentsTag) { - aTag = objectTag; - } - - if (bTag === argumentsTag) { - bTag = objectTag; - } - - if (aTag !== bTag) { - return false; - } - - switch (aTag) { - case stringTag: - return a.toString() === b.toString(); - - case numberTag: { - const x = a.valueOf(); - const y = b.valueOf(); - - return x === y || (Number.isNaN(x) && Number.isNaN(y)); - } - - case booleanTag: - case dateTag: - case symbolTag: - return Object.is(a.valueOf(), b.valueOf()); - - case regexpTag: { - return a.source === b.source && a.flags === b.flags; - } - - case functionTag: { - return a === b; - } - } - - stack = stack ?? new Map(); - - const aStack = stack.get(a); - const bStack = stack.get(b); - - if (aStack != null && bStack != null) { - return aStack === b; - } - - stack.set(a, b); - stack.set(b, a); - - try { - switch (aTag) { - case mapTag: { - if (a.size !== b.size) { - return false; - } - - for (const [key, value] of a.entries()) { - if (!b.has(key) || !areObjectsEqual(value, b.get(key), stack)) { - return false; - } - } - - return true; - } - - case setTag: { - if (a.size !== b.size) { - return false; - } - - const aValues = Array.from(a.values()); - const bValues = Array.from(b.values()); - - for (let i = 0; i < aValues.length; i++) { - const aValue = aValues[i]; - const index = bValues.findIndex(bValue => { - return areObjectsEqual(aValue, bValue, stack); - }); - - if (index === -1) { - return false; - } - - bValues.splice(index, 1); - } - - return true; - } - - case arrayTag: - case uint8ArrayTag: - case uint8ClampedArrayTag: - case uint16ArrayTag: - case uint32ArrayTag: - case bigUint64ArrayTag: - case int8ArrayTag: - case int16ArrayTag: - case int32ArrayTag: - case bigInt64ArrayTag: - case float32ArrayTag: - case float64ArrayTag: { - // Buffers are also treated as [object Uint8Array]s. - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(a) !== Buffer.isBuffer(b)) { - return false; - } - - if (a.length !== b.length) { - return false; - } - - for (let i = 0; i < a.length; i++) { - if (!areObjectsEqual(a[i], b[i], stack)) { - return false; - } - } - - return true; - } - - case arrayBufferTag: { - if (a.byteLength !== b.byteLength) { - return false; - } - - return areObjectsEqual(new Uint8Array(a), new Uint8Array(b), stack); - } - - case dataViewTag: { - if (a.byteLength !== b.byteLength || a.byteOffset !== b.byteOffset) { - return false; - } - - return areObjectsEqual(a.buffer, b.buffer, stack); - } - - case errorTag: { - return a.name === b.name && a.message === b.message; - } - - case objectTag: { - const areEqualInstances = - areObjectsEqual(a.constructor, b.constructor, stack) || (isPlainObject(a) && isPlainObject(b)); - - if (!areEqualInstances) { - return false; - } - - const aKeys = [...Object.keys(a), ...getSymbols(a)]; - const bKeys = [...Object.keys(b), ...getSymbols(b)]; - - if (aKeys.length !== bKeys.length) { - return false; - } - - for (let i = 0; i < aKeys.length; i++) { - const propKey = aKeys[i]; - const aProp = (a as any)[propKey]; - - if (!Object.hasOwn(b, propKey)) { - return false; - } - - const bProp = (b as any)[propKey]; - - if (!areObjectsEqual(aProp, bProp, stack)) { - return false; - } - } - - return true; - } - default: { - return false; - } - } - } finally { - stack.delete(a); - stack.delete(b); - } + return isEqualWith(a, b, noop); } diff --git a/src/predicate/isEqualWith.spec.ts b/src/predicate/isEqualWith.spec.ts new file mode 100644 index 00000000..e7d2cb02 --- /dev/null +++ b/src/predicate/isEqualWith.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { isEqualWith } from './isEqualWith'; + +describe('isEqualWith', () => { + it('should use the customizer function for string comparison', () => { + const customizer = (a: any, b: any) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.toLowerCase() === b.toLowerCase(); + } + }; + + expect(isEqualWith('Hello', 'hello', customizer)).toBe(true); + expect(isEqualWith('Hello', 'world', customizer)).toBe(false); + }); + + it('should use the customizer function for number comparison', () => { + const customizer = (a: any, b: any) => { + if (typeof a === 'number' && typeof b === 'number') { + return Math.abs(a - b) < 0.01; + } + }; + + expect(isEqualWith(1.001, 1.002, customizer)).toBe(true); + expect(isEqualWith(1.001, 1.02, customizer)).toBe(false); + }); + + it('should use the customizer function for object comparison', () => { + const customizer = (a: any, b: any, key?: PropertyKey) => { + if (key === 'date' && a instanceof Date && b instanceof Date) { + return a.getFullYear() === b.getFullYear(); + } + }; + + const obj1 = { name: 'John', date: new Date('2023-01-01') }; + const obj2 = { name: 'John', date: new Date('2023-06-15') }; + const obj3 = { name: 'John', date: new Date('2024-01-01') }; + + expect(isEqualWith(obj1, obj2, customizer)).toBe(true); + expect(isEqualWith(obj1, obj3, customizer)).toBe(false); + }); + + it('should use the customizer function with parent objects', () => { + const customizer = (a: any, b: any, key?: PropertyKey, aParent?: any, bParent?: any) => { + if (key === 'value' && aParent && bParent && aParent.type === 'special' && bParent.type === 'special') { + return String(a) === String(b); + } + }; + + const obj1 = { type: 'special', value: 42 }; + const obj2 = { type: 'special', value: '42' }; + const obj3 = { type: 'normal', value: 42 }; + const obj4 = { type: 'normal', value: '42' }; + + expect(isEqualWith(obj1, obj2, customizer)).toBe(true); + expect(isEqualWith(obj1, obj3, customizer)).toBe(false); + expect(isEqualWith(obj3, obj4, customizer)).toBe(false); + }); +}); diff --git a/src/predicate/isEqualWith.ts b/src/predicate/isEqualWith.ts new file mode 100644 index 00000000..e663a6ea --- /dev/null +++ b/src/predicate/isEqualWith.ts @@ -0,0 +1,335 @@ +import { isPlainObject } from './isPlainObject.ts'; +import { getSymbols } from '../compat/_internal/getSymbols.ts'; +import { getTag } from '../compat/_internal/getTag.ts'; +import { + argumentsTag, + arrayBufferTag, + arrayTag, + bigInt64ArrayTag, + bigUint64ArrayTag, + booleanTag, + dataViewTag, + dateTag, + errorTag, + float32ArrayTag, + float64ArrayTag, + functionTag, + int8ArrayTag, + int16ArrayTag, + int32ArrayTag, + mapTag, + numberTag, + objectTag, + regexpTag, + setTag, + stringTag, + symbolTag, + uint8ArrayTag, + uint8ClampedArrayTag, + uint16ArrayTag, + uint32ArrayTag, +} from '../compat/_internal/tags.ts'; + +declare let Buffer: + | { + isBuffer: (a: any) => boolean; + } + | undefined; + +/** + * Compares two values for equality using a custom comparison function. + * + * The custom function allows for fine-tuned control over the comparison process. If it returns a boolean, that result determines the equality. If it returns undefined, the function falls back to the default equality comparison. + * + * This function also uses the custom equality function to compare values inside objects, + * arrays, maps, sets, and other complex structures, ensuring a deep comparison. + * + * This approach provides flexibility in handling complex comparisons while maintaining efficient default behavior for simpler cases. + * + * The custom comparison function can take up to six parameters: + * - `x`: The value from the first object `a`. + * - `y`: The value from the second object `b`. + * - `property`: The property key used to get `x` and `y`. + * - `xParent`: The parent of the first value `x`. + * - `yParent`: The parent of the second value `y`. + * - `stack`: An internal stack (Map) to handle circular references. + * + * @param {unknown} a - The first value to compare. + * @param {unknown} b - The second value to compare. + * @param {(x: any, y: any, property?: PropertyKey, xParent?: any, yParent?: any, stack?: Map) => boolean | void} areValuesEqual - A function to customize the comparison. + * If it returns a boolean, that result will be used. If it returns undefined, + * the default equality comparison will be used. + * @returns {boolean} `true` if the values are equal according to the customizer, otherwise `false`. + * + * @example + * const customizer = (a, b) => { + * if (typeof a === 'string' && typeof b === 'string') { + * return a.toLowerCase() === b.toLowerCase(); + * } + * }; + * isEqualWith('Hello', 'hello', customizer); // true + * isEqualWith({ a: 'Hello' }, { a: 'hello' }, customizer); // true + * isEqualWith([1, 2, 3], [1, 2, 3], customizer); // true + */ +export function isEqualWith( + a: any, + b: any, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean { + return isEqualWithImpl(a, b, undefined, undefined, undefined, undefined, areValuesEqual); +} + +function isEqualWithImpl( + a: any, + b: any, + property: PropertyKey | undefined, + aParent: any, + bParent: any, + stack: Map | undefined, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +): boolean { + const result = areValuesEqual(a, b, property, aParent, bParent, stack); + + if (result !== undefined) { + return result; + } + + if (typeof a === typeof b) { + switch (typeof a) { + case 'bigint': + case 'string': + case 'boolean': + case 'symbol': + case 'undefined': { + return a === b; + } + case 'number': { + return a === b || Object.is(a, b); + } + case 'function': { + return a === b; + } + case 'object': { + return areObjectsEqual(a, b, stack, areValuesEqual); + } + } + } + + return areObjectsEqual(a, b, stack, areValuesEqual); +} + +function areObjectsEqual( + a: any, + b: any, + stack: Map | undefined, + areValuesEqual: ( + x: any, + y: any, + property?: PropertyKey, + xParent?: any, + yParent?: any, + stack?: Map + ) => boolean | void +) { + if (Object.is(a, b)) { + return true; + } + + let aTag = getTag(a); + let bTag = getTag(b); + + if (aTag === argumentsTag) { + aTag = objectTag; + } + + if (bTag === argumentsTag) { + bTag = objectTag; + } + + if (aTag !== bTag) { + return false; + } + + switch (aTag) { + case stringTag: + return a.toString() === b.toString(); + + case numberTag: { + const x = a.valueOf(); + const y = b.valueOf(); + + return x === y || (Number.isNaN(x) && Number.isNaN(y)); + } + + case booleanTag: + case dateTag: + case symbolTag: + return Object.is(a.valueOf(), b.valueOf()); + + case regexpTag: { + return a.source === b.source && a.flags === b.flags; + } + + case functionTag: { + return a === b; + } + } + + stack = stack ?? new Map(); + + const aStack = stack.get(a); + const bStack = stack.get(b); + + if (aStack != null && bStack != null) { + return aStack === b; + } + + stack.set(a, b); + stack.set(b, a); + + try { + switch (aTag) { + case mapTag: { + if (a.size !== b.size) { + return false; + } + + for (const [key, value] of a.entries()) { + if (!b.has(key) || !isEqualWithImpl(value, b.get(key), key, a, b, stack, areValuesEqual)) { + return false; + } + } + + return true; + } + + case setTag: { + if (a.size !== b.size) { + return false; + } + + const aValues = Array.from(a.values()); + const bValues = Array.from(b.values()); + + for (let i = 0; i < aValues.length; i++) { + const aValue = aValues[i]; + const index = bValues.findIndex(bValue => { + return isEqualWithImpl(aValue, bValue, undefined, a, b, stack, areValuesEqual); + }); + + if (index === -1) { + return false; + } + + bValues.splice(index, 1); + } + + return true; + } + + case arrayTag: + case uint8ArrayTag: + case uint8ClampedArrayTag: + case uint16ArrayTag: + case uint32ArrayTag: + case bigUint64ArrayTag: + case int8ArrayTag: + case int16ArrayTag: + case int32ArrayTag: + case bigInt64ArrayTag: + case float32ArrayTag: + case float64ArrayTag: { + // Buffers are also treated as [object Uint8Array]s. + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(a) !== Buffer.isBuffer(b)) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (!isEqualWithImpl(a[i], b[i], i, a, b, stack, areValuesEqual)) { + return false; + } + } + + return true; + } + + case arrayBufferTag: { + if (a.byteLength !== b.byteLength) { + return false; + } + + return areObjectsEqual(new Uint8Array(a), new Uint8Array(b), stack, areValuesEqual); + } + + case dataViewTag: { + if (a.byteLength !== b.byteLength || a.byteOffset !== b.byteOffset) { + return false; + } + + return areObjectsEqual(new Uint8Array(a), new Uint8Array(b), stack, areValuesEqual); + } + + case errorTag: { + return a.name === b.name && a.message === b.message; + } + + case objectTag: { + const areEqualInstances = + areObjectsEqual(a.constructor, b.constructor, stack, areValuesEqual) || + (isPlainObject(a) && isPlainObject(b)); + + if (!areEqualInstances) { + return false; + } + + const aKeys = [...Object.keys(a), ...getSymbols(a)]; + const bKeys = [...Object.keys(b), ...getSymbols(b)]; + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (let i = 0; i < aKeys.length; i++) { + const propKey = aKeys[i]; + const aProp = (a as any)[propKey]; + + if (!Object.hasOwn(b, propKey)) { + return false; + } + + const bProp = (b as any)[propKey]; + + if (!isEqualWithImpl(aProp, bProp, propKey, a, b, stack, areValuesEqual)) { + return false; + } + } + + return true; + } + default: { + return false; + } + } + } finally { + stack.delete(a); + stack.delete(b); + } +}