Skip to content
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
9 changes: 9 additions & 0 deletions .changeset/add-notations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'flattify': minor
---

Add the `notation` option to `flatten` and `unflatten`, with the keys inferred by TypeScript for each one:

- `'dot'` (default): `items.0.id`
- `'bracket'`: `items[0].id`. `unflatten` also reads form-field names such as `user[name]` and `items[0][id]`
- `'pointer'`: `/items/0/id`, a JSON Pointer (RFC 6901) with `~0`/`~1` escaping, as used by JSON Patch and JSON Schema validators
25 changes: 25 additions & 0 deletions src/flatten/flatten.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ describe('flatten options', () => {
expect(flatten({ db: { MAX_CONN: 1, MAX_: 2 } }, { delimiter: '__' })).toEqual({ db__MAX_CONN: 1, 'db__MAX\\_': 2 });
});

test('notation: Should write array indices between brackets', () => {
expect(flatten({ items: [{ id: 1, tags: ['a'] }], 'a.b': { 'c[d': 1 } }, { notation: 'bracket' })).toEqual({ 'items[0].id': 1, 'items[0].tags[0]': 'a', 'a\\.b.c\\[d': 1 });
expect(flatten([{ id: 1 }], { notation: 'bracket' })).toEqual({ '[0].id': 1 });
expect(flatten({ a: { 0: 'x' } }, { notation: 'bracket' })).toEqual({ 'a.0': 'x' });
});

test('notation: Should write JSON Pointers', () => {
expect(flatten({ items: [{ id: 1 }], 'a/b': { 'm~n': 1, '': 2 } }, { notation: 'pointer' })).toEqual({ '/items/0/id': 1, '/a~1b/m~0n': 1, '/a~1b/': 2 });
expect(flatten([1], { notation: 'pointer' })).toEqual({ '/0': 1 });
});

test('notation: Should ignore delimiter and escape with pointers', () => {
expect(flatten({ 'a.b': { c: 1 } }, { notation: 'pointer', delimiter: '', escape: false })).toEqual({ '/a.b/c': 1 });
});

test('escape: Should leave keys as they are when off', () => {
expect(flatten({ 'a.b': { c: 1 } }, { escape: false })).toEqual({ 'a.b.c': 1 });
});
Expand Down Expand Up @@ -185,6 +200,7 @@ describe('flatten options', () => {
[{ maxDepth: 0 }, new RangeError('`maxDepth` must be a positive integer or Infinity')],
[{ maxDepth: 1.5 }, new RangeError('`maxDepth` must be a positive integer or Infinity')],
[{ circular: 'ignore' as 'skip' }, new TypeError("`circular` must be 'throw' or 'skip'")],
[{ notation: 'slash' as 'dot' }, new TypeError("`notation` must be 'dot', 'bracket' or 'pointer'")],
])('Should reject invalid options %j', (options, error) => {
expect(() => flatten({ a: 1 }, options)).toThrow(error);
});
Expand Down Expand Up @@ -255,11 +271,20 @@ describe('Flatten type', () => {
expectTypeOf(flatten(input, { circular: 'skip' })).toEqualTypeOf<Flatten<typeof input>>();
});

test('Should follow the notation', () => {
type Input = { items: { id: number }[]; point: [number, number]; 'a/b': { 'c.d': 1 } };
expectTypeOf<Flatten<Input, { notation: 'bracket' }>>().toEqualTypeOf<{ [x: `items[${number}].id`]: number; items?: []; 'point[0]': number; 'point[1]': number; 'a/b.c\\.d': 1 }>();
expectTypeOf<Flatten<Input, { notation: 'pointer' }>>().toEqualTypeOf<{ [x: `/items/${number}/id`]: number; '/items'?: []; '/point/0': number; '/point/1': number; '/a~1b/c.d': 1 }>();
expectTypeOf<Flatten<{ id: number }[], { notation: 'bracket' }>>().toEqualTypeOf<{ [x: `[${number}].id`]: number }>();
expectTypeOf<Flatten<{ data: object }, { notation: 'pointer' }>>().toEqualTypeOf<{ [x: `/data/${string}`]: unknown; '/data'?: unknown }>();
});

