diff --git a/.changeset/add-notations.md b/.changeset/add-notations.md new file mode 100644 index 0000000..fe983bb --- /dev/null +++ b/.changeset/add-notations.md @@ -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 diff --git a/src/flatten/flatten.spec.ts b/src/flatten/flatten.spec.ts index 5f8f3ec..ee9ddda 100644 --- a/src/flatten/flatten.spec.ts +++ b/src/flatten/flatten.spec.ts @@ -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 }); }); @@ -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); }); @@ -255,11 +271,20 @@ describe('Flatten type', () => { expectTypeOf(flatten(input, { circular: 'skip' })).toEqualTypeOf>(); }); + test('Should follow the notation', () => { + type Input = { items: { id: number }[]; point: [number, number]; 'a/b': { 'c.d': 1 } }; + expectTypeOf>().toEqualTypeOf<{ [x: `items[${number}].id`]: number; items?: []; 'point[0]': number; 'point[1]': number; 'a/b.c\\.d': 1 }>(); + expectTypeOf>().toEqualTypeOf<{ [x: `/items/${number}/id`]: number; '/items'?: []; '/point/0': number; '/point/1': number; '/a~1b/c.d': 1 }>(); + expectTypeOf>().toEqualTypeOf<{ [x: `[${number}].id`]: number }>(); + expectTypeOf>().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>(); expectTypeOf>().toEqualTypeOf>(); expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); expectTypeOf(flatten({ a: 1 }, { preserve: () => false })).toEqualTypeOf>(); }); diff --git a/src/flatten/flatten.ts b/src/flatten/flatten.ts index fb91106..e36646f 100644 --- a/src/flatten/flatten.ts +++ b/src/flatten/flatten.ts @@ -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 { @@ -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'"); } @@ -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. @@ -43,8 +48,11 @@ function validate(delimiter: string, maxDepth: number, escape: boolean, circular export function flatten(input: T): Flatten; export function flatten(input: T, options: O): Flatten; export function flatten(input: object, options: FlattenOptions = {}): Record { - 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 = {}; @@ -62,9 +70,7 @@ export function flatten(input: object, options: FlattenOptions = {}): Record = V extends Leaf ? 'leaf' : V extends readonly u type LeavesOf = V extends unknown ? (Kind extends 'leaf' ? V : never) : never; type ContainersOf = V extends unknown ? (Kind extends 'leaf' ? never : V) : never; -type Segment = K extends number ? `${K}` : K extends string ? (C['escape'] extends true ? EscapeKey : K) : never; - -/** Mirrors `escapeKey`: see `src/shared/path.ts`. */ -type EscapeKey = NeedsEscape extends true ? (D extends `${infer First}${string}` ? ReplaceAll, First, `\\${First}`> : K) : K; -type NeedsEscape = K extends `${string}\\${string}` | `${string}${D}${string}` ? true : [Overlaps] extends [never] ? false : K extends `${string}${Overlaps}` ? true : false; +/** Mirrors `escapeKey`: see `src/shared/path.ts`. `X` is `'['` in bracket notation. */ +type EscapeKey = NeedsEscape extends true + ? D extends `${infer First}${string}` + ? ReplaceAll, First, `\\${First}`>, X, `\\${X}`> + : K + : K; +type NeedsEscape = K extends `${string}\\${string}` | `${string}${D}${string}` | `${string}${X}${string}` ? true : [Overlaps] extends [never] ? false : K extends `${string}${Overlaps}` ? true : false; /** Proper prefixes of the delimiter that overlap with it: `':'` for `'::'`, as `':' + '::'` starts with `'::'`. */ type Overlaps = Prefixes extends infer P ? (P extends string ? (`${P}${D}` extends `${D}${string}` ? P : never) : never) : never; type Prefixes = D extends `${infer Char}${infer Rest}` ? (Rest extends '' ? never : `${Acc}${Char}` | Prefixes) : never; -type Join

= D extends [] ? K : `${P}${C['delimiter']}${K}`; +/** Mirrors `pathFormat().join`: appends key `K` to path `P` (the top level is at depth `[]`). */ +type Join

= C['notation'] extends 'pointer' + ? `${P}/${K extends string ? ReplaceAll, '/', '~1'> : K}` + : C['notation'] extends 'bracket' + ? IsIndex extends true + ? `${P}[${K}]` + : JoinKey : K) : `${K}`, D, C> + : JoinKey : K) : `${K}`, D, C>; +type JoinKey

