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
23 changes: 23 additions & 0 deletions bench/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Inputs shared by the benchmarks: plain JSON-like data, as the libraries compared all support it. */

const user = (id: number) => ({
id,
name: `User ${id}`,
active: id % 2 === 0,
address: { street: `${id} Main St`, city: 'London', geo: { lat: 51.5, lng: -0.12 } },
tags: ['admin', 'dev'],
});

/** A small API record: 10 values, 3 levels. */
export const shallow = user(1);

/** One value 12 levels down. */
export const deep = Array.from({ length: 12 }).reduce<Record<string, unknown>>((child, _, i) => ({ [`level${i}`]: child }), { value: 1 });

/** 1,000 keys of 2 levels, like a big configuration or translation file. */
export const wide = Object.fromEntries(Array.from({ length: 1000 }, (_, i) => [`section${i}`, { title: `Title ${i}`, enabled: true }]));

/** 100 records in an array, like an API list response. */
export const list = { users: Array.from({ length: 100 }, (_, i) => user(i)) };

export const inputs = { shallow, deep, wide, list } as const;
21 changes: 21 additions & 0 deletions bench/flatten.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { flattenObject } from 'es-toolkit';
import { flatten as flat } from 'flat';
import { flattie } from 'flattie';
import { crush } from 'radashi';
import { describe, test } from 'vitest';
import { flatten } from '../dist/index.js';
import { inputs } from './fixtures.ts';

describe('flatten', () => {
for (const [name, input] of Object.entries(inputs)) {
test(name, async ({ bench }) => {
await bench.compare(
bench('flattify', () => void flatten(input)),
bench('flat', () => void flat(input)),
bench('flattie', () => void flattie(input)),
bench('es-toolkit', () => void flattenObject(input)),
bench('radashi', () => void crush(input))
);
});
}
});
21 changes: 21 additions & 0 deletions bench/unflatten.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { unflatten as unflat } from 'flat';
import { nestie } from 'nestie';
import { construct } from 'radashi';
import { describe, test } from 'vitest';
import { flatten, unflatten } from '../dist/index.js';
import { inputs } from './fixtures.ts';

// es-toolkit has no unflatten. Every library reads the same dot paths, produced without escaping.
describe('unflatten', () => {
for (const [name, input] of Object.entries(inputs)) {
const paths = flatten(input, { escape: false });
test(name, async ({ bench }) => {
await bench.compare(
bench('flattify', () => void unflatten(paths)),
bench('flat', () => void unflat(paths)),
bench('nestie', () => void nestie(paths)),
bench('radashi', () => void construct(paths))
);
});
}
});
8 changes: 8 additions & 0 deletions bench/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

// Benchmarks run in Node.js only, apart from the test projects of the root config.
export default defineConfig({
test: {
benchmark: { include: ['bench/**/*.bench.ts'] },
},
});
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"dev": "vitest --project node",
"test": "vitest run --project node",
"coverage": "vitest run --project node --coverage",
"bench": "pnpm run build && vitest bench --run --config bench/vitest.config.mts",
"build": "tsdown",
"lint": "tsc",
"ci": "pnpm run lint && pnpm run test && pnpm run build && pnpm run lint:package",
Expand All @@ -73,10 +74,15 @@
"@types/node": "^26.6.2",
"@vitest/browser-playwright": "^5.0.1",
"@vitest/coverage-istanbul": "^5.0.1",
"es-toolkit": "1.52.0",
"fast-check": "^4.10.2",
"flat": "6.0.1",
"flattie": "1.1.1",
"miniflare": "^4.20260730.0",
"nestie": "1.0.3",
"playwright": "^1.63.0",
"publint": "^0.3.24",
"radashi": "12.9.6",
"tsdown": "^0.23.0",
"typescript": "^7.0.2",
"typescript-5.0": "npm:typescript@5.0.4",
Expand Down
45 changes: 45 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion src/flatten/flatten.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, test } from 'vitest';
import { flatten, type Flatten } from '../index.ts';
import { flatten, unflatten, type Flatten } from '../index.ts';

