Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(crush): fix handling of period-containing property names #95

Merged
merged 7 commits into from
Jul 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/object/crush.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { get, keys, objectify } from 'radashi'
import { type Intersect, isArray, isObject, type Simplify } from 'radashi'

/**
* Flattens a deep object to a single dimension, converting the keys
Expand All @@ -11,13 +11,54 @@ import { get, keys, objectify } from 'radashi'
* // { name: 'ra', 'children.0.name': 'hathor' }
* ```
*/
export function crush<TValue extends object>(value: TValue): object {
export function crush<T extends object>(value: T): Crush<T> {
if (!value) {
return {}
return {} as Crush<T>
}
return objectify(
keys(value),
k => k,
k => get(value, k),
)
return (function crushReducer(
crushed: Crush<T>,
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<T>] = value as Crush<T>[keyof Crush<T>]
}
return crushed
})({} as Crush<T>, 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> = T extends readonly (infer U)[]
? Record<string, U extends object ? unknown : U>
: Simplify<
Intersect<
keyof T extends infer Prop
? Prop extends keyof T
? T[Prop] extends infer Value
?
| ([Extract<Value, object>] extends [never]
? never
: Record<string, unknown>)
| ([Exclude<Value, object>] extends [never]
? never
: [Extract<Value, object>] extends [never]
? { [P in Prop]: Value }
: Record<string, unknown>)
: never
: never
: never
>
>
16 changes: 16 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,19 @@ export type ComparableProperty<T> = CompatibleProperty<T, Comparable>
* value, and 0 to keep the order of the values.
*/
export type Comparator<T> = (left: T, right: T) => number

/** Convert a union to an intersection */
export type Intersect<U> = (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<T> = {} & { [P in keyof T]: T[P] }
53 changes: 53 additions & 0 deletions tests/object/crush.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()

const obj2 = _.crush({ a: 1, b: { c: 2 } })
expectTypeOf(obj2).toEqualTypeOf<{ a: number; [key: string]: unknown }>()
expectTypeOf(obj2.a).toEqualTypeOf<number>()
expectTypeOf(obj2['b.c']).toEqualTypeOf<unknown>()
})
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<number, number>)
expectTypeOf(obj).toEqualTypeOf<Record<number, number>>()

const obj2 = _.crush({} as Record<string, string>)
expectTypeOf(obj2).toEqualTypeOf<Record<string, string>>()

const obj3 = _.crush({} as Record<number, number | object>)
expectTypeOf(obj3).toEqualTypeOf<Record<string, unknown>>()

const obj4 = _.crush({} as Record<string, string | unknown[]>)
expectTypeOf(obj4).toEqualTypeOf<Record<string, unknown>>()
})
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<Record<string, number>>()

const obj2 = _.crush([1, { b: 2 }])
expectTypeOf(obj2).toEqualTypeOf<Record<string, unknown>>()
expectTypeOf(obj2[0]).toEqualTypeOf<unknown>()
})
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<Record<string, unknown>>()
})
})
63 changes: 63 additions & 0 deletions tests/object/crush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,67 @@ describe('crush', () => {
timestamp: now,
})
})
test('handles arrays', () => {
const obj = ['value', 123.4, true]
expect(_.crush(obj)).toEqual({
'0': 'value',
'1': 123.4,
'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,
})
})
})
})
Loading