diff --git a/.changeset/forty-books-mate.md b/.changeset/forty-books-mate.md new file mode 100644 index 0000000..dc595c8 --- /dev/null +++ b/.changeset/forty-books-mate.md @@ -0,0 +1,7 @@ +--- +"@naverpay/hidash": patch +--- + +🚀 find + +PR: [🚀 find](https://github.com/NaverPayDev/hidash/pull/153) diff --git a/index.ts b/index.ts index 50fbf25..83a624d 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ const moduleMap = { debounce: './src/debounce.ts', eq: './src/eq.ts', every: './src/every.ts', + find: './src/find.ts', findIndex: './src/findIndex.ts', findLastIndex: './src/findLastIndex.ts', first: './src/first.ts', diff --git a/package.json b/package.json index 5fae1bf..a672519 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,16 @@ "default": "./every.js" } }, + "./find": { + "import": { + "types": "./find.d.mts", + "default": "./find.mjs" + }, + "require": { + "types": "./find.d.ts", + "default": "./find.js" + } + }, "./findIndex": { "import": { "types": "./findIndex.d.mts", diff --git a/src/find.bench.ts b/src/find.bench.ts new file mode 100644 index 0000000..95c3d5e --- /dev/null +++ b/src/find.bench.ts @@ -0,0 +1,61 @@ +import _find from 'lodash/find' +import {describe, bench} from 'vitest' + +import {find} from './find' + +const testCases = [ + [[0, 1, 0, 1, 0], (v: number) => v === 1], + [new Array(10000).fill(0).map((_, i) => (i % 1000 === 0 ? 1 : 0)), (v: number) => v === 1], + [ + [ + {name: {first: 'a', last: 'b'}}, + {name: {first: 'c', last: 'd'}}, + {name: {first: 'e', last: 'f'}}, + {name: {first: 'a', last: 'b'}}, + ], + {name: {first: 'a'}}, + ], + [ + [ + {id: 1, value: 'a'}, + {id: 2, value: 'b'}, + {id: 3, value: 'c'}, + {id: 4, value: 'd'}, + ], + 'value', + ], + [ + [ + {id: 1, value: 'a'}, + {id: 2, value: 'b'}, + {id: 3, value: 'c'}, + {id: 4, value: 'd'}, + ], + ['value', 'b'], + ], + // eslint-disable-next-line no-sparse-arrays + [[0, , 1, , , 3], (v: number) => v === 1], + ['hello world'.split(''), (v: string) => v === 'o'], +] as const + +const ITERATIONS = 1000 + +describe('find performance', () => { + bench('hidash', () => { + for (let i = 0; i < ITERATIONS; i++) { + testCases.forEach(([array, predicate]) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + find(array, predicate) + }) + } + }) + + bench('lodash', () => { + for (let i = 0; i < ITERATIONS; i++) { + testCases.forEach(([array, predicate]) => { + _find(array, predicate) + }) + } + }) +}) diff --git a/src/find.test.ts b/src/find.test.ts new file mode 100644 index 0000000..bc6c92f --- /dev/null +++ b/src/find.test.ts @@ -0,0 +1,70 @@ +import _find from 'lodash/find' +import {describe, it, expect} from 'vitest' + +import {find} from './find' + +describe('find', () => { + it('should find the first matching element based on the predicate', () => { + const array = [1, 2, 3, 4, 5] + + expect(find(array, (x) => x === 10)).toBe(undefined) + expect(find(array, (x) => x === 3)).toBe(_find(array, (x) => x === 3)) + expect(find(array, (x) => x > 3)).toBe(_find(array, (x) => x > 3)) + expect(find(array, (x) => x === 10)).toBe(_find(array, (x) => x === 10)) + }) + + it('should handle shorthand predicates', () => { + const nestedArray = [{a: 1}, {a: 2}, {a: 3}] + + expect(find(nestedArray, {a: 2})).toEqual({a: 2}) + expect(find(nestedArray, {a: 10})).toBe(undefined) + expect(find(nestedArray, {a: 2})).toEqual(_find(nestedArray, {a: 2})) + expect(find(nestedArray, {a: 10})).toEqual(_find(nestedArray, {a: 10})) + }) + + it('should handle array shorthand predicates', () => { + const nestedArray = [{a: 1}, {a: 2}, {a: 3}] + + expect(find(nestedArray, ['a', 2])).toEqual({a: 2}) + expect(find(nestedArray, ['a', 10])).toBe(undefined) + expect(find(nestedArray, ['a', 2])).toEqual(_find(nestedArray, ['a', 2])) + expect(find(nestedArray, ['a', 10])).toEqual(_find(nestedArray, ['a', 10])) + }) + + it('should handle string shorthand predicates', () => { + const nestedArray = [{a: 1}, {b: 2}, {a: 3}] + + expect(find(nestedArray, 'a')).toEqual(_find(nestedArray, 'a')) + expect(find(nestedArray, 'b')).toEqual(_find(nestedArray, 'b')) + }) + + it('should return undefined for an empty array', () => { + expect(find([], (x) => x === 1)).toBe(undefined) + expect(find([], (x) => x === 1)).toBe(_find([], (x) => x === 1)) + }) + + it('should handle complex objects with nested properties', () => { + const nestedArray = [ + {name: {first: 'Alice', last: 'Smith'}}, + {name: {first: 'Bob', last: 'Jones'}}, + {name: {first: 'Alice', last: 'Brown'}}, + ] + + expect(find(nestedArray, {name: {first: 'Alice'}})).toEqual(_find(nestedArray, {name: {first: 'Alice'}})) + expect(find(nestedArray, {name: {first: 'Alice', last: 'Brown'}})).toEqual( + _find(nestedArray, {name: {first: 'Alice', last: 'Brown'}}), + ) + }) + + it('should handle truthy values if no predicate is provided', () => { + const array = [0, 1, false, 2, '', 3] + + expect(find(array)).toBe(_find(array)) + }) + + it('should handle sparse arrays', () => { + // eslint-disable-next-line no-sparse-arrays + const sparseArray = [0, , 1, , , 3] + expect(find(sparseArray)).toBe(_find(sparseArray)) + }) +}) diff --git a/src/find.ts b/src/find.ts new file mode 100644 index 0000000..7a0c546 --- /dev/null +++ b/src/find.ts @@ -0,0 +1,65 @@ +import isArray from './isArray' +import isEqual from './isEqual' +import isObject from './isObject' + +type Predicate = (item: T, index: number, array: T[]) => boolean +type Shorthand = DeepPartial | [keyof T, unknown] | keyof T + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] +} + +function isPartialMatch(item: T, partial: DeepPartial): boolean { + if (!item || !partial) { + return false + } + + return Object.keys(partial).every((key) => { + const partialValue = partial[key as keyof T] + const itemValue = item[key as keyof T] + + if (isObject(partialValue) && isObject(itemValue)) { + return isPartialMatch(itemValue, partialValue as DeepPartial) + } + + return isEqual(itemValue, partialValue) + }) +} + +function isMatchPredicate(item: T | undefined, predicate: Predicate | Shorthand): boolean { + if (!item) { + return false + } + + if (typeof predicate === 'function') { + return predicate(item, 0, []) + } + + if (isArray(predicate)) { + const [key, value] = predicate + return isEqual(item[key], value) + } + + if (typeof predicate === 'string') { + return Boolean(item[predicate as keyof T]) + } + + return isPartialMatch(item, predicate as DeepPartial) +} + +export function find(collection: T[] | Record, predicate?: Predicate | Shorthand): T | undefined { + const array = isArray(collection) ? collection : Object.values(collection) + + const isMatch = predicate ? (item: T) => isMatchPredicate(item, predicate) : (item: T) => Boolean(item) + + for (let i = 0; i < array.length; i++) { + const item = array[i] + if (item !== undefined && isMatch(item)) { + return item + } + } + + return undefined +} + +export default find