test('Should widen the keys to string when an option is only known at runtime', () => {
const options = { delimiter: '.' } as { delimiter: string };
expectTypeOf(flatten({ a: { b: 1 } }, options)).toEqualTypeOf<Record<string, unknown>>();
expectTypeOf<Flatten<{ a: 1 }, { safe: boolean }>>().toEqualTypeOf<Record<string, unknown>>();
expectTypeOf<Flatten<{ a: 1 }, { maxDepth: number }>>().toEqualTypeOf<Record<string, unknown>>();
expectTypeOf<Flatten<{ a: 1 }, { notation: 'dot' | 'pointer' }>>().toEqualTypeOf<Record<string, unknown>>();
expectTypeOf(flatten({ a: 1 }, { preserve: () => false })).toEqualTypeOf<Record<string, unknown>>();
});

Expand Down
24 changes: 15 additions & 9 deletions src/flatten/flatten.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assign, isContainer } from '../shared/guards.ts';
import { escapeKey } from '../shared/path.ts';
import { pathFormat, validateNotation } from '../shared/notation.ts';
import { validate } from '../shared/validate.ts';
import type { Flatten, FlattenOptions } from './types.ts';

interface Frame {
Expand All @@ -11,9 +12,7 @@ interface Frame {
depth: number;
}

function validate(delimiter: string, maxDepth: number, escape: boolean, circular: string): void {
if (typeof delimiter !== 'string' || delimiter === '') throw new TypeError('`delimiter` must be a non-empty string');
if (escape && delimiter.includes('\\')) throw new RangeError('`delimiter` cannot contain `\\` while `escape` is enabled: it is the escape character');
function validateWalk(maxDepth: number, circular: string): void {
if (maxDepth !== Infinity && (!Number.isInteger(maxDepth) || maxDepth < 1)) throw new RangeError('`maxDepth` must be a positive integer or Infinity');
if (circular !== 'throw' && circular !== 'skip') throw new TypeError("`circular` must be 'throw' or 'skip'");
}
Expand All @@ -33,6 +32,12 @@ function validate(delimiter: string, maxDepth: number, escape: boolean, circular
*
* flatten({ user: { name: 'Ada' } }, { delimiter: '_' });
* // { user_name: 'Ada' }
*
* flatten({ items: [{ id: 1 }] }, { notation: 'bracket' });
* // { 'items[0].id': 1 }
*
* flatten({ items: [{ id: 1 }] }, { notation: 'pointer' });
* // { '/items/0/id': 1 }
* ```
*
* @param input The plain object or array to flatten.
Expand All @@ -43,8 +48,11 @@ function validate(delimiter: string, maxDepth: number, escape: boolean, circular
export function flatten<T extends object>(input: T): Flatten<T>;
export function flatten<T extends object, const O extends FlattenOptions>(input: T, options: O): Flatten<T, O>;
export function flatten(input: object, options: FlattenOptions = {}): Record<string, unknown> {
const { delimiter = '.', maxDepth = Infinity, safe = false, keepEmpty = true, escape = true, circular = 'throw', transformKey, preserve } = options;
validate(delimiter, maxDepth, escape, circular);
const { delimiter = '.', notation = 'dot', maxDepth = Infinity, safe = false, keepEmpty = true, escape = true, circular = 'throw', transformKey, preserve } = options;
validateNotation(notation);
if (notation !== 'pointer') validate(delimiter, escape);
validateWalk(maxDepth, circular);
const format = pathFormat(notation, delimiter, escape);
if (!isContainer(input, safe)) throw new TypeError('flatten() expects a plain object or an array');

const result: Record<string, unknown> = {};
Expand All @@ -62,9 +70,7 @@ export function flatten(input: object, options: FlattenOptions = {}): Record<str

const key = frame.keys[frame.index++];
const value = frame.value[key];
let segment = transformKey && !frame.isArray ? transformKey(key) : key;
if (escape) segment = escapeKey(segment, delimiter);
const path = frame.path === undefined ? segment : frame.path + delimiter + segment;
const path = format.join(frame.path, transformKey && !frame.isArray ? transformKey(key) : key, frame.isArray);

if (isContainer(value, safe) && frame.depth < maxDepth && !(preserve && preserve(path, value))) {
if (ancestors.has(value)) {
Expand Down
48 changes: 33 additions & 15 deletions src/flatten/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { IsAny, Leaf, MergeVariants, Option, Prettify, ReplaceAll, UnionToIntersection } from '../shared/types.ts';
import type { Notation } from '../shared/notation.ts';
import type { IsAny, IsUnion, Leaf, MergeVariants, Option, Prettify, ReplaceAll, UnionToIntersection } from '../shared/types.ts';

export interface FlattenOptions {
/** Joins the keys of a path. Default: `'.'`. */
delimiter?: string;
/** `'dot'` (`items.0.id`), `'bracket'` (`items[0].id`) or `'pointer'` (`/items/0/id`, RFC 6901). Default: `'dot'`. */
notation?: Notation;
/** Levels to walk into; deeper objects are kept as values. `1` keeps the top-level keys only. Default: `Infinity`. */
maxDepth?: number;
/** Keeps arrays as values instead of flattening them to index keys. Default: `false`. */
Expand All @@ -24,6 +27,7 @@ type TypeDepthLimit = 10;

interface Context {
delimiter: string;
notation: Notation;
maxDepth: number | undefined;
safe: boolean;
keepEmpty: boolean;
Expand All @@ -35,15 +39,28 @@ type Kind<V, C extends Context> = V extends Leaf ? 'leaf' : V extends readonly u
type LeavesOf<V, C extends Context> = V extends unknown ? (Kind<V, C> extends 'leaf' ? V : never) : never;
type ContainersOf<V, C extends Context> = V extends unknown ? (Kind<V, C> extends 'leaf' ? never : V) : never;

type Segment<K, C extends Context> = K extends number ? `${K}` : K extends string ? (C['escape'] extends true ? EscapeKey<K, C['delimiter']> : K) : never;

/** Mirrors `escapeKey`: see `src/shared/path.ts`. */
type EscapeKey<K extends string, D extends string> = NeedsEscape<K, D> extends true ? (D extends `${infer First}${string}` ? ReplaceAll<ReplaceAll<K, '\\', '\\\\'>, First, `\\${First}`> : K) : K;
type NeedsEscape<K extends string, D extends string> = K extends `${string}\\${string}` | `${string}${D}${string}` ? true : [Overlaps<D>] extends [never] ? false : K extends `${string}${Overlaps<D>}` ? true : false;
/** Mirrors `escapeKey`: see `src/shared/path.ts`. `X` is `'['` in bracket notation. */
type EscapeKey<K extends string, D extends string, X extends string> = NeedsEscape<K, D, X> extends true
? D extends `${infer First}${string}`
? ReplaceAll<ReplaceAll<ReplaceAll<K, '\\', '\\\\'>, First, `\\${First}`>, X, `\\${X}`>
: K
: K;
type NeedsEscape<K extends string, D extends string, X extends string> = K extends `${string}\\${string}` | `${string}${D}${string}` | `${string}${X}${string}` ? true : [Overlaps<D>] extends [never] ? false : K extends `${string}${Overlaps<D>}` ? true : false;
/** Proper prefixes of the delimiter that overlap with it: `':'` for `'::'`, as `':' + '::'` starts with `'::'`. */
type Overlaps<D extends string> = Prefixes<D> extends infer P ? (P extends string ? (`${P}${D}` extends `${D}${string}` ? P : never) : never) : never;
type Prefixes<D extends string, Acc extends string = ''> = D extends `${infer Char}${infer Rest}` ? (Rest extends '' ? never : `${Acc}${Char}` | Prefixes<Rest, `${Acc}${Char}`>) : never;
type Join<P extends string, K extends string, D extends unknown[], C extends Context> = D extends [] ? K : `${P}${C['delimiter']}${K}`;
/** Mirrors `pathFormat().join`: appends key `K` to path `P` (the top level is at depth `[]`). */
type Join<P extends string, K extends string | number, IsIndex extends boolean, D extends unknown[], C extends Context> = C['notation'] extends 'pointer'
? `${P}/${K extends string ? ReplaceAll<ReplaceAll<K, '~', '~0'>, '/', '~1'> : K}`
: C['notation'] extends 'bracket'
? IsIndex extends true
? `${P}[${K}]`
: JoinKey<P, K extends string ? (C['escape'] extends true ? EscapeKey<K, C['delimiter'], '['> : K) : `${K}`, D, C>
: JoinKey<P, K extends string ? (C['escape'] extends true ? EscapeKey<K, C['delimiter'], never> : K) : `${K}`, D, C>;
type JoinKey<P extends string, K extends string, D extends unknown[], C extends Context> = D extends [] ? K : `${P}${C['delimiter']}${K}`;

/** Any path below `K`, for values whose shape is unknown. */
type Below<K extends string, C extends Context> = C['notation'] extends 'pointer' ? `${K}/${string}` : C['notation'] extends 'bracket' ? `${K}${C['delimiter']}${string}` | `${K}[${string}` : `${K}${C['delimiter']}${string}`;

/** `?` is skipped for template keys (`` `tags.${number}.name` ``): they are index signatures, where it would only add `undefined`. */
type Field<K extends string, V, Optional extends boolean> = [V] extends [never] ? {} : Optional extends true ? ({} extends Record<K, 1> ? { [_ in K]: V } : { [_ in K]?: V }) : { [_ in K]: V };
Expand All @@ -60,7 +77,7 @@ type EmptyField<V, K extends string, C extends Context> = C['keepEmpty'] extends

type Nested<V, K extends string, D extends unknown[], C extends Context> = V extends unknown
? Kind<V, C> extends 'unknown'
? { [_ in K | `${K}${C['delimiter']}${string}`]?: unknown }
? { [_ in K | Below<K, C>]?: unknown }
: Prettify<FlatContainer<V, K, [...D, unknown], C> & EmptyField<V, K, C>>
: never;

Expand All @@ -71,7 +88,7 @@ type Property<V, K extends string, Optional extends boolean, D extends unknown[]
: CanDescend<D, C> extends false
? Field<K, V, Optional>
: D['length'] extends TypeDepthLimit
? { [_ in K | `${K}${C['delimiter']}${string}`]?: unknown }
? { [_ in K | Below<K, C>]?: unknown }
: [ContainersOf<V, C>] extends [never]
? Field<K, LeavesOf<V, C>, Optional>
: Mixed<Exclude<LeavesOf<V, C>, Optional extends true ? undefined : never>, NestedMerged<ContainersOf<V, C>, K, D, C>, K, Optional>;
Expand All @@ -88,8 +105,8 @@ type NestedMerged<V, K extends string, D extends unknown[], C extends Context> =
*/
type Mixed<L, N, K extends string, Optional extends boolean> = Field<K, L, true> & ([L] extends [never] ? (Optional extends true ? Partial<N> : N) : Partial<N>);

type ObjectEntries<T, P extends string, D extends unknown[], C extends Context> = UnionToIntersection<
{ [K in keyof T]-?: K extends string | number ? Property<T[K], Join<P, Segment<K, C>, D, C>, IsOptional<T, K>, D, C> : {} }[keyof T]
type ObjectEntries<T, P extends string, IsArray extends boolean, D extends unknown[], C extends Context> = UnionToIntersection<
{ [K in keyof T]-?: K extends string | number ? Property<T[K], Join<P, K, IsArray, D, C>, IsOptional<T, K>, D, C> : {} }[keyof T]
>;

/** Index signatures (`[key: string]: V`) are left required: `?` would add `undefined` to their values. */
Expand All @@ -99,11 +116,11 @@ type TupleEntries<T extends readonly unknown[]> = { [K in keyof T as K extends `

type FlatContainer<T, P extends string, D extends unknown[], C extends Context> = T extends readonly unknown[]
? number extends T['length']
? Property<T[number], Join<P, `${number}`, D, C>, false, D, C>
: ObjectEntries<TupleEntries<T>, P, D, C>
: ObjectEntries<T, P, D, C>;
? Property<T[number], Join<P, `${number}`, true, D, C>, false, D, C>
: ObjectEntries<TupleEntries<T>, P, true, D, C>
: ObjectEntries<T, P, false, D, C>;

type IsWide<C extends Context> = string extends C['delimiter'] ? true : number extends C['maxDepth'] ? true : boolean extends C['safe'] ? true : boolean extends C['keepEmpty'] ? true : boolean extends C['escape'] ? true : false;
type IsWide<C extends Context> = string extends C['delimiter'] ? true : IsUnion<C['notation']> extends true ? true : number extends C['maxDepth'] ? true : boolean extends C['safe'] ? true : boolean extends C['keepEmpty'] ? true : boolean extends C['escape'] ? true : false;

/**
* The object returned by `flatten(T, O)`: one key per path, typed with the value found there.
Expand All @@ -123,6 +140,7 @@ export type Flatten<T, O extends FlattenOptions = {}> =
: [Option<O, 'transformKey', never> | Option<O, 'preserve', never>] extends [never]
? FlattenWith<T, {
delimiter: Option<O, 'delimiter', '.'>;
notation: Option<O, 'notation', 'dot'>;
maxDepth: Option<O, 'maxDepth', undefined>;
safe: Option<O, 'safe', false>;
keepEmpty: Option<O, 'keepEmpty', true>;
Expand Down
24 changes: 23 additions & 1 deletion src/roundtrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const hasIndexKeysOnly = (value: unknown): boolean =>
const someNested = (value: unknown, predicate: (value: unknown) => boolean): boolean =>
predicate(value) || (typeof value === 'object' && value !== null && Object.values(value).some((child) => someNested(child, predicate)));

const key = fc.oneof(fc.string(), fc.stringMatching(/^[ab_:.\\]{0,4}$/)).filter((k) => k !== '__proto__');
const key = fc.oneof(fc.string(), fc.stringMatching(/^[ab_:.\\[\]/~01]{0,4}$/)).filter((k) => k !== '__proto__');
const json = fc.letrec((tie) => ({
value: fc.oneof({ depthSize: 'small' }, fc.string(), fc.integer(), fc.double({ noNaN: true }), fc.boolean(), fc.constant(null), tie('array'), tie('object')),
array: fc.array(tie('value'), { maxLength: 4 }),
Expand All @@ -35,6 +35,22 @@ describe('unflatten(flatten(x))', () => {
);
});

test.each([
['bracket', '.'],
['bracket', '__'],
['bracket', '::'],
['pointer', '.'],
] as const)('Should give back any JSON object with the %s notation and the %j delimiter', (notation, delimiter) => {
// In bracket notation, a top-level empty key holding an array (`''` + `[0]`) reads back as the array itself.
const input = notation === 'bracket' ? json.filter((value) => !Array.isArray(value[''])) : json;
fc.assert(
fc.property(input, (value) => {
expect(unflatten(flatten(value, { notation, delimiter }), { notation, delimiter })).toEqual(value);
}),
{ numRuns: 2000 }
);
});

test('Should give back any array of JSON objects with asArray', () => {
fc.assert(
fc.property(fc.array(json.filter((row) => !hasIndexKeysOnly(row)), { maxLength: 5 }), (rows) => {
Expand Down Expand Up @@ -92,6 +108,12 @@ describe('Unflatten<Flatten<T>>', () => {
expectTypeOf<Unflatten<Flatten<Row[]>, { asArray: true }>>().toEqualTypeOf<Row[]>();
});

test('Should give back plain data types with every notation', () => {
type Order = { id: number; items: { sku: string; qty: number }[]; 'a/b.c': { 'd[e~f': 1 } };
expectTypeOf<Unflatten<Flatten<Order, { notation: 'bracket' }>, { notation: 'bracket' }>>().toEqualTypeOf<Order>();
expectTypeOf<Unflatten<Flatten<Order, { notation: 'pointer' }>, { notation: 'pointer' }>>().toEqualTypeOf<Order>();
});

test('Should follow the delimiter on both sides', () => {
type Config = { db: { host: string; port: number } };
expectTypeOf<Unflatten<Flatten<Config, { delimiter: '__' }>, { delimiter: '__' }>>().toEqualTypeOf<Config>();
Expand Down
37 changes: 37 additions & 0 deletions src/shared/notation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { escapeKey, escapePointer, splitBracketPath, splitPath, splitPointer } from './path.ts';

/**
* How paths are written:
* - `'dot'`: `items.0.id`
* - `'bracket'`: `items[0].id`, array indices between brackets
* - `'pointer'`: `/items/0/id`, a JSON Pointer (RFC 6901); `delimiter` and `escape` do not apply
*/
export type Notation = 'dot' | 'bracket' | 'pointer';

export interface PathFormat {
/** Appends `key` to `path` (`undefined` at the top level). `isIndex` is `true` for array elements. */
join(path: string | undefined, key: string, isIndex: boolean): string;
split(path: string): string[];
}

export function validateNotation(notation: string): void {
if (notation !== 'dot' && notation !== 'bracket' && notation !== 'pointer') throw new TypeError("`notation` must be 'dot', 'bracket' or 'pointer'");
}

export function pathFormat(notation: Notation, delimiter: string, escape: boolean): PathFormat {
if (notation === 'pointer') {
return {
join: (path, key) => `${path ?? ''}/${escapePointer(key)}`,
split: splitPointer,
};
}
const extra = notation === 'bracket' ? '[' : '';
return {
join: (path, key, isIndex) => {
if (isIndex && notation === 'bracket') return `${path ?? ''}[${key}]`;
const segment = escape ? escapeKey(key, delimiter, extra) : key;
return path === undefined ? segment : path + delimiter + segment;
},
split: notation === 'bracket' ? (path) => splitBracketPath(path, delimiter, escape) : (path) => splitPath(path, delimiter, escape),
};
}
Loading
Loading