From efd4d09c8dd408385fe303b810dfe718637bd93f Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Thu, 20 Jul 2023 20:36:09 -0400 Subject: [PATCH] feat(utils): `ksort` Signed-off-by: Lexus Drumgold --- .dictionary.txt | 1 + .eslintrc.cjs | 12 ++ src/types/__tests__/object-plain.spec-d.ts | 19 ++- src/types/object-plain.ts | 9 +- src/utils/__snapshots__/ksort.snap | 44 ++++++ src/utils/__tests__/ksort.options.spec-d.ts | 20 +++ src/utils/__tests__/ksort.spec-d.ts | 17 +++ src/utils/__tests__/ksort.spec.ts | 109 +++++++++++++++ src/utils/index.ts | 2 + src/utils/is-array.ts | 6 +- src/utils/is-object-plain.ts | 6 +- src/utils/ksort.options.ts | 25 ++++ src/utils/ksort.ts | 141 ++++++++++++++++++++ 13 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 src/utils/__snapshots__/ksort.snap create mode 100644 src/utils/__tests__/ksort.options.spec-d.ts create mode 100644 src/utils/__tests__/ksort.spec-d.ts create mode 100644 src/utils/__tests__/ksort.spec.ts create mode 100644 src/utils/ksort.options.ts create mode 100644 src/utils/ksort.ts diff --git a/.dictionary.txt b/.dictionary.txt index 1872a00f..a9bfaf77 100644 --- a/.dictionary.txt +++ b/.dictionary.txt @@ -21,6 +21,7 @@ iife infile jsonifiable keyid +ksort larsgw lcov lintstagedrc diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 5ab769a4..5f78cdda 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,6 +28,18 @@ const config = { '@typescript-eslint/ban-types': 0 } }, + { + files: ['src/utils/__tests__/ksort.spec.ts'], + rules: { + 'sort-keys': 0 + } + }, + { + files: ['src/utils/ksort.ts'], + rules: { + '@typescript-eslint/no-use-before-define': 0 + } + }, { files: ['src/utils/noop.ts'], rules: { diff --git a/src/types/__tests__/object-plain.spec-d.ts b/src/types/__tests__/object-plain.spec-d.ts index afba1e3e..de39f772 100644 --- a/src/types/__tests__/object-plain.spec-d.ts +++ b/src/types/__tests__/object-plain.spec-d.ts @@ -9,15 +9,18 @@ import type TestSubject from '../object-plain' import type Primitive from '../primitive' describe('unit-d:types/ObjectPlain', () => { - it('should match [[x: string]: unknown]', () => { - expectTypeOf().toBeUnknown() + class Person {} + + it('should match [[x: string]: Primitive | object]', () => { + expectTypeOf().toEqualTypeOf() }) - it('should match [[x: symbol]: unknown]', () => { - expectTypeOf().toBeUnknown() + it('should match [[x: symbol]: Primitive | object]', () => { + expectTypeOf().toEqualTypeOf() }) it('should match pojos', () => { + expectTypeOf({}).toMatchTypeOf() expectTypeOf({ 5: null }).toMatchTypeOf() expectTypeOf({ email: faker.internet.email() }).toMatchTypeOf() }) @@ -31,13 +34,15 @@ describe('unit-d:types/ObjectPlain', () => { }) it('should not match class instance objects', () => { - expectTypeOf(new Date()).not.toMatchTypeOf() - expectTypeOf(new Map()).not.toMatchTypeOf() - expectTypeOf(new Set()).not.toMatchTypeOf() + expectTypeOf().not.toMatchTypeOf() + expectTypeOf().not.toMatchTypeOf() + expectTypeOf().not.toMatchTypeOf() + expectTypeOf().not.toMatchTypeOf() }) it('should not match functions', () => { expectTypeOf().not.toMatchTypeOf() + expectTypeOf>().not.toMatchTypeOf() }) it('should not match primitives', () => { diff --git a/src/types/object-plain.ts b/src/types/object-plain.ts index e2748f1d..69fdde30 100644 --- a/src/types/object-plain.ts +++ b/src/types/object-plain.ts @@ -3,11 +3,16 @@ * @module tutils/types/ObjectPlain */ +import type Primitive from './primitive' + /** - * Type representing a plain old JavaScript object (POJO). + * A plain old JavaScript object (POJO). * * @see https://masteringjs.io/tutorials/fundamentals/pojo */ -type ObjectPlain = { [K in string | symbol]?: unknown } +type ObjectPlain = { + [x: string | symbol]: Primitive | object + readonly arguments?: never +} export type { ObjectPlain as default } diff --git a/src/utils/__snapshots__/ksort.snap b/src/utils/__snapshots__/ksort.snap new file mode 100644 index 00000000..2865bcab --- /dev/null +++ b/src/utils/__snapshots__/ksort.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`unit:utils/ksort > arrays > deep > should return obj with item keys sorted deeply 1`] = ` +[ + { + "make": "lexus", + "model": "lc", + "vin": "0WBW1G4D29TC62167", + "year": 2023, + }, + [Circular], +] +`; + +exports[`unit:utils/ksort > plain objects > deep > should return obj with nested and top-level keys sorted 1`] = ` +{ + "circular": [Circular], + "driver": { + "first_name": "tres", + "last_name": "koelpin", + "nanoid": "mhR2OWSEhQFXkwU1NJY23", + }, + "make": "lexus", + "model": "lc", + "riders": [ + { + "first_name": "adrian", + "last_name": "rodriguez", + "uuid": "19fd6413-7fc9-4e7f-a5b5-9a3689d25134", + }, + ], + "vin": "0WBW1G4D29TC62167", + "year": 2023, +} +`; + +exports[`unit:utils/ksort > plain objects > should return obj with top-level keys sorted 1`] = ` +{ + "make": "lexus", + "model": "lc", + "vin": "0WBW1G4D29TC62167", + "year": 2023, +} +`; diff --git a/src/utils/__tests__/ksort.options.spec-d.ts b/src/utils/__tests__/ksort.options.spec-d.ts new file mode 100644 index 00000000..19a15ab5 --- /dev/null +++ b/src/utils/__tests__/ksort.options.spec-d.ts @@ -0,0 +1,20 @@ +/** + * @file Type Tests - KsortOptions + * @module tutils/utils/tests/unit-d/KsortOptions + */ + +import type { Nilable } from '#src/types' +import type AlphabetizeOptions from '../alphabetize.options' +import type TestSubject from '../ksort.options' + +describe('unit-d:utils/KsortOptions', () => { + it('should extend AlphabetizeOptions', () => { + expectTypeOf().toMatchTypeOf() + }) + + it('should match [deep?: Nilable]', () => { + expectTypeOf() + .toHaveProperty('deep') + .toEqualTypeOf>() + }) +}) diff --git a/src/utils/__tests__/ksort.spec-d.ts b/src/utils/__tests__/ksort.spec-d.ts new file mode 100644 index 00000000..d09d4e46 --- /dev/null +++ b/src/utils/__tests__/ksort.spec-d.ts @@ -0,0 +1,17 @@ +/** + * @file Type Tests - ksort + * @module tutils/utils/tests/unit-d/ksort + */ + +import type Vehicle from '#fixtures/types/vehicle' +import type testSubject from '../ksort' + +describe('unit-d:utils/ksort', () => { + it('should return T', () => { + // Arrange + type T = Vehicle + + // Expect + expectTypeOf>().returns.toEqualTypeOf() + }) +}) diff --git a/src/utils/__tests__/ksort.spec.ts b/src/utils/__tests__/ksort.spec.ts new file mode 100644 index 00000000..1cb5b9cd --- /dev/null +++ b/src/utils/__tests__/ksort.spec.ts @@ -0,0 +1,109 @@ +/** + * @file Unit Tests - ksort + * @module tutils/utils/tests/unit/ksort + */ + +import type Vehicle from '#fixtures/types/vehicle' +import VEHICLE, { VEHICLE_TAG } from '#fixtures/vehicle' +import type { Opaque } from '#src/types' +import cast from '../cast' +import define from '../define' +import testSubject from '../ksort' + +describe('unit:utils/ksort', () => { + let vehicle: Opaque + + beforeAll(() => { + vehicle = cast({ year: 2023, vin: VEHICLE.vin, model: 'lc', make: 'lexus' }) + + define(vehicle, VEHICLE_TAG, { + configurable: false, + enumerable: false, + value: 'vehicle', + writable: false + }) + }) + + it('should return obj if obj is not an array or plain object', () => { + // Arrange + const obj: Set = new Set([vehicle]) + + // Act + Expect + expect(testSubject(obj)).to.equal(obj) + }) + + describe('arrays', () => { + it('should return obj if item keys should not be sorted', () => { + // Arrange + const obj: [Vehicle] = [vehicle] + + // Act + Expect + expect(testSubject(obj)).to.equal(obj) + }) + + describe('deep', () => { + let obj: (Vehicle | Vehicle[])[] + + beforeAll(() => { + obj = [vehicle] + obj.push(cast(obj)) + }) + + it('should return obj with item keys sorted deeply', () => { + // Act + const result = testSubject(obj, { deep: true }) + + // Expect + expect(result).to.eql(obj).and.equal(obj) + expect(result).toMatchSnapshot() + }) + }) + }) + + describe('plain objects', () => { + it('should return obj with top-level keys sorted', () => { + // Act + const result = testSubject(vehicle) + + // Expect + expect(result).to.eql(vehicle).and.equal(vehicle) + expect(result).toMatchSnapshot() + }) + + describe('deep', () => { + let obj: Vehicle & { + driver: { first_name: string; last_name: string; nanoid: string } + riders: { first_name: string; last_name: string; uuid: string }[] + } + + beforeAll(() => { + obj = { + ...vehicle, + driver: { + nanoid: 'mhR2OWSEhQFXkwU1NJY23', + last_name: 'koelpin', + first_name: 'tres' + }, + riders: [ + { + uuid: '19fd6413-7fc9-4e7f-a5b5-9a3689d25134', + last_name: 'rodriguez', + first_name: 'adrian' + } + ] + } + + define(obj, 'circular', { value: obj }) + }) + + it('should return obj with nested and top-level keys sorted', () => { + // Act + const result = testSubject(obj, { deep: true }) + + // Expect + expect(result).to.eql(obj).and.equal(obj) + expect(result).toMatchSnapshot() + }) + }) + }) +}) diff --git a/src/utils/index.ts b/src/utils/index.ts index a3f391b3..f956ba2f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -86,6 +86,8 @@ export { default as isWeakSet } from './is-weak-set' export { default as join } from './join' export { default as keys } from './keys' export type { default as KeysOptions } from './keys.options' +export { default as ksort } from './ksort' +export type { default as KsortOptions } from './ksort.options' export { default as listify } from './listify' export { default as lowercase } from './lowercase' export { default as noop } from './noop' diff --git a/src/utils/is-array.ts b/src/utils/is-array.ts index 2a800a17..f06e6e9a 100644 --- a/src/utils/is-array.ts +++ b/src/utils/is-array.ts @@ -9,13 +9,15 @@ import isObject from './is-object' /** * Checks if `value` is an array. * + * @todo examples + * * @template T - Array item type * * @param {unknown} value - Value to check * @return {value is ReadonlyArray | T[]} `true` if `value` is an array */ -function isArray(value: unknown): value is T[] | readonly T[] { - return isObject(value) && equal(Reflect.get(value, 'constructor'), Array) +const isArray = (value: unknown): value is T[] | readonly T[] => { + return isObject(value) && equal(value.constructor, Array) } export default isArray diff --git a/src/utils/is-object-plain.ts b/src/utils/is-object-plain.ts index 37516fe3..7100cd30 100644 --- a/src/utils/is-object-plain.ts +++ b/src/utils/is-object-plain.ts @@ -8,7 +8,7 @@ import equal from './equal' import isNull from './is-null' /** - * Checks if `value` is a plain object (i.e. [POJO][1]). + * Checks if `value` is a plain object ([POJO][1]). * * A plain object is an object created by the [`Object`][2] constructor or an * object with a `[[Prototype]]` of `null`. @@ -18,12 +18,14 @@ import isNull from './is-null' * * @see {@linkcode ObjectPlain} * + * @todo examples + * * @param {unknown} value - Value to check * @return {value is ObjectPlain} `true` if `value` is plain object */ const isObjectPlain = (value: unknown): value is ObjectPlain => { /** - * Plain object check for {@linkcode value}. + * Plain object check. * * @var {boolean} plain */ diff --git a/src/utils/ksort.options.ts b/src/utils/ksort.options.ts new file mode 100644 index 00000000..e8814168 --- /dev/null +++ b/src/utils/ksort.options.ts @@ -0,0 +1,25 @@ +/** + * @file Utilities - KsortOptions + * @module tutils/utils/ksort/options + */ + +import type { Nilable } from '#src/types' +import type AlphabetizeOptions from './alphabetize.options' + +/** + * Property key sorting options. + * + * @see {@linkcode AlphabetizeOptions} + * + * @extends {AlphabetizeOptions} + */ +interface KsortOptions extends AlphabetizeOptions { + /** + * Recursively sort keys, including keys of plain objects within arrays. + * + * @default false + */ + deep?: Nilable +} + +export type { KsortOptions as default } diff --git a/src/utils/ksort.ts b/src/utils/ksort.ts new file mode 100644 index 00000000..509e9d35 --- /dev/null +++ b/src/utils/ksort.ts @@ -0,0 +1,141 @@ +/** + * @file Utilities - ksort + * @module tutils/utils/ksort + */ + +import type { PropertyDescriptor } from '#src/interfaces' +import type { Nilable, ObjectCurly } from '#src/types' +import alphabetize from './alphabetize' +import cast from './cast' +import define from './define' +import descriptor from './descriptor' +import identity from './identity' +import isArray from './is-array' +import isObjectPlain from './is-object-plain' +import type KsortOptions from './ksort.options' +import properties from './properties' + +/** + * Returns an object with sorted keys. + * + * The initial target object **will** be modified. + * + * @see {@linkcode KsortOptions} + * + * @todo examples + * + * @template T - Object containing keys to sort + * + * @param {T} obj - Object containing keys to sort + * @param {Nilable} [options] - Key sorting options + * @return {T} Target object with sorted keys + */ +const ksort = ( + obj: T, + options?: Nilable +): T => { + // do nothing if obj is not an array or plain object + if (!isArray(obj) && !isObjectPlain(obj)) return obj + + /** + * Sorted values cache. + * + * @const {WeakMap} cache + */ + const cache: WeakMap = new WeakMap() + + /** + * Sorts the keys of plain objects in the given array. + * + * @template T - Array containing plain objects + * + * @param {T} arr - Array containing plain objects + * @return {T} New array containing plain objects with sorted keys + */ + const arrsort = (arr: T): T => { + // return cached output value + if (cache.has(arr)) return cast(cache.get(arr)) + + // cache array + cache.set(arr, arr) + + // sort keys of plain objects in arr + for (const [key, item] of arr.entries()) { + if (isArray(item)) arr[key] = arrsort(cast(item)) + if (isObjectPlain(item)) arr[key] = sort(item) + } + + return arr + } + + /** + * Removes all properties from an object. + * + * @template T - Target object + * + * @param {T} obj - Target object + * @return {T} Empty object + */ + const clear = (obj: T): T => { + for (const key of properties(obj)) Reflect.deleteProperty(obj, key) + return obj + } + + /** + * Returns a new object with sorted keys. + * + * @template T - Object to sort + * + * @param {T} object - Object to sort + * @return {T} New object with sorted keys + */ + const sort = (object: T): T => { + // return cached output value + if (cache.has(object)) return cast(cache.get(object)) + + /** + * Property names mapped to property descriptors. + * + * @const {[string | symbol, PropertyDescriptor][]} + */ + const props: [string | symbol, PropertyDescriptor][] = [ + ...alphabetize(Object.getOwnPropertyNames(object), identity, options), + ...Object.getOwnPropertySymbols(object) + ].map(key => [key, descriptor(object, key)]) + + /** + * New object with sorted keys. + * + * @const {T} ret + */ + const ret: T = clear(object) + + // cache object with sorted keys + cache.set(object, ret) + + // define properties of new object and recursively sort keys + for (const [key, descriptor] of props) { + if (options?.deep) { + switch (true) { + case isArray(descriptor.value): + descriptor.value = arrsort(cast(descriptor.value)) + break + case isObjectPlain(descriptor.value): + descriptor.value = sort(cast(descriptor.value)) + break + default: + break + } + } + + // define property + define(ret, key, descriptor) + } + + return ret + } + + return cast(isArray(obj) ? (options?.deep ? arrsort(obj) : obj) : sort(obj)) +} + +export default ksort