From a0e80d7714926272352db658093789677c0eef33 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 25 Jun 2024 20:25:58 -0400 Subject: [PATCH 1/7] test: crush with dot in property name --- tests/object/crush.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/object/crush.test.ts b/tests/object/crush.test.ts index 01ed141c..fc52192f 100644 --- a/tests/object/crush.test.ts +++ b/tests/object/crush.test.ts @@ -33,4 +33,12 @@ describe('crush', () => { timestamp: now, }) }) + test('handles property names with dots', () => { + const obj = { + a: { 'b.c': 'value' } + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value' + }) + }) }) From 6a0729e4390305e0198e533d9abb1d2fc9cdefcb Mon Sep 17 00:00:00 2001 From: Stefaan Vandevelde Date: Mon, 8 Jul 2024 21:15:16 +0200 Subject: [PATCH 2/7] fixes support dot in property name #23 --- src/object/crush.ts | 41 ++++++++++++++++++++++++++++---------- tests/object/crush.test.ts | 26 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/object/crush.ts b/src/object/crush.ts index 7a6397f2..3315fa9f 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -1,4 +1,4 @@ -import { get, keys, objectify } from 'radashi' +import { isDate, isPrimitive, objectify } from "radashi"; /** * Flattens a deep object to a single dimension, converting the keys @@ -11,13 +11,34 @@ import { get, keys, objectify } from 'radashi' * // { name: 'ra', 'children.0.name': 'hathor' } * ``` */ -export function crush(value: TValue): object { - if (!value) { - return {} - } - return objectify( - keys(value), - k => k, - k => get(value, k), - ) +type Primitive = + | number + | string + | boolean + | Date + | symbol + | bigint + | undefined + | null; + +export function crush( + value: TValue, +): Record | Record { + if (!value) return {}; + const crushToPvArray: ( + obj: object, + path: string, + ) => Array<{ p: string; v: Primitive }> = (obj: object, path: string) => + Object.entries(obj).flatMap(([key, value]) => + isPrimitive(value) || isDate(value) + ? { p: path === "" ? key : `${path}.${key}`, v: value } + : crushToPvArray(value, path === "" ? key : `${path}.${key}`), + ); + + const result = objectify( + crushToPvArray(value, ""), + (o) => o.p, + (o) => o.v, + ); + return result; } diff --git a/tests/object/crush.test.ts b/tests/object/crush.test.ts index fc52192f..536da0dd 100644 --- a/tests/object/crush.test.ts +++ b/tests/object/crush.test.ts @@ -33,7 +33,7 @@ describe('crush', () => { timestamp: now, }) }) - test('handles property names with dots', () => { + test('handles property names with dots 1', () => { const obj = { a: { 'b.c': 'value' } } @@ -41,4 +41,28 @@ describe('crush', () => { 'a.b.c': 'value' }) }) + test('handles property names with dots 2', () => { + const obj = { + 'a.b': { c: 'value' } + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value' + }) + }) + test('handles property names with dots 3', () => { + const obj = { + 'a.b': { 'c.d': 123.4 } + } + expect(_.crush(obj)).toEqual({ + 'a.b.c.d': 123.4 + }) + }) + test('handles arrays', () => { + const obj = ['value', 123.4, true] + expect(_.crush(obj)).toEqual({ + '0': 'value', + '1': 123.4, + '2': true + }) + }) }) From b3eef455c37af3dd3da999e89b3a1af85bd323b5 Mon Sep 17 00:00:00 2001 From: Stefaan Vandevelde Date: Sun, 14 Jul 2024 20:32:53 +0200 Subject: [PATCH 3/7] Extracted inner recursive funtion out of the crush function for better performance --- src/object/crush.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/object/crush.ts b/src/object/crush.ts index 3315fa9f..c0819f48 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -21,19 +21,20 @@ type Primitive = | undefined | null; +const crushToPvArray: ( + obj: object, + path: string, +) => Array<{ p: string; v: Primitive }> = (obj: object, path: string) => + Object.entries(obj).flatMap(([key, value]) => + isPrimitive(value) || isDate(value) + ? { p: path === "" ? key : `${path}.${key}`, v: value } + : crushToPvArray(value, path === "" ? key : `${path}.${key}`), + ); + export function crush( value: TValue, ): Record | Record { if (!value) return {}; - const crushToPvArray: ( - obj: object, - path: string, - ) => Array<{ p: string; v: Primitive }> = (obj: object, path: string) => - Object.entries(obj).flatMap(([key, value]) => - isPrimitive(value) || isDate(value) - ? { p: path === "" ? key : `${path}.${key}`, v: value } - : crushToPvArray(value, path === "" ? key : `${path}.${key}`), - ); const result = objectify( crushToPvArray(value, ""), From ee3aac32af9be5318a9ca12ec59975b8a5792ff9 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 19 Jul 2024 12:35:57 -0400 Subject: [PATCH 4/7] chore: format --- src/object/crush.ts | 50 ++++++++++++++++++++------------------ tests/object/crush.test.ts | 14 +++++------ 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/object/crush.ts b/src/object/crush.ts index c0819f48..d2835826 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -1,4 +1,4 @@ -import { isDate, isPrimitive, objectify } from "radashi"; +import { isDate, isPrimitive, objectify } from 'radashi' /** * Flattens a deep object to a single dimension, converting the keys @@ -12,34 +12,36 @@ import { isDate, isPrimitive, objectify } from "radashi"; * ``` */ type Primitive = - | number - | string - | boolean - | Date - | symbol - | bigint - | undefined - | null; + | number + | string + | boolean + | Date + | symbol + | bigint + | undefined + | null const crushToPvArray: ( - obj: object, - path: string, + obj: object, + path: string, ) => Array<{ p: string; v: Primitive }> = (obj: object, path: string) => - Object.entries(obj).flatMap(([key, value]) => - isPrimitive(value) || isDate(value) - ? { p: path === "" ? key : `${path}.${key}`, v: value } - : crushToPvArray(value, path === "" ? key : `${path}.${key}`), - ); + Object.entries(obj).flatMap(([key, value]) => + isPrimitive(value) || isDate(value) + ? { p: path === '' ? key : `${path}.${key}`, v: value } + : crushToPvArray(value, path === '' ? key : `${path}.${key}`), + ) export function crush( - value: TValue, + value: TValue, ): Record | Record { - if (!value) return {}; + if (!value) { + return {} + } - const result = objectify( - crushToPvArray(value, ""), - (o) => o.p, - (o) => o.v, - ); - return result; + const result = objectify( + crushToPvArray(value, ''), + o => o.p, + o => o.v, + ) + return result } diff --git a/tests/object/crush.test.ts b/tests/object/crush.test.ts index 536da0dd..16cfd0af 100644 --- a/tests/object/crush.test.ts +++ b/tests/object/crush.test.ts @@ -35,26 +35,26 @@ describe('crush', () => { }) test('handles property names with dots 1', () => { const obj = { - a: { 'b.c': 'value' } + a: { 'b.c': 'value' }, } expect(_.crush(obj)).toEqual({ - 'a.b.c': 'value' + 'a.b.c': 'value', }) }) test('handles property names with dots 2', () => { const obj = { - 'a.b': { c: 'value' } + 'a.b': { c: 'value' }, } expect(_.crush(obj)).toEqual({ - 'a.b.c': 'value' + 'a.b.c': 'value', }) }) test('handles property names with dots 3', () => { const obj = { - 'a.b': { 'c.d': 123.4 } + 'a.b': { 'c.d': 123.4 }, } expect(_.crush(obj)).toEqual({ - 'a.b.c.d': 123.4 + 'a.b.c.d': 123.4, }) }) test('handles arrays', () => { @@ -62,7 +62,7 @@ describe('crush', () => { expect(_.crush(obj)).toEqual({ '0': 'value', '1': 123.4, - '2': true + '2': true, }) }) }) From ac36679c2baa56c344a32b571e7156934dc462cb Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:43:21 -0400 Subject: [PATCH 5/7] stop using objectify and improve the return type --- src/object/crush.ts | 79 +++++++++++++++++++++++++++------------------ src/types.ts | 16 +++++++++ 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/object/crush.ts b/src/object/crush.ts index d2835826..36298a5b 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -1,4 +1,4 @@ -import { isDate, isPrimitive, objectify } from 'radashi' +import { type Intersect, isArray, isObject, type Simplify } from 'radashi' /** * Flattens a deep object to a single dimension, converting the keys @@ -11,37 +11,54 @@ import { isDate, isPrimitive, objectify } from 'radashi' * // { name: 'ra', 'children.0.name': 'hathor' } * ``` */ -type Primitive = - | number - | string - | boolean - | Date - | symbol - | bigint - | undefined - | null - -const crushToPvArray: ( - obj: object, - path: string, -) => Array<{ p: string; v: Primitive }> = (obj: object, path: string) => - Object.entries(obj).flatMap(([key, value]) => - isPrimitive(value) || isDate(value) - ? { p: path === '' ? key : `${path}.${key}`, v: value } - : crushToPvArray(value, path === '' ? key : `${path}.${key}`), - ) - -export function crush( - value: TValue, -): Record | Record { +export function crush(value: T): Crush { if (!value) { return {} } - - const result = objectify( - crushToPvArray(value, ''), - o => o.p, - o => o.v, - ) - return result + return (function crushReducer( + crushed: Crush, + value: unknown, + path: string, + ) { + if (isObject(value) || isArray(value)) { + for (const [prop, propValue] of Object.entries(value)) { + crushReducer(crushed, propValue, path ? `${path}.${prop}` : prop) + } + } else { + crushed[path as keyof Crush] = value as Crush[keyof Crush] + } + return crushed + })({} as Crush, value, '') } + +/** + * The return type of the `crush` function. + * + * This type is limited by TypeScript's development. There's no + * reliable way to detect if an object will pass `isObject` or not, so + * we cannot infer the property types of nested objects that have been + * crushed. + * + * @see https://radashi-org.github.io/reference/object/crush + */ +export type Crush = T extends readonly (infer U)[] + ? Record + : Simplify< + Intersect< + keyof T extends infer Prop + ? Prop extends keyof T + ? T[Prop] extends infer Value + ? + | ([Extract] extends [never] + ? never + : Record) + | ([Exclude] extends [never] + ? never + : [Extract] extends [never] + ? { [P in Prop]: Value } + : Record) + : never + : never + : never + > + > diff --git a/src/types.ts b/src/types.ts index 5234cb95..20baf2c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,3 +108,19 @@ export type ComparableProperty = CompatibleProperty * value, and 0 to keep the order of the values. */ export type Comparator = (left: T, right: T) => number + +/** Convert a union to an intersection */ +export type Intersect = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never + +/** + * Useful to flatten the type output to improve type hints shown in + * editors. And also to transform an interface into a type to aide + * with assignability. + * + * @see https://github.com/microsoft/TypeScript/issues/15300 + */ +export type Simplify = {} & { [P in keyof T]: T[P] } From 0168241648a3701beaa0a025bca0c124753699a1 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:43:36 -0400 Subject: [PATCH 6/7] add failing test case and add typing tests --- tests/object/crush.test-d.ts | 53 ++++++++++++++++++++++++ tests/object/crush.test.ts | 79 +++++++++++++++++++++++++----------- 2 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 tests/object/crush.test-d.ts diff --git a/tests/object/crush.test-d.ts b/tests/object/crush.test-d.ts new file mode 100644 index 00000000..059db22c --- /dev/null +++ b/tests/object/crush.test-d.ts @@ -0,0 +1,53 @@ +import * as _ from 'radashi' + +describe('crush', () => { + test('direct properties are preserved in the result type', () => { + const obj = _.crush({ a: 1, b: '2', c: true }) + expectTypeOf(obj).toEqualTypeOf<{ a: number; b: string; c: boolean }>() + }) + test('nested objects add Record to result type', () => { + const obj = _.crush({ a: { b: 1 } }) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush({ a: 1, b: { c: 2 } }) + expectTypeOf(obj2).toEqualTypeOf<{ a: number; [key: string]: unknown }>() + expectTypeOf(obj2.a).toEqualTypeOf() + expectTypeOf(obj2['b.c']).toEqualTypeOf() + }) + test('optional property', () => { + const obj = _.crush({} as { a?: number }) + // FIXME: Try to preserve optionality. + expectTypeOf(obj).toEqualTypeOf<{ a: number | undefined }>() + }) + test('crushed Record type', () => { + const obj = _.crush({} as Record) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush({} as Record) + expectTypeOf(obj2).toEqualTypeOf>() + + const obj3 = _.crush({} as Record) + expectTypeOf(obj3).toEqualTypeOf>() + + const obj4 = _.crush({} as Record) + expectTypeOf(obj4).toEqualTypeOf>() + }) + test('crushed array', () => { + // We cannot assume the keys from "number[]" input value, but we + // *can* assume the value type. Note that the value type doesn't + // contain "undefined" (which matches array behavior). + const obj = _.crush([1, 2, 3]) + expectTypeOf(obj).toEqualTypeOf>() + + const obj2 = _.crush([1, { b: 2 }]) + expectTypeOf(obj2).toEqualTypeOf>() + expectTypeOf(obj2[0]).toEqualTypeOf() + }) + test('union type with object and primitive', () => { + // Since "a" may be an object, we cannot assume the result will + // have an "a" property. Therefore, the keys and values of the + // result are unknown. + const obj = _.crush({ a: {} as number | object }) + expectTypeOf(obj).toEqualTypeOf>() + }) +}) diff --git a/tests/object/crush.test.ts b/tests/object/crush.test.ts index 16cfd0af..3645a8d6 100644 --- a/tests/object/crush.test.ts +++ b/tests/object/crush.test.ts @@ -33,30 +33,6 @@ describe('crush', () => { timestamp: now, }) }) - test('handles property names with dots 1', () => { - const obj = { - a: { 'b.c': 'value' }, - } - expect(_.crush(obj)).toEqual({ - 'a.b.c': 'value', - }) - }) - test('handles property names with dots 2', () => { - const obj = { - 'a.b': { c: 'value' }, - } - expect(_.crush(obj)).toEqual({ - 'a.b.c': 'value', - }) - }) - test('handles property names with dots 3', () => { - const obj = { - 'a.b': { 'c.d': 123.4 }, - } - expect(_.crush(obj)).toEqual({ - 'a.b.c.d': 123.4, - }) - }) test('handles arrays', () => { const obj = ['value', 123.4, true] expect(_.crush(obj)).toEqual({ @@ -65,4 +41,59 @@ describe('crush', () => { '2': true, }) }) + describe('property names containing period', () => { + test('inside the root object', () => { + const obj = { + 'a.b': { c: 'value' }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value', + }) + }) + test('inside a nested object', () => { + const obj = { + a: { 'b.c': 'value' }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c': 'value', + }) + }) + test('inside both root and nested object', () => { + const obj = { + 'a.b': { 'c.d': 123.4 }, + } + expect(_.crush(obj)).toEqual({ + 'a.b.c.d': 123.4, + }) + }) + test('crush array containing object with nested property', () => { + const arr = [{ 'c.d': { 'e.f': 'g' } }] + const obj = { + 'a.b': arr, + } + expect(_.crush(obj)).toEqual({ + 'a.b.0.c.d.e.f': 'g', + }) + }) + test('do not crush Date objects', () => { + const date = new Date() + const obj = { + 'a.b': date, + } + expect(_.crush(obj)).toEqual({ + 'a.b': date, + }) + }) + test('do not crush Map objects', () => { + const map = new Map() + map.set('a', 'b') + map.set('c', 'd') + const obj = { + 'a.b': map, + } + expect(_.crush(obj)).toEqual({ + 'a.b': map, + }) + }) + }) }) From b7370c71295a1b7333fc28512f24379928f4ee13 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:44:28 -0400 Subject: [PATCH 7/7] fix lint issue --- src/object/crush.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/object/crush.ts b/src/object/crush.ts index 36298a5b..324dddcc 100644 --- a/src/object/crush.ts +++ b/src/object/crush.ts @@ -13,7 +13,7 @@ import { type Intersect, isArray, isObject, type Simplify } from 'radashi' */ export function crush(value: T): Crush { if (!value) { - return {} + return {} as Crush } return (function crushReducer( crushed: Crush,