= D extends [] ? K : `${P}${C['delimiter']}${K}`; + +/** Any path below `K`, for values whose shape is unknown. */ +type Below = 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 = [V] extends [never] ? {} : Optional extends true ? ({} extends Record ? { [_ in K]: V } : { [_ in K]?: V }) : { [_ in K]: V }; @@ -60,7 +77,7 @@ type EmptyField = C['keepEmpty'] extends type Nested = V extends unknown ? Kind extends 'unknown' - ? { [_ in K | `${K}${C['delimiter']}${string}`]?: unknown } + ? { [_ in K | Below]?: unknown } : Prettify & EmptyField> : never; @@ -71,7 +88,7 @@ type Property extends false ? Field : D['length'] extends TypeDepthLimit - ? { [_ in K | `${K}${C['delimiter']}${string}`]?: unknown } + ? { [_ in K | Below]?: unknown } : [ContainersOf] extends [never] ? Field, Optional> : Mixed, Optional extends true ? undefined : never>, NestedMerged, K, D, C>, K, Optional>; @@ -88,8 +105,8 @@ type NestedMerged = */ type Mixed = Field & ([L] extends [never] ? (Optional extends true ? Partial : N) : Partial); -type ObjectEntries = UnionToIntersection< - { [K in keyof T]-?: K extends string | number ? Property, D, C>, IsOptional, D, C> : {} }[keyof T] +type ObjectEntries = UnionToIntersection< + { [K in keyof T]-?: K extends string | number ? Property, IsOptional, D, C> : {} }[keyof T] >; /** Index signatures (`[key: string]: V`) are left required: `?` would add `undefined` to their values. */ @@ -99,11 +116,11 @@ type TupleEntries = { [K in keyof T as K extends ` type FlatContainer = T extends readonly unknown[] ? number extends T['length'] - ? Property, false, D, C> - : ObjectEntries, P, D, C> - : ObjectEntries; + ? Property, false, D, C> + : ObjectEntries, P, true, D, C> + : ObjectEntries; -type IsWide = 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 = string extends C['delimiter'] ? true : IsUnion 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. @@ -123,6 +140,7 @@ export type Flatten = : [Option | Option] extends [never] ? FlattenWith; + notation: Option; maxDepth: Option; safe: Option; keepEmpty: Option; diff --git a/src/roundtrip.spec.ts b/src/roundtrip.spec.ts index bc423ae..5a63842 100644 --- a/src/roundtrip.spec.ts +++ b/src/roundtrip.spec.ts @@ -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 }), @@ -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) => { @@ -92,6 +108,12 @@ describe('Unflatten>', () => { expectTypeOf, { asArray: true }>>().toEqualTypeOf(); }); + 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, { notation: 'bracket' }>>().toEqualTypeOf(); + expectTypeOf, { notation: 'pointer' }>>().toEqualTypeOf(); + }); + test('Should follow the delimiter on both sides', () => { type Config = { db: { host: string; port: number } }; expectTypeOf, { delimiter: '__' }>>().toEqualTypeOf(); diff --git a/src/shared/notation.ts b/src/shared/notation.ts new file mode 100644 index 0000000..5c1f18b --- /dev/null +++ b/src/shared/notation.ts @@ -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), + }; +} diff --git a/src/shared/path.spec.ts b/src/shared/path.spec.ts index e256d35..25350ef 100644 --- a/src/shared/path.spec.ts +++ b/src/shared/path.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { escapeKey, isIndex, splitPath } from './path.ts'; +import { escapeKey, escapePointer, isIndex, splitBracketPath, splitPath, splitPointer } from './path.ts'; describe('escapeKey', () => { test.each([ @@ -69,3 +69,68 @@ describe('isIndex', () => { expect(isIndex(segment, 1000)).toBe(expected); }); }); + +describe('splitBracketPath', () => { + test.each([ + ['a.b[0].c', ['a', 'b', '0', 'c']], + ['a[b][0][c]', ['a', 'b', '0', 'c']], + ['[0].id', ['0', 'id']], + ['[0][1]', ['0', '1']], + ['a[0]', ['a', '0']], + ['a.[0]', ['a', '', '0']], + ['a[0]b', ['a', '0', 'b']], + ['a[]', ['a', '']], + ['a', ['a']], + ['', ['']], + ['a.', ['a', '']], + ['a[0', ['a[0']], + ['a]b', ['a]b']], + ['a\\[0]', ['a[0]']], + ['a\\.b[0]', ['a.b', '0']], + ['a[x\\]y]', ['a', 'x]y']], + ['a[x\\]', ['a[x]']], + ])('Should split %j', (path, expected) => { + expect(splitBracketPath(path, '.', true)).toEqual(expected); + }); + + test('Should use any delimiter', () => { + expect(splitBracketPath('a::b[0]::c', '::', true)).toEqual(['a', 'b', '0', 'c']); + }); + + test('Should read `\\` as a character when escape is off', () => { + expect(splitBracketPath('a\\[0]', '.', false)).toEqual(['a\\', '0']); + expect(splitBracketPath('a[x\\]', '.', false)).toEqual(['a', 'x\\']); + }); + + test('Should stay linear on hostile input', () => { + const path = `${'['.repeat(200_000)}]`; + const start = performance.now(); + splitBracketPath(path, '.', true); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('JSON Pointer', () => { + test.each([ + ['a/b', 'a~1b'], + ['m~n', 'm~0n'], + ['~1', '~01'], + ['plain', 'plain'], + ])('Should escape %j as %j', (key, expected) => { + expect(escapePointer(key)).toBe(expected); + }); + + test.each([ + ['/a/0/b', ['a', '0', 'b']], + ['/a~1b/m~0n', ['a/b', 'm~n']], + ['/~01', ['~1']], + ['/', ['']], + ['//', ['', '']], + ])('Should split %j', (pointer, expected) => { + expect(splitPointer(pointer)).toEqual(expected); + }); + + test.each(['', 'a/b', '#/a'])('Should reject %j, which does not start with "/"', (pointer) => { + expect(() => splitPointer(pointer)).toThrow(new TypeError(`A JSON Pointer must start with "/", got "${pointer}"`)); + }); +}); diff --git a/src/shared/path.ts b/src/shared/path.ts index 9522021..f566e3f 100644 --- a/src/shared/path.ts +++ b/src/shared/path.ts @@ -1,9 +1,11 @@ /** - * `true` when joining `key` with the delimiter could be split back differently: the key contains `\` or - * the delimiter, or ends like the start of the delimiter so that the two overlap (`'a:' + '::'` → `'a:::'`). + * `true` when joining `key` with the delimiter could be split back differently: the key contains `\`, + * the delimiter or one of the `extra` characters, or ends like the start of the delimiter so that the two + * overlap (`'a:' + '::'` → `'a:::'`). */ -function needsEscape(key: string, delimiter: string): boolean { +function needsEscape(key: string, delimiter: string, extra: string): boolean { if (key.includes('\\') || key.includes(delimiter)) return true; + for (const char of extra) if (key.includes(char)) return true; for (let length = 1; length < delimiter.length; length++) { const tail = delimiter.slice(0, length); if (key.endsWith(tail) && (tail + delimiter).startsWith(delimiter)) return true; @@ -13,13 +15,14 @@ function needsEscape(key: string, delimiter: string): boolean { /** * Escapes a single key so the path can be split back. Keys that are already unambiguous are returned as - * they are; the others get `\` before every `\` and every first character of the delimiter. + * they are; the others get `\` before every `\`, every first character of the delimiter and every `extra` + * character (`[` in bracket notation). */ -export function escapeKey(key: string, delimiter: string): string { - if (!needsEscape(key, delimiter)) return key; - const first = delimiter[0]; +export function escapeKey(key: string, delimiter: string, extra = ''): string { + if (!needsEscape(key, delimiter, extra)) return key; + const special = `\\${delimiter[0]}${extra}`; let escaped = ''; - for (const char of key) escaped += char === '\\' || char === first ? `\\${char}` : char; + for (const char of key) escaped += special.includes(char) ? `\\${char}` : char; return escaped; } @@ -50,3 +53,82 @@ export function splitPath(path: string, delimiter: string, escape: boolean): str export function isIndex(segment: string, limit: number): boolean { return /^(?:0|[1-9]\d*)$/.test(segment) && Number(segment) <= limit; } + +/** + * Splits a bracket-notation path: `a.b[0].c` and `a[b][0][c]` (the form-field style) both give + * `['a', 'b', '0', 'c']`. A `[` without a closing `]` is part of the key. + */ +export function splitBracketPath(path: string, delimiter: string, escape: boolean): string[] { + const segments: string[] = []; + let current = ''; + // A key is started by the beginning of the path or a delimiter, so `a.[0]` has an empty key before `[0]`. + let started = true; + // Looked up once: searching for `]` at every `[` would be quadratic on hostile input like `a[[[[...`. + const lastClose = lastUnescapedClose(path, escape); + let i = 0; + while (i < path.length) { + const char = path[i]; + if (escape && char === '\\' && i + 1 < path.length) { + current += path[i + 1]; + started = true; + i += 2; + } else if (char === '[' && i < lastClose) { + const close = findClose(path, i + 1, escape); + if (current !== '' || (started && i > 0)) segments.push(current); + segments.push(unescapeBracket(path.slice(i + 1, close), escape)); + current = ''; + started = false; + i = close + 1; + if (path.startsWith(delimiter, i)) { + started = true; + i += delimiter.length; + } + } else if (path.startsWith(delimiter, i)) { + segments.push(current); + current = ''; + started = true; + i += delimiter.length; + } else { + current += char; + started = true; + i++; + } + } + if (current !== '' || started) segments.push(current); + return segments; +} + +/** Called only before `lastUnescapedClose`, so a closing `]` is always found. */ +function findClose(path: string, from: number, escape: boolean): number { + let i = from; + while (path[i] !== ']') i += escape && path[i] === '\\' ? 2 : 1; + return i; +} + +function lastUnescapedClose(path: string, escape: boolean): number { + if (!escape) return path.lastIndexOf(']'); + let last = -1; + for (let i = 0; i < path.length; i++) { + if (path[i] === '\\') i++; + else if (path[i] === ']') last = i; + } + return last; +} + +function unescapeBracket(key: string, escape: boolean): string { + return escape && key.includes('\\') ? key.replace(/\\(.)/g, '$1') : key; +} + +/** RFC 6901: `~` is written `~0` and `/` is written `~1`. */ +export function escapePointer(key: string): string { + return key.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** Splits a JSON Pointer (`/a/0/b~1c` → `['a', '0', 'b/c']`). */ +export function splitPointer(pointer: string): string[] { + if (!pointer.startsWith('/')) throw new TypeError(`A JSON Pointer must start with "/", got "${pointer}"`); + return pointer + .slice(1) + .split('/') + .map((segment) => (segment.includes('~') ? segment.replace(/~1/g, '/').replace(/~0/g, '~') : segment)); +} diff --git a/src/shared/validate.ts b/src/shared/validate.ts new file mode 100644 index 0000000..49f04c6 --- /dev/null +++ b/src/shared/validate.ts @@ -0,0 +1,4 @@ +export function validate(delimiter: string, escape: boolean): 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'); +} diff --git a/src/unflatten/types.ts b/src/unflatten/types.ts index 85fdb50..1fb91c2 100644 --- a/src/unflatten/types.ts +++ b/src/unflatten/types.ts @@ -1,8 +1,14 @@ -import type { IsAny, Option, Prettify } from '../shared/types.ts'; +import type { Notation } from '../shared/notation.ts'; +import type { IsAny, IsUnion, Option, Prettify, ReplaceAll } from '../shared/types.ts'; export interface UnflattenOptions { /** Splits the keys into paths. Default: `'.'`. */ delimiter?: string; + /** + * `'dot'` (`items.0.id`), `'bracket'` (`items[0].id`, also `items[0][id]` as in form fields) or + * `'pointer'` (`/items/0/id`, RFC 6901: every key must start with `/`). Default: `'dot'`. + */ + notation?: Notation; /** Builds objects only: `{ 'list.0': 'a' }` becomes `{ list: { 0: 'a' } }` instead of `{ list: ['a'] }`. Default: `false`. */ object?: boolean; /** Lets a later key replace a value in its way (`{ a: 1, 'a.b': 2 }` → `{ a: { b: 2 } }`). Otherwise the later key is skipped. Default: `false`. */ @@ -26,6 +32,7 @@ export interface UnflattenOptions { interface Context { delimiter: string; + notation: Notation; escape: boolean; object: boolean; } @@ -39,14 +46,34 @@ type SplitEscaped = ? SplitEscaped : [`${Acc}${K}`, never]; -/** `[first key, rest of the path]`, or `[key, never]` for the last key of a path. */ -type Split = C['escape'] extends true - ? K extends `${infer Head}${C['delimiter']}${infer Rest}` - ? Head extends `${string}\\${string}` ? SplitEscaped : [Head, Rest] - : K extends `${string}\\${string}` ? SplitEscaped : [K, never] - : K extends `${infer Head}${C['delimiter']}${infer Rest}` - ? [Head, Rest] - : [K, never]; +/** `[first key, rest of the path]`, or `[key, never]` for the last key of a path. Mirrors `pathFormat().split`. */ +type Split = C['notation'] extends 'pointer' + ? SplitPointer + : C['notation'] extends 'bracket' + ? SplitBracket + : C['escape'] extends true + ? K extends `${infer Head}${C['delimiter']}${infer Rest}` + ? Head extends `${string}\\${string}` ? SplitEscaped : [Head, Rest] + : K extends `${string}\\${string}` ? SplitEscaped : [K, never] + : K extends `${infer Head}${C['delimiter']}${infer Rest}` + ? [Head, Rest] + : [K, never]; + +type Unpointer = ReplaceAll, '~0', '~'>; +type SplitPointer = K extends `/${infer Path}` ? (Path extends `${infer Head}/${infer Rest}` ? [Unpointer, `/${Rest}`] : [Unpointer, never]) : never; + +type SplitBracket = K extends `[${infer Inner}]${infer Rest}` + ? Acc extends '' ? [Inner, Rest extends '' ? never : Rest extends `${D}${infer After}` ? After : Rest] : [Acc, K] + : E extends true + ? K extends `\\${infer Char}${infer Rest}` + ? SplitBracket + : SplitBracketNext + : SplitBracketNext; +type SplitBracketNext = K extends `${D}${infer Rest}` + ? [Acc, Rest] + : K extends `${infer Char}${infer Rest}` + ? SplitBracket + : [`${Acc}${K}`, never]; type SplitKey = K extends string | number ? Split<`${K}`, C> : never; @@ -87,7 +114,7 @@ type ToArray = C['object'] extends true : never : T; -type IsWide = string extends C['delimiter'] ? true : boolean extends C['escape'] ? true : boolean extends C['object'] ? true : false; +type IsWide = string extends C['delimiter'] ? true : IsUnion extends true ? true : boolean extends C['escape'] ? true : boolean extends C['object'] ? true : false; /** * The object returned by `unflatten(T, O)`: the flat keys of `T` split into a nested object, with arrays @@ -106,7 +133,7 @@ export type Unflatten = Root< IsAny extends true ? Record : [Option] extends [never] - ? UnflattenWith; escape: Option; object: Option }> + ? UnflattenWith; notation: Option; escape: Option; object: Option }> : Record, Option >; @@ -118,7 +145,7 @@ type RootArray = [keyof R] extends [never] : string extends keyof R ? R[keyof R][] : [keyof R] extends [`${number}`] - ? ToArray + ? ToArray : never; type UnflattenWith = IsWide extends true ? Record : T extends unknown ? Build : never; diff --git a/src/unflatten/unflatten.spec.ts b/src/unflatten/unflatten.spec.ts index c130697..fad1c56 100644 --- a/src/unflatten/unflatten.spec.ts +++ b/src/unflatten/unflatten.spec.ts @@ -140,6 +140,26 @@ describe('unflatten options', () => { expect(unflatten({ DB__HOST: 'localhost', DB__PORT: '5432' }, { delimiter: '__' })).toEqual({ DB: { HOST: 'localhost', PORT: '5432' } }); }); + test('notation: Should read bracket paths, including form-field names', () => { + expect(unflatten({ 'items[0].id': 1, 'items[1].id': 2, 'a\\.b.c\\[d': 1 }, { notation: 'bracket' })).toEqual({ items: [{ id: 1 }, { id: 2 }], 'a.b': { 'c[d': 1 } }); + expect(unflatten({ 'user[name]': 'Ada', 'user[tags][0]': 'x', 'user[tags][1]': 'y' }, { notation: 'bracket' })).toEqual({ user: { name: 'Ada', tags: ['x', 'y'] } }); + expect(unflatten({ '[0].id': 1, '[1].id': 2 }, { notation: 'bracket', asArray: true })).toEqual([{ id: 1 }, { id: 2 }]); + }); + + test('notation: Should read JSON Pointers', () => { + expect(unflatten({ '/items/0/id': 1, '/a~1b/m~0n': 2, '/a~1b/': 3 }, { notation: 'pointer' })).toEqual({ items: [{ id: 1 }], 'a/b': { 'm~n': 2, '': 3 } }); + }); + + test('notation: Should reject keys that are not JSON Pointers', () => { + expect(() => unflatten({ 'a/b': 1 }, { notation: 'pointer' })).toThrow(new TypeError('A JSON Pointer must start with "/", got "a/b"')); + }); + + test('notation: Should not pollute Object.prototype with any notation', () => { + unflatten({ '__proto__[polluted]': true, 'constructor[prototype][polluted]': true }, { notation: 'bracket' }); + unflatten({ '/__proto__/polluted': true, '/constructor/prototype/polluted': true }, { notation: 'pointer' }); + expect(({} as Record).polluted).toBeUndefined(); + }); + test('object: Should never build arrays', () => { expect(unflatten({ 'list.0': 'a', 'list.1': 'b' }, { object: true })).toEqual({ list: { 0: 'a', 1: 'b' } }); }); @@ -194,6 +214,7 @@ describe('unflatten options', () => { [{ delimiter: 1 as unknown as string }, new TypeError('`delimiter` must be a non-empty string')], [{ delimiter: '\\' }, new RangeError('`delimiter` cannot contain `\\` while `escape` is enabled: it is the escape character')], [{ arrayLimit: -1 }, new RangeError('`arrayLimit` must be a non-negative integer or Infinity')], + [{ notation: 'slash' as 'dot' }, new TypeError("`notation` must be 'dot', 'bracket' or 'pointer'")], [{ arrayLimit: 1.5 }, new RangeError('`arrayLimit` must be a non-negative integer or Infinity')], ])('Should reject invalid options %j', (options, error) => { expect(() => unflatten({ a: 1 }, options)).toThrow(error); @@ -243,6 +264,12 @@ describe('Unflatten type', () => { expectTypeOf>().toEqualTypeOf<{ 0: 1 } | [1]>(); }); + test('Should follow the notation', () => { + expectTypeOf>().toEqualTypeOf<{ items: { id: number }[]; point: [1]; 'a.b': 2 }>(); + expectTypeOf>().toEqualTypeOf<{ user: { name: string; tags: [string] } }>(); + expectTypeOf>().toEqualTypeOf<{ items: { id: number }[]; 'a/b': { 'm~n': 1 } }>(); + }); + test('Should widen to Record when the keys are unknown', () => { expectTypeOf>>().toEqualTypeOf<{ [x: string]: unknown }>(); expectTypeOf>().toEqualTypeOf>(); diff --git a/src/unflatten/unflatten.ts b/src/unflatten/unflatten.ts index 41c5b50..c238d47 100644 --- a/src/unflatten/unflatten.ts +++ b/src/unflatten/unflatten.ts @@ -1,14 +1,14 @@ import { assign, isContainer, isPlainObject } from '../shared/guards.ts'; -import { isIndex, splitPath } from '../shared/path.ts'; +import { pathFormat, validateNotation } from '../shared/notation.ts'; +import { isIndex } from '../shared/path.ts'; +import { validate } from '../shared/validate.ts'; import type { Unflatten, UnflattenOptions } from './types.ts'; type Container = Record; const hasOwn = (target: object, key: string): boolean => Object.prototype.hasOwnProperty.call(target, key); -function validate(delimiter: string, escape: boolean, arrayLimit: number): 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 validateArrayLimit(arrayLimit: number): void { if (arrayLimit !== Infinity && (!Number.isInteger(arrayLimit) || arrayLimit < 0)) throw new RangeError('`arrayLimit` must be a non-negative integer or Infinity'); } @@ -68,6 +68,10 @@ function restoreArrays(root: Container, arrayLimit: number, owned: WeakSet(input: T): Unflatten; export function unflatten(input: T, options: O): Unflatten; export function unflatten(input: object, options: UnflattenOptions = {}): Record | unknown[] { - const { delimiter = '.', object = false, overwrite = false, escape = true, arrayLimit = 1000, asArray = false, transformKey } = options; - validate(delimiter, escape, arrayLimit); + const { delimiter = '.', notation = 'dot', object = false, overwrite = false, escape = true, arrayLimit = 1000, asArray = false, transformKey } = options; + validateNotation(notation); + if (notation !== 'pointer') validate(delimiter, escape); + validateArrayLimit(arrayLimit); + const { split } = pathFormat(notation, delimiter, escape); if (!isPlainObject(input)) throw new TypeError('unflatten() expects a plain object'); const result: Container = {}; @@ -90,7 +97,7 @@ export function unflatten(input: object, options: UnflattenOptions = {}): Record const owned = new WeakSet([result]); entries: for (const key of Object.keys(input)) { - const segments = splitPath(key, delimiter, escape); + const segments = split(key); if (segments.includes('__proto__')) continue; if (transformKey) { for (let i = 0; i < segments.length; i++) if (!isIndex(segments[i], Infinity)) segments[i] = transformKey(segments[i]); diff --git a/test/typescript-5.0/index.ts b/test/typescript-5.0/index.ts index ada1ab2..c28da87 100644 --- a/test/typescript-5.0/index.ts +++ b/test/typescript-5.0/index.ts @@ -21,4 +21,9 @@ const tags: string[] = nested.tags; const rows = unflatten(flatten([{ id: 1 }, { id: 2 }]), { asArray: true }); const firstId: number = rows[0].id; -export { id, city, tag, underscored, nestedCity, tags, firstId }; +const pointer = flatten({ items: [{ id: 1 }] }, { notation: 'pointer' }); +const pointerId: number = pointer['/items/0/id']; +const form = unflatten({ 'user[name]': 'Ada' } as { 'user[name]': string }, { notation: 'bracket' }); +const formName: string = form.user.name; + +export { id, city, tag, underscored, nestedCity, tags, firstId, pointerId, formName }; diff --git a/test/workers/worker.js b/test/workers/worker.js index 11efda0..228ae2d 100644 --- a/test/workers/worker.js +++ b/test/workers/worker.js @@ -10,6 +10,7 @@ export default { ['keeps dates', flatten({ at: { d: new Date(0) } })['at.d'] instanceof Date], ['unflatten', same(unflatten({ 'a.b.0': 1, 'a.b.1.c': 2 }), { a: { b: [1, { c: 2 }] } })], ['unflatten asArray', same(unflatten(flatten([{ id: 1 }, { id: 2 }]), { asArray: true }), [{ id: 1 }, { id: 2 }])], + ['notations', same(flatten({ a: [{ b: 1 }] }, { notation: 'bracket' }), { 'a[0].b': 1 }) && same(unflatten({ '/a/0/b': 1 }, { notation: 'pointer' }), { a: [{ b: 1 }] })], ['unflatten is pollution-safe', (unflatten({ '__proto__.polluted': true, 'constructor.prototype.polluted': true }), {}.polluted === undefined)], ]; const failed = checks.filter(([, ok]) => !ok).map(([name]) => name);