From f5f77ac6a9baa37d8e9f090ca5db5a94993611ee Mon Sep 17 00:00:00 2001 From: Fernando Machado Bernardino Date: Fri, 25 Sep 2026 23:15:28 -0300 Subject: [PATCH] chore: add benchmarks against flat, flattie, nestie, es-toolkit and radashi pnpm bench builds the package and compares flatten and unflatten on small, deep, wide and list inputs with Vitest 5 bench.compare. The other libraries are dev dependencies only. The flatten walk now reads arrays by index and computes key escaping once per call, with tests for digit delimiters and a polluted Object.prototype. --- bench/fixtures.ts | 23 ++++++++++++++++++ bench/flatten.bench.ts | 21 +++++++++++++++++ bench/unflatten.bench.ts | 21 +++++++++++++++++ bench/vitest.config.mts | 8 +++++++ package.json | 6 +++++ pnpm-lock.yaml | 45 +++++++++++++++++++++++++++++++++++ src/flatten/flatten.spec.ts | 18 +++++++++++++- src/flatten/flatten.ts | 47 +++++++++++++++++++++++++++---------- src/shared/guards.ts | 14 ++++++----- src/shared/notation.ts | 14 +++++++---- src/shared/path.ts | 46 +++++++++++++++++++++--------------- 11 files changed, 220 insertions(+), 43 deletions(-) create mode 100644 bench/fixtures.ts create mode 100644 bench/flatten.bench.ts create mode 100644 bench/unflatten.bench.ts create mode 100644 bench/vitest.config.mts diff --git a/bench/fixtures.ts b/bench/fixtures.ts new file mode 100644 index 0000000..efac31f --- /dev/null +++ b/bench/fixtures.ts @@ -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>((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; diff --git a/bench/flatten.bench.ts b/bench/flatten.bench.ts new file mode 100644 index 0000000..99d912f --- /dev/null +++ b/bench/flatten.bench.ts @@ -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)) + ); + }); + } +}); diff --git a/bench/unflatten.bench.ts b/bench/unflatten.bench.ts new file mode 100644 index 0000000..d8da854 --- /dev/null +++ b/bench/unflatten.bench.ts @@ -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)) + ); + }); + } +}); diff --git a/bench/vitest.config.mts b/bench/vitest.config.mts new file mode 100644 index 0000000..44f3375 --- /dev/null +++ b/bench/vitest.config.mts @@ -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'] }, + }, +}); diff --git a/package.json b/package.json index 881cd2f..5a3dd6d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e286f9..3b0ed98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,18 +186,33 @@ importers: '@vitest/coverage-istanbul': specifier: ^5.0.1 version: 5.0.1(vitest@5.0.1) + es-toolkit: + specifier: 1.52.0 + version: 1.52.0 fast-check: specifier: ^4.10.2 version: 4.10.2 + flat: + specifier: 6.0.1 + version: 6.0.1 + flattie: + specifier: 1.1.1 + version: 1.1.1 miniflare: specifier: ^4.20260730.0 version: 4.20260730.0(@types/node@26.6.2) + nestie: + specifier: 1.0.3 + version: 1.0.3 playwright: specifier: ^1.63.0 version: 1.63.0 publint: specifier: ^0.3.24 version: 0.3.24 + radashi: + specifier: 12.9.6 + version: 12.9.6 tsdown: specifier: ^0.23.0 version: 0.23.0(@arethetypeswrong/core@0.18.5)(publint@0.3.24)(typescript@7.0.2) @@ -1209,6 +1224,9 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1245,9 +1263,18 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + flat@6.0.1: + resolution: {integrity: sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==} + engines: {node: '>=18'} + hasBin: true + flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + flru@1.0.2: resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} engines: {node: '>=6'} @@ -1436,6 +1463,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nestie@1.0.3: + resolution: {integrity: sha512-971uBUL8p6TuISghZ4Qxx35DKgRKVuOLNBl9Q6CJNIcwh79zAB07d3bkrN3Sh3xbOhyzdUS9V7t0KV6/gyOl+g==} + engines: {node: '>=8'} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -1507,6 +1538,10 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + radashi@12.9.6: + resolution: {integrity: sha512-0xf53IXAtaJEQ9VSy7npEDIRZKyKBNtNFRYycdtejzHXb0/pbKXj/nFVeJbv1Sbzzzj7aaR+Ttu9c5KTrSmGzw==} + engines: {node: '>=16.0.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2718,6 +2753,8 @@ snapshots: es-module-lexer@2.3.2: {} + es-toolkit@1.52.0: {} + escalade@3.2.0: {} estree-walker@3.0.3: @@ -2746,8 +2783,12 @@ snapshots: fflate@0.8.3: {} + flat@6.0.1: {} + flatted@3.4.4: {} + flattie@1.1.1: {} + flru@1.0.2: {} fsevents@2.3.3: @@ -2891,6 +2932,8 @@ snapshots: nanoid@3.3.19: {} + nestie@1.0.3: {} + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -2947,6 +2990,8 @@ snapshots: quansync@1.0.0: {} + radashi@12.9.6: {} + require-directory@2.1.1: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/flatten/flatten.spec.ts b/src/flatten/flatten.spec.ts index ee9ddda..a263ecd 100644 --- a/src/flatten/flatten.spec.ts +++ b/src/flatten/flatten.spec.ts @@ -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', () => { @@ -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; + 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 }); }); diff --git a/src/flatten/flatten.ts b/src/flatten/flatten.ts index e36646f..7cef81c 100644 --- a/src/flatten/flatten.ts +++ b/src/flatten/flatten.ts @@ -5,13 +5,25 @@ import type { Flatten, FlattenOptions } from './types.ts'; interface Frame { value: Record; - 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, 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): 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'"); @@ -58,32 +70,41 @@ export function flatten(input: object, options: FlattenOptions = {}): Record = {}; // Containers on the current path, to tell a circular reference from the same object reused twice. const ancestors = new Set([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; } diff --git a/src/shared/guards.ts b/src/shared/guards.ts index b154396..43e2baf 100644 --- a/src/shared/guards.ts +++ b/src/shared/guards.ts @@ -6,7 +6,7 @@ export function isPlainObject(input: unknown): input is Record { 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. */ @@ -19,9 +19,11 @@ export function isContainer(input: unknown, safe: boolean): input is Record, 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 }); } diff --git a/src/shared/notation.ts b/src/shared/notation.ts index 5c1f18b..b51ec7c 100644 --- a/src/shared/notation.ts +++ b/src/shared/notation.ts @@ -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: @@ -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), diff --git a/src/shared/path.ts b/src/shared/path.ts index f566e3f..56bb9e7 100644 --- a/src/shared/path.ts +++ b/src/shared/path.ts @@ -1,29 +1,31 @@ /** - * `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:::'`). + * Builds the function that 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 `\`, every first character of the + * delimiter and every `extra` character (`[` in bracket notation). + * + * A key is ambiguous when it contains `\`, the delimiter or an `extra` character, or ends like the start + * of the delimiter so that the two overlap (`'a:' + '::'` → `'a:::'`). The overlapping tails depend on the + * delimiter only, so they are computed once here rather than for every key. */ -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; +export function keyEscaper(delimiter: string, extra: string): (key: string) => string { + const tails: string[] = []; for (let length = 1; length < delimiter.length; length++) { const tail = delimiter.slice(0, length); - if (key.endsWith(tail) && (tail + delimiter).startsWith(delimiter)) return true; + if ((tail + delimiter).startsWith(delimiter)) tails.push(tail); } - return false; + const special = `\\${delimiter[0]}${extra}`; + + return (key) => { + if (key.indexOf('\\') === -1 && key.indexOf(delimiter) === -1 && (extra === '' || key.indexOf(extra) === -1) && !tails.some((tail) => key.endsWith(tail))) return key; + let escaped = ''; + for (const char of key) escaped += special.includes(char) ? `\\${char}` : char; + return escaped; + }; } -/** - * 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 `\`, every first character of the delimiter and every `extra` - * character (`[` in bracket notation). - */ +/** Escapes a single key; see `keyEscaper`. */ 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 += special.includes(char) ? `\\${char}` : char; - return escaped; + return keyEscaper(delimiter, extra)(key); } /** Splits a path on the delimiter. With `escape`, `\` makes the next character part of the key, reversing `escapeKey`. */ @@ -51,7 +53,13 @@ export function splitPath(path: string, delimiter: string, escape: boolean): str /** Canonical array indices (`0`, `1`, `42`, not `01` or `-1`) up to `limit`. */ export function isIndex(segment: string, limit: number): boolean { - return /^(?:0|[1-9]\d*)$/.test(segment) && Number(segment) <= limit; + const length = segment.length; + if (length === 0 || (length > 1 && segment.charCodeAt(0) === 48)) return false; + for (let i = 0; i < length; i++) { + const code = segment.charCodeAt(i); + if (code < 48 || code > 57) return false; + } + return Number(segment) <= limit; } /**