describe('flatten', () => {
test('Should join nested keys with the delimiter', () => {
Expand Down Expand Up @@ -149,6 +149,22 @@ describe('flatten options', () => {
expect(flatten({ 'a.b': { c: 1 } }, { notation: 'pointer', delimiter: '', escape: false })).toEqual({ '/a.b/c': 1 });
});

test('escape: Should escape array indices when the delimiter holds a digit', () => {
const result = flatten({ a: ['x'], b: { c: 1 } }, { delimiter: '0' });
expect(result).toEqual({ 'a0\\0': 'x', b0c: 1 });
expect(unflatten(result, { delimiter: '0' })).toEqual({ a: ['x'], b: { c: 1 } });
});

test('keepEmpty: Should keep empty objects even when Object.prototype was polluted by other code', () => {
const proto = Object.prototype as Record<string, unknown>;
proto.polluted = true;
try {
expect(flatten({ a: {}, b: { c: 1 } })).toEqual({ a: {}, 'b.c': 1 });
} finally {
delete proto.polluted;
}
});

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
47 changes: 34 additions & 13 deletions src/flatten/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@ import type { Flatten, FlattenOptions } from './types.ts';

interface Frame {
value: Record<string, unknown>;
keys: string[];
/** Object keys to visit, or `undefined` for an array, walked by index. */
keys: string[] | undefined;
index: number;
isArray: boolean;
length: number;
path: string | undefined;
depth: number;
}

function frame(value: Record<string, unknown>, path: string | undefined, depth: number): Frame {
const keys = Array.isArray(value) ? undefined : Object.keys(value);
return { value, keys, index: 0, length: keys ? keys.length : (value as unknown as unknown[]).length, path, depth };
}

/** `true` for an object or array without any key, which `keepEmpty` keeps as a value. */
function isEmpty(value: Record<string, unknown>): boolean {
for (const key in value) if (Object.prototype.hasOwnProperty.call(value, key)) return false;
return true;
}

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 Down Expand Up @@ -58,32 +70,41 @@ export function flatten(input: object, options: FlattenOptions = {}): Record<str
const result: Record<string, unknown> = {};
// Containers on the current path, to tell a circular reference from the same object reused twice.
const ancestors = new Set<object>([input]);
const stack: Frame[] = [{ value: input, keys: Object.keys(input), index: 0, isArray: Array.isArray(input), path: undefined, depth: 1 }];
const stack: Frame[] = [frame(input, undefined, 1)];

while (stack.length > 0) {
const frame = stack[stack.length - 1];
if (frame.index === frame.keys.length) {
const current = stack[stack.length - 1];
if (current.index === current.length) {
stack.pop();
ancestors.delete(frame.value);
ancestors.delete(current.value);
continue;
}

const key = frame.keys[frame.index++];
const value = frame.value[key];
const path = format.join(frame.path, transformKey && !frame.isArray ? transformKey(key) : key, frame.isArray);
const { keys } = current;
const i = current.index++;
let path: string;
let value: unknown;
if (keys === undefined) {
if (!(i in current.value)) continue; // array hole
value = current.value[i];
path = format.join(current.path, String(i), true);
} else {
const key = keys[i];
value = current.value[key];
path = format.join(current.path, transformKey ? transformKey(key) : key, false);
}

if (isContainer(value, safe) && frame.depth < maxDepth && !(preserve && preserve(path, value))) {
if (typeof value === 'object' && value !== null && isContainer(value, safe) && current.depth < maxDepth && !(preserve && preserve(path, value))) {
if (ancestors.has(value)) {
if (circular === 'throw') throw new TypeError(`Circular reference at "${path}"`);
continue;
}
const keys = Object.keys(value);
if (keys.length === 0) {
if (isEmpty(value)) {
if (keepEmpty) assign(result, path, value);
continue;
}
ancestors.add(value);
stack.push({ value, keys, index: 0, isArray: Array.isArray(value), path, depth: frame.depth + 1 });
stack.push(frame(value, path, current.depth + 1));
continue;
}

Expand Down
14 changes: 8 additions & 6 deletions src/shared/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
export function isPlainObject(input: unknown): input is Record<string, unknown> {
if (typeof input !== 'object' || input === null) return false;
const proto: unknown = Object.getPrototypeOf(input);
return proto === null || Object.getPrototypeOf(proto) === null;
return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null;
}

/** Plain objects, and arrays unless `safe` keeps them as values. */
Expand All @@ -19,9 +19,11 @@ export function isContainer(input: unknown, safe: boolean): input is Record<stri
* `target` instead of creating a key.
*/
export function assign(target: Record<string, unknown>, key: string, value: unknown): void {
if (key === '__proto__') {
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
} else {
target[key] = value;
}
if (key === '__proto__') defineOwn(target, key, value);
else target[key] = value;
}

/** Kept apart from `assign` so the common path stays small enough for engines to inline. */
function defineOwn(target: object, key: string, value: unknown): void {
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
}
14 changes: 10 additions & 4 deletions src/shared/notation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { escapeKey, escapePointer, splitBracketPath, splitPath, splitPointer } from './path.ts';
import { escapePointer, keyEscaper, splitBracketPath, splitPath, splitPointer } from './path.ts';

/**
* How paths are written:
Expand All @@ -25,11 +25,17 @@ export function pathFormat(notation: Notation, delimiter: string, escape: boolea
split: splitPointer,
};
}
const extra = notation === 'bracket' ? '[' : '';
const bracket = notation === 'bracket';
const escapeKey = escape ? keyEscaper(delimiter, bracket ? '[' : '') : undefined;
const digitDelimiter = /\d/.test(delimiter);
return {
join: (path, key, isIndex) => {
if (isIndex && notation === 'bracket') return `${path ?? ''}[${key}]`;
const segment = escape ? escapeKey(key, delimiter, extra) : key;
if (isIndex) {
if (bracket) return `${path ?? ''}[${key}]`;
// Array indices are digits: they only need escaping when the delimiter holds a digit.
if (!digitDelimiter) return path === undefined ? key : path + delimiter + key;
}
const segment = escapeKey ? escapeKey(key) : key;
return path === undefined ? segment : path + delimiter + segment;
},
split: notation === 'bracket' ? (path) => splitBracketPath(path, delimiter, escape) : (path) => splitPath(path, delimiter, escape),
Expand Down
Loading
Loading