From 5d0192a25346ebb354f094a903e396a0e9867c8c Mon Sep 17 00:00:00 2001 From: Fernando Machado Bernardino Date: Sat, 26 Sep 2026 02:31:24 -0300 Subject: [PATCH 1/2] docs: rewrite the README for 2.0, with verified comparisons A lean README led by data integrity and a feature comparison with flat, flattie/nestie, es-toolkit and radashi, where every row is checked by bench/claims.spec.ts (pnpm claims, also in CI). bench/size.js measures sizes (pnpm size) and benchmarks load dist/ natively. unflatten builds arrays from candidates tracked while building, and the JSON Pointer '' reads as the whole document in get and has. --- .changeset/add-path-helpers.md | 1 + .github/workflows/main.yml | 16 ++ README.md | 439 +++++++++++++++++++++++--------- bench/claims.spec.ts | 155 +++++++++++ bench/size.js | 29 +++ bench/tsconfig.json | 9 + bench/vitest.config.mts | 6 +- docs/file-docs.md | 12 + package.json | 5 +- pnpm-lock.yaml | 308 ++++++++++++++++++++-- src/flatten/flatten.spec.ts | 15 ++ src/flatten/flatten.ts | 46 ++-- src/path/path.spec.ts | 9 + src/path/path.ts | 5 +- src/shared/notation.ts | 22 +- src/shared/path.spec.ts | 6 +- src/shared/path.ts | 3 +- src/unflatten/unflatten.spec.ts | 20 ++ src/unflatten/unflatten.ts | 58 +++-- vitest.config.mts | 2 + 20 files changed, 966 insertions(+), 200 deletions(-) create mode 100644 bench/claims.spec.ts create mode 100644 bench/size.js create mode 100644 bench/tsconfig.json diff --git a/.changeset/add-path-helpers.md b/.changeset/add-path-helpers.md index f0c1938..29c50b5 100644 --- a/.changeset/add-path-helpers.md +++ b/.changeset/add-path-helpers.md @@ -9,3 +9,4 @@ Add the `flattify/path` entry to read and write nested values by path, with auto - `has`, `del`, `paths` (the paths `flatten` produces), `parsePath`, `stringifyPath` and `escapeKey` - `Path`, `Get` and `SetValue` types - Every helper takes the `delimiter`, `notation` and `escape` options, and is safe against prototype pollution +- The JSON Pointer `''` is the whole document, as JSON Schema validators report errors at the root: `get(obj, '', { notation: 'pointer' })` returns `obj` diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 52af034..cb8b5a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -109,6 +109,22 @@ jobs: - run: pnpm exec playwright install --with-deps chromium firefox webkit - run: pnpm run test:browser + claims: + name: Check README claims about other libraries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm run claims + jsr: name: Check JSR publish (dry run) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 740158f..279c258 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,347 @@ -# [Flattify](https://github.com/NandoMB/flattify) +# [flattify](https://github.com/NandoMB/flattify) -Convert JSON to "Flat JSON". +[![npm version](https://img.shields.io/npm/v/flattify.svg)](https://www.npmjs.com/package/flattify) +[![JSR](https://jsr.io/badges/@nandomb/flattify)](https://jsr.io/@nandomb/flattify) +[![JSR Score](https://jsr.io/badges/@nandomb/flattify/score)](https://jsr.io/@nandomb/flattify) +[![CI](https://github.com/NandoMB/flattify/actions/workflows/main.yml/badge.svg)](https://github.com/NandoMB/flattify/actions/workflows/main.yml) +[![npm downloads](https://img.shields.io/npm/dm/flattify.svg)](https://www.npmjs.com/package/flattify) +[![license](https://img.shields.io/npm/l/flattify.svg)](./LICENSE) +[![Socket Badge](https://badge.socket.dev/npm/package/flattify)](https://socket.dev/npm/package/flattify) -## Getting Started -#### Installation +Turn nested objects into flat `path → value` pairs **and back, without losing anything on the way**, with every path and value **inferred by TypeScript**. + +- **Your data comes back exactly as it went:** dates, class instances, `null`, empty objects, keys with dots in them, key order. [Nothing is dropped or changed](#data-integrity) +- **Safe on untrusted input:** no prototype pollution, no stack overflow on deep or circular objects, no huge arrays from hostile indices +- **Typed end to end:** hover the result to see every path; `get` and `set` autocomplete paths and check values +- **Three notations:** `a.b.0`, `a.b[0]` (as in HTML forms) and `/a/b/0` (JSON Pointer) +- **Checked, not claimed:** 100% test coverage, thousands of random round trips per run, and every [comparison](#why-flattify) with other libraries verified in CI +- Zero dependencies, ESM and CommonJS, on [npm](https://www.npmjs.com/package/flattify) and [JSR](https://jsr.io/@nandomb/flattify). Runs on Node.js, Bun, Deno, browsers and Cloudflare Workers + +> Upgrading from flattify 1.x? See [Migrating](#migrating). + +## Contents + +- [Installation](#installation) +- [Quick start](#quick-start) +- [Data integrity](#data-integrity) +- [Why flattify?](#why-flattify) +- [Use cases](#use-cases) +- [API](#api): [`flatten`](#flatteninput-options), [`unflatten`](#unflatteninput-options), [notations](#notations), [`flattify/path`](#flattifypath) +- [Types](#types) +- [Limitations](#limitations) +- [Compatibility](#compatibility) +- [Migrating](#migrating) +- [Security](#security) + +## Installation ```sh -$ npm install flattify --save +npm add flattify # or: pnpm add flattify / yarn add flattify +``` +###### Deno, Bun and Node.js from JSR +```sh +deno add jsr:@nandomb/flattify +npx jsr add @nandomb/flattify # or: bunx jsr add @nandomb/flattify ``` -#### How to use +## Quick start -```js -var flattify = require('./flattify'); -``` +```ts +import { flatten, unflatten } from 'flattify'; +// CommonJS: const { flatten, unflatten } = require('flattify'); -##### flattify(json); -Using with only the first parameter, only objects will be converted. -Arrays will be transformed to string. - -```js -var flattify = require('./flattify'); - -var json = { - "id": 1, - "gender": "Male", - "first": "Joshua", - "last": "Williams", - "emails": [ - "jwilliams1@vkontakte.ru", - "phowell1@ezinearticles.com", - "aferguson2@pen.io" +const order = { + id: 1042, + customer: { name: 'Ada Lovelace' }, + items: [ + { sku: 'BOOK-1', qty: 2 }, + { sku: 'PEN-7', qty: 1 }, ], - "addresses": [ - { - "street": "Anzinger Court", - "number": 73902 - }, - { - "street": "Anthes Parkway", - "number": 106 - } - ] + shipping: { method: null }, + 'utm.source': 'newsletter', }; -var flattenJson = flattify(json); -console.log(flattenJson); +const flat = flatten(order); +// { +// 'id': 1042, +// 'customer.name': 'Ada Lovelace', +// 'items.0.sku': 'BOOK-1', +// 'items.0.qty': 2, +// 'items.1.sku': 'PEN-7', +// 'items.1.qty': 1, +// 'shipping.method': null, +// 'utm\\.source': 'newsletter', // escaped, so the dot survives the way back +// } + +const back = unflatten(flat); // equals `order` again ``` -Result: - -```js -{ - "id": 1, - "gender": "Male", - "first": "Joshua", - "last": "Williams", - "emails": "[\"jwilliams1@vkontakte.ru\",\"phowell1@ezinearticles.com\",\"aferguson2@pen.io\"]", - "addresses": "[{\"street\":\"Anzinger Court\",\"number\":73902},{\"street\":\"Anthes Parkway\",\"number\":106}]" -} +Both results are typed, so the editor knows every path: + +```ts +flat['items.0.sku']; // string +flat['customer.phone']; // ❌ Property 'customer.phone' does not exist +back.items[0].sku; // string ``` -#### flattify(json, true); -Using with the first and second parameter, all objects and arrays will be converted. - -```js -var flattify = require('./flattify'); - -var json = { - "id": 1, - "gender": "Male", - "first": "Joshua", - "last": "Williams", - "emails": [ - "jwilliams1@vkontakte.ru", - "phowell1@ezinearticles.com", - "aferguson2@pen.io" - ], - "addresses": [ - { - "street": "Anzinger Court", - "number": 73902 - }, - { - "street": "Anthes Parkway", - "number": 106 - } - ] +`flatten` and `unflatten` are a round trip, like serializing and deserializing, except that both sides stay JavaScript objects: only the shape changes. A lot of data already arrives flat, too: form fields, query strings, environment variables, CSV columns. `unflatten` turns it into the object your code wants, safely. + +## Data integrity + +Most libraries lose something on the way: a `Date` becomes `{}`, `null` disappears, an empty list vanishes, `'utm.source'` is split in two. flattify keeps all of it: + +```ts +const input = { + createdAt: new Date(), + price: new Money(1990), // a class instance + discount: null, + coupon: undefined, + items: [], + meta: {}, + 'utm.source': 'newsletter', + sizes: ['S', , 'L'], // an array with a hole }; -var flattenJson = flattify(json, true); -console.log(flattenJson); +const back = unflatten(flatten(input)); + +back.createdAt === input.createdAt; // true: the same Date, not {} or a copy +back.price instanceof Money; // true +back.discount; // null +'coupon' in back; // true +back.items; // [] +back['utm.source']; // 'newsletter', still one key +1 in back.sizes; // false: the hole is still a hole ``` -Result: - -```js -{ - "id": 1, - "gender": "Male", - "first": "Joshua", - "last": "Williams", - "emails.0": "jwilliams1@vkontakte.ru", - "emails.1": "phowell1@ezinearticles.com", - "emails.2": "aferguson2@pen.io", - "addresses.0.street": "Anzinger Court", - "addresses.0.number": 73902, - "addresses.1.street": "Anthes Parkway", - "addresses.1.number": 106 -} +- **Values are never converted:** only plain objects and arrays are walked. `Date`, `Map`, `Set`, `RegExp`, typed arrays, functions and class instances are kept as they are, by reference +- **Nothing is dropped:** `null`, `undefined`, empty objects and empty arrays are kept +- **Keys stay whole:** a key that contains the delimiter is escaped (`'utm\\.source'`); keys without it are never changed +- **Order is kept:** keys come out depth first, in the order of the input, and come back in the same order +- **Your input is never modified:** `unflatten` copies the objects it finds in its input before adding keys to them +- **No surprise arrays:** only indices such as `0` or `42` build arrays, up to [`arrayLimit`](#unflatteninput-options); `'01'` or `'1e3'` stay object keys +- **Circular data is reported** with its path, instead of freezing or crashing the process; **any depth** is handled, even 100,000 levels + +Each guarantee is covered by the test suite, and the round trip is checked on thousands of random objects on every run ([fast-check](https://fast-check.dev)), for every notation. + +## Why flattify? + +Every row is checked by [`bench/claims.spec.ts`](./bench/claims.spec.ts) against flat 6.0.1, flattie 1.1.1, nestie 1.0.3, es-toolkit 1.52.0 and radashi 12.9.6, on every change in CI (`pnpm claims`). + +| | flattify | [flat](https://www.npmjs.com/package/flat) | [flattie](https://www.npmjs.com/package/flattie) / [nestie](https://www.npmjs.com/package/nestie) | [es-toolkit](https://www.npmjs.com/package/es-toolkit) | [radashi](https://www.npmjs.com/package/radashi) | +| --- | --- | --- | --- | --- | --- | +| Keys with the delimiter survive the round trip | ✅ | ❌ | ❌ | – | ❌ | +| Keeps `Date` and `Map` as values | ✅ | ✅ | ❌ dropped | ✅ | ✅ | +| Keeps class instances as values | ✅ | ❌ | ❌ | ✅ | ❌ | +| Keeps `null` values | ✅ | ✅ | ❌ dropped | ✅ | ✅ | +| Keeps empty objects and arrays | ✅ | ✅ | ❌ dropped | ✅ | ❌ dropped | +| Circular references | ✅ clear error | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | +| Deeply nested input (100k levels) | ✅ | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | +| Limit on array indices when unflattening | ✅ | ❌ | ❌ | – | ❌ | +| Paths and values inferred by TypeScript | ✅ | ❌ you pass the type | ❌ | ❌ `Record` | ❌ `Record` | +| `unflatten` | ✅ | ✅ | ✅ | ❌ | ✅ | +| Bracket notation and JSON Pointer | ✅ | ❌ | ❌ | ❌ | ❌ | +| `get` / `set` with autocompleted paths | ✅ | ❌ | ❌ | ❌ | ❌ | +| ESM and CommonJS | ✅ | ❌ v6 is ESM only | ✅ | ✅ | ✅ | +| Size (min + gzip) | 2.4 kB | 0.7 kB | 0.35 kB | 0.34 kB | 0.8 kB | + +flattify is bigger because it does more: the checks above, three notations, escaping and clear error messages. `flattify/path` is a separate import, so it only counts when you use it. + +## Use cases + +**HTML forms:** field names such as `address[city]` or `items[0][qty]` become a nested object. Keys like `__proto__[isAdmin]` sent by a malicious client are ignored. + +```ts +const body = unflatten(Object.fromEntries(new FormData(form)), { notation: 'bracket' }); +// { name: 'Ada', address: { city: 'London' }, items: [{ sku: 'BOOK-1', qty: '2' }] } +``` + +**Query strings**, both ways: + +```ts +const flat = flatten({ status: 'open', tags: ['a', 'b'] }, { notation: 'bracket' }); +const query = new URLSearchParams(Object.entries(flat).map(([k, v]) => [k, String(v)])); +// status=open&tags[0]=a&tags[1]=b (with the brackets percent-encoded) + +unflatten(Object.fromEntries(new URLSearchParams(location.search)), { notation: 'bracket' }); +// { status: 'open', tags: ['a', 'b'] } ``` +**Environment variables:** with `'__'` as delimiter, `APP__DB__MAX_CONN` is `app.db.max_conn`. + +```ts +const env = unflatten(process.env, { delimiter: '__', transformKey: (key) => key.toLowerCase() }); +env.app; // { db: { host: 'localhost', max_conn: '10' } } +``` + +**API lists → CSV or spreadsheets:** one row per record, and the keys are the headers. + +```ts +const rows = users.map((user) => flatten(user)); +// [{ id: 1, name: 'Ada', 'address.city': 'London' }, ...] + +// Or flatten the whole list into one object, and get the list back: +const same = unflatten(flatten(users), { asArray: true }); +``` + +**Partial updates in MongoDB:** `$set` with flat paths changes only the given fields. + +```ts +await users.updateOne({ _id }, { $set: flatten({ profile: { name: 'Ada' } }) }); +// $set: { 'profile.name': 'Ada' }: the other profile fields are kept +``` + +**JSON Schema errors:** validators such as Ajv point to errors with JSON Pointers, which `get` reads directly. + +```ts +get(data, error.instancePath, { notation: 'pointer' }); // e.g. '/items/0/qty' +``` + +Also: translation files (`messages['home.title']`, typed), structured logs for Datadog or Elasticsearch, and finding what changed between two objects by comparing their flat forms. + +## API + +### `flatten(input, options?)` + +Takes a plain object or an array, and returns a new flat object. Values are not cloned. + +| Option | Default | | +| --- | --- | --- | +| `delimiter` | `'.'` | Joins the keys: `{ delimiter: '__' }` → `db__host` | +| `notation` | `'dot'` | `'dot'`, `'bracket'` or `'pointer'`, see [notations](#notations) | +| `maxDepth` | `Infinity` | Levels to walk into; deeper objects are kept as values. `1` keeps the top-level keys only | +| `safe` | `false` | Keeps arrays as values: `{ 'user.tags': ['a', 'b'] }` instead of `user.tags.0`, `user.tags.1` | +| `keepEmpty` | `true` | Keeps empty objects and arrays; `false` drops them | +| `escape` | `true` | Escapes keys that contain the delimiter (`'utm\\.source'`); `false` leaves them as they are | +| `circular` | `'throw'` | On a circular reference, throws a `TypeError` with its path, or `'skip'` leaves the key out | +| `transformKey` | | Renames each object key: `(key) => key.toUpperCase()`. Keys are then typed as `string` | +| `preserve` | | `(path, value) => boolean`: `true` keeps that object or array as a value | + +With a longer delimiter, only keys that would really be ambiguous are escaped: with `'__'`, `MAX_CONN` stays as it is, but `MAX_` (which would merge with the delimiter) is escaped. + +### `unflatten(input, options?)` + +Takes a flat object, and returns a new nested object. Keys that are array indices build arrays, whatever their order; the top level is always an object, unless `asArray` is set. + +| Option | Default | | +| --- | --- | --- | +| `delimiter` | `'.'` | Splits the keys into paths | +| `notation` | `'dot'` | `'dot'`, `'bracket'` or `'pointer'`, see [notations](#notations) | +| `asArray` | `false` | Returns an array: `{ '0.id': 1, '1.id': 2 }` → `[{ id: 1 }, { id: 2 }]`. A top-level key that is not an index throws | +| `object` | `false` | Never builds arrays: `{ 'list.0': 'a' }` → `{ list: { 0: 'a' } }` | +| `overwrite` | `false` | When a path goes through a value (`{ a: 1, 'a.b': 2 }`), replaces it (`{ a: { b: 2 } }`) instead of skipping the path | +| `arrayLimit` | `1000` | Larger indices build object keys, so `'a.4294967294'` can't allocate a huge array | +| `escape` | `true` | Reads `\` as an escape, as `flatten` writes it | +| `transformKey` | | Renames each key of the path (not array indices) | + +### Notations + +Every function takes `notation`, and the inferred types follow it: + +| `notation` | Path | Useful for | +| --- | --- | --- | +| `'dot'` (default) | `items.0.sku` | Config, translations, MongoDB, logs | +| `'bracket'` | `items[0].sku` | HTML forms and query strings. `unflatten` also reads `items[0][sku]` | +| `'pointer'` | `/items/0/sku` | [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901): JSON Patch, JSON Schema errors. `~` and `/` are written `~0` and `~1` | + +### `flattify/path` + +Read and write one value by path, with the path autocompleted and the value typed: + +```ts +import { get, set, has, del, paths } from 'flattify/path'; + +get(order, 'items.0.sku'); // 'BOOK-1', typed string | undefined + +set(config, 'db.port', 6543); // ✅ +set(config, 'db.port', '6543'); // ❌ type error: string is not number +set(config, 'db.prot', 6543); // ❌ type error: no such path +``` + +| Function | | +| --- | --- | +| `get(obj, path)` | The value at `path`, or `undefined` when a key is missing | +| `set(obj, path, value)` | Sets the value, creating missing objects and arrays; returns `obj` | +| `has(obj, path)` | `true` when every key of the path exists | +| `del(obj, path)` | Deletes the key; `true` when it existed | +| `paths(obj)` | The paths `flatten` produces | +| `parsePath(path)` / `stringifyPath(keys)` | `'a\\.b.c'` ↔ `['a.b', 'c']` | +| `escapeKey(key)` | Escapes one key: `` `user.${escapeKey(field)}` `` | + +All of them take `delimiter`, `notation` and `escape` as a last argument, and are safe with untrusted paths. + +## Types + +Paths and values are computed at the type level, for every option that changes them: + +```ts +flatten({ user: { tags: ['admin'] } }); +// { [x: `user.tags.${number}`]: string; 'user.tags'?: [] } + +flatten({ a: { b: 1 } }, { notation: 'pointer' }); +// { '/a/b': number } +``` + +Arrays give `` `${number}` `` keys and tuples exact indices; optional or nullable objects give optional paths; unions are merged; `Date`, `Map` and other built-ins stay as values. `Unflatten>` gives back `T` for plain data. When an option is only known at runtime (a `string` delimiter, `transformKey`, `preserve`), keys are typed as `string` rather than guessed. + +Exported types: `Flatten`, `Unflatten`, and from `flattify/path`, `Path`, `Get` and `SetValue`. + +## Limitations + +- **Some round trips can't be exact:** an object with only index keys (`{ '0': 'a' }`) comes back as an array; an array given to `flatten` comes back as an object unless `unflatten` gets `{ asArray: true }`; `transformKey` needs its reverse on the way back. +- **Symbol keys are ignored**, as by `JSON.stringify`. +- **Types:** TypeScript can't tell a class instance from a plain object, so class instances are typed as if they were flattened. Paths are typed down to 10 levels, and as `unknown` below. +- **Bracket notation:** an empty top-level key holding an array (`{ '': [1] }`) can't be told apart from the array itself. + +## Compatibility + +| | | +| --- | --- | +| Runtimes | Node.js, Bun, Deno, browsers (ES2015), Cloudflare Workers | +| Modules | ESM and CommonJS | +| TypeScript | 5.0+, any `moduleResolution` | + +CI runs the tests on Node.js 22, 24 and 26, Bun, Deno, Chromium, Firefox, WebKit and workerd (the Cloudflare Workers runtime), and checks the package with [publint](https://publint.dev) and [Are the Types Wrong?](https://arethetypeswrong.github.io). + +## Migrating + +### From `flat` + +Same option names (`delimiter`, `maxDepth`, `safe`, `object`, `overwrite`, `transformKey`), with safer defaults: + +| | `flat` | flattify | +| --- | --- | --- | +| Result type | you pass it: `flatten()` | inferred | +| Keys with the delimiter | split in two on the way back | escaped; `{ escape: false }` for the old behavior | +| `maxDepth: 0` | no limit | `RangeError`: leave it out for no limit | +| `unflatten({ a: { 'b.c': 1 } })` | `{ a: { b: { c: 1 } } }` | values are kept as they are | +| `unflatten` of a non-object | returned as it is | `TypeError` | +| Class instances | flattened | kept as values | +| Circular references | stack overflow | `TypeError`, or `circular: 'skip'` | + +### From flattify 1.x + +2.0 is a rewrite with named exports: + +| 1.x | 2.x | +| --- | --- | +| `flattify(obj, true)` | `flatten(obj)` | +| `flattify(obj)` (arrays turned into JSON strings) | `flatten(obj, { safe: true })` (arrays kept as arrays) | +| `flattify(array)` (each item flattened) | `array.map((item) => flatten(item))` | +| `flattify(jsonString)` | `flatten(JSON.parse(jsonString))` | + +It also fixes nested arrays losing their path: `{ a: { tags: [1] } }` gave `{ tags: '[1]' }`, and now gives `{ 'a.tags.0': 1 }`. + +## Security + +- **No prototype pollution:** `unflatten` and `set` only create own properties; paths through `__proto__` are skipped, in every notation. (`flat` had this flaw as [CVE-2020-36632](https://nvd.nist.gov/vuln/detail/CVE-2020-36632), fixed in 5.0.1.) +- **No stack overflow:** objects are walked with an explicit stack instead of recursion, so any depth is handled, and circular references throw a clear error. +- **No huge arrays:** `arrayLimit` bounds the indices `unflatten` turns into arrays. +- **No slowdown on hostile paths:** the bracket parser stays linear on input like `a[[[[...`. + +All of this is tested, and `unflatten` is fuzzed with random paths made of `__proto__`, `constructor` and `prototype`. Releases are published from GitHub Actions with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) and [staged](https://docs.npmjs.com/staged-publishing/) for approval with 2FA. + +Found a vulnerability? Please report it privately, see [SECURITY.md](./SECURITY.md). + ## License -The MIT License (MIT) - -Copyright (c) 2016 Fernando Machado Bernardino -[NandoMB](https://github.com/NandoMB). https://github.com/NandoMB/flattify - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + +[MIT](./LICENSE) © 2016 Fernando Machado Bernardino diff --git a/bench/claims.spec.ts b/bench/claims.spec.ts new file mode 100644 index 0000000..b356562 --- /dev/null +++ b/bench/claims.spec.ts @@ -0,0 +1,155 @@ +/** + * Every claim the README makes about other libraries, checked against the versions installed from + * package.json: `pnpm claims`. When an update changes a behavior, this fails and the README is fixed. + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import * as esToolkit from 'es-toolkit'; +import { get as esToolkitGet } from 'es-toolkit/compat'; +import { flatten as flat, unflatten as flatUnflatten } from 'flat'; +import { flattie } from 'flattie'; +import { nestie } from 'nestie'; +import { construct, crush, get as radashiGet } from 'radashi'; +import { describe, expect, expectTypeOf, test } from 'vitest'; +import { flatten, unflatten } from '../dist/index.js'; + +type Flatten = (input: object) => Record; +type Unflatten = (input: Record) => Record; + +const flatteners: Record = { + flattify: flatten, + flat: (input) => flat(input), + flattie: (input) => flattie(input), + 'es-toolkit': (input) => esToolkit.flattenObject(input), + radashi: (input) => crush(input), +}; +const unflatteners: Record = { + flattify: unflatten, + flat: (input) => flatUnflatten(input), + nestie: (input) => nestie(input) as Record, + radashi: (input) => construct(input) as Record, +}; +const roundTrips: Record unknown> = { + flattify: (input) => unflatten(flatten(input)), + flat: (input) => flatUnflatten(flat(input)), + 'flattie/nestie': (input) => nestie(flattie(input)), + radashi: (input) => construct(crush(input)), +}; + +const require = createRequire(import.meta.url); +// Read from disk: `flat` does not export its package.json. +const packageJson = (name: string) => JSON.parse(readFileSync(join(dirname(require.resolve(name)), 'package.json'), 'utf8')) as { type?: string; exports?: unknown }; + +class Point { + x = 1; +} + +describe('Why flattify?', () => { + test('Paths and values inferred by TypeScript: the other libraries type the result loosely or not at all', () => { + expectTypeOf(flatten({ a: { b: 1 } })).toEqualTypeOf<{ 'a.b': number }>(); + // flat: `flatten(target: T): R`, you pass the result type + expectTypeOf(flat({ a: { b: 1 } })).toEqualTypeOf(); + expectTypeOf(flattie({ a: { b: 1 } })).toEqualTypeOf>(); + expectTypeOf(esToolkit.flattenObject({ a: { b: 1 } })).toEqualTypeOf>(); + // radashi: the keys are `string`, not the paths + expectTypeOf>>().toEqualTypeOf(); + }); + + test('unflatten: es-toolkit has none', () => { + expect('unflattenObject' in esToolkit).toBe(false); + }); + + test('ESM and CommonJS: flat 6 is ESM only', () => { + expect(packageJson('flat').type).toBe('module'); + expect(JSON.stringify(packageJson('flat').exports)).not.toContain('require'); + }); + + test.each(Object.entries(roundTrips))('Keys with the delimiter survive the round trip: %s', (name, roundTrip) => { + expect(JSON.stringify(roundTrip({ 'a.b': 1 })) === JSON.stringify({ 'a.b': 1 })).toBe(name === 'flattify'); + }); + + test.each(Object.entries(flatteners))('Keeps Date and Map as values: %s', (name, flattenWith) => { + const date = new Date(0); + const map = new Map([[1, 2]]); + const result = flattenWith({ a: { date, map } }); + expect(result['a.date'] === date && result['a.map'] === map).toBe(name !== 'flattie'); + }); + + test.each(Object.entries(flatteners))('Keeps class instances as values: %s', (name, flattenWith) => { + const point = new Point(); + expect(flattenWith({ a: { point } })['a.point'] === point).toBe(name === 'flattify' || name === 'es-toolkit'); + }); + + test.each(Object.entries(flatteners))('Keeps null values: %s', (name, flattenWith) => { + expect('a.b' in flattenWith({ a: { b: null } })).toBe(name !== 'flattie'); + }); + + test.each(Object.entries(flatteners))('Keeps empty objects and arrays: %s', (name, flattenWith) => { + const result = flattenWith({ a: {}, b: [] }); + expect('a' in result && 'b' in result).toBe(name === 'flattify' || name === 'flat' || name === 'es-toolkit'); + }); + + test.each(Object.entries(flatteners))('Circular references: %s', (name, flattenWith) => { + const node: Record = {}; + node.self = node; + expect(() => flattenWith(node)).toThrow(name === 'flattify' ? new TypeError('Circular reference at "self"') : RangeError); + }); + + test.each(Object.entries(flatteners))('Deeply nested input (100k levels): %s', (name, flattenWith) => { + let deep: Record = { leaf: 1 }; + for (let i = 0; i < 100_000; i++) deep = { n: deep }; + if (name === 'flattify') expect(() => flattenWith(deep)).not.toThrow(); + else expect(() => flattenWith(deep)).toThrow(RangeError); + }); + + test.each(Object.entries(unflatteners))('Limit on array indices when unflattening: %s', (name, unflattenWith) => { + const result = unflattenWith({ 'a.5000': 1 }); + expect(Array.isArray(result.a)).toBe(name !== 'flattify'); + }); + + test('Bracket notation and JSON Pointer: the other libraries write `a.0` and have no such option', () => { + const options = { notation: 'bracket' } as never; + expect(flatten({ a: [1] }, { notation: 'bracket' })).toEqual({ 'a[0]': 1 }); + expect(flat({ a: [1] }, options)).toEqual({ 'a.0': 1 }); + expect(flattie({ a: [1] })).toEqual({ 'a.0': 1 }); + expect(esToolkit.flattenObject({ a: [1] }, options)).toEqual({ 'a.0': 1 }); + expect(crush({ a: [1] })).toEqual({ 'a.0': 1 }); + }); + + test('get / set with autocompleted paths: the other libraries take any string', () => { + expectTypeOf(radashiGet).parameter(1).toEqualTypeOf(); + // es-toolkit: a path that does not exist compiles, so the editor has no paths to suggest + expect(esToolkitGet({ a: { b: 1 } }, 'not.a.path')).toBeUndefined(); + expect('get' in { flat, flattie }).toBe(false); + }); +}); + +describe('Migrating from flat', () => { + test('Key with the delimiter: split in two on the way back', () => { + expect(flat({ 'a.b': 1 })).toEqual({ 'a.b': 1 }); + expect(flatUnflatten({ 'a.b': 1 })).toEqual({ a: { b: 1 } }); + }); + + test('maxDepth: 0 means no limit', () => { + expect(flat({ a: { b: { c: 1 } } }, { maxDepth: 0 })).toEqual({ 'a.b.c': 1 }); + }); + + test('unflatten walks into values that are objects', () => { + expect(flatUnflatten({ a: { 'b.c': 1 } })).toEqual({ a: { b: { c: 1 } } }); + }); + + test('unflatten returns anything but an object as it is', () => { + expect(flatUnflatten('text' as never)).toBe('text'); + expect(flatUnflatten(null as never)).toBe(null); + }); + + test('Class instances are flattened', () => { + expect(flat({ a: new Point() })).toEqual({ 'a.x': 1 }); + }); + + test("Array indices: any number, such as '01' or '1e3'", () => { + expect(flatUnflatten({ 'a.01': 1 })).toEqual({ a: [undefined, 1] }); + expect((flatUnflatten({ 'a.1e3': 1 }) as { a: unknown[] }).a).toHaveLength(1001); + }); +}); diff --git a/bench/size.js b/bench/size.js new file mode 100644 index 0000000..c870748 --- /dev/null +++ b/bench/size.js @@ -0,0 +1,29 @@ +// Size of each library's flatten/unflatten functions, bundled and minified as an app would ship them, +// then gzipped: `pnpm size`. The README table comes from this output. +import { gzipSync } from 'node:zlib'; +import { build } from 'esbuild'; + +const entries = { + 'flattify: flatten + unflatten': "export { flatten, unflatten } from './dist/index.js'", + 'flattify: flatten': "export { flatten } from './dist/index.js'", + 'flattify/path: get + set': "export { get, set } from './dist/path.js'", + 'flat: flatten + unflatten': "export { flatten, unflatten } from 'flat'", + 'flattie + nestie': "export { flattie } from 'flattie'; export { nestie } from 'nestie'", + 'es-toolkit: flattenObject': "export { flattenObject } from 'es-toolkit'", + 'radashi: crush + construct': "export { crush, construct } from 'radashi'", +}; + +console.log('| Import | min + gzip |\n| --- | --- |'); +for (const [name, contents] of Object.entries(entries)) { + const { outputFiles } = await build({ + stdin: { contents, resolveDir: process.cwd(), sourcefile: 'entry.js' }, + bundle: true, + minify: true, + format: 'esm', + platform: 'neutral', + mainFields: ['module', 'main'], + write: false, + }); + const bytes = gzipSync(outputFiles[0].contents, { level: 9 }).length; + console.log(`| ${name} | ${(bytes / 1000).toFixed(2)} kB |`); +} diff --git a/bench/tsconfig.json b/bench/tsconfig.json new file mode 100644 index 0000000..c30a468 --- /dev/null +++ b/bench/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false, + "declaration": false, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/bench/vitest.config.mts b/bench/vitest.config.mts index 44f3375..1bf20d8 100644 --- a/bench/vitest.config.mts +++ b/bench/vitest.config.mts @@ -1,8 +1,12 @@ import { defineConfig } from 'vitest/config'; -// Benchmarks run in Node.js only, apart from the test projects of the root config. +// Benchmarks and README claims run in Node.js only, apart from the test projects of the root config. export default defineConfig({ test: { + include: ['bench/**/*.spec.ts'], benchmark: { include: ['bench/**/*.bench.ts'] }, + // Load the built package with Node.js itself, as the other libraries are loaded from node_modules. + // Through Vitest's module runner, every import between its chunks would go through a getter. + server: { deps: { external: [/\/dist\//] } }, }, }); diff --git a/docs/file-docs.md b/docs/file-docs.md index 16d083a..088f26d 100644 --- a/docs/file-docs.md +++ b/docs/file-docs.md @@ -60,3 +60,15 @@ the oldest version supported: the public types rely on `const` type parameters, Makes `import ... from 'flattify/path'` resolve with `moduleResolution: "node"` (node10) and older bundlers, which ignore the `exports` field of `package.json`. They look for a `path` folder in the package and follow its `main`, `module` and `types` fields to `dist/`. Checked by `attw` in `lint:package`. + +### bench/ +- `pnpm bench`: speed of `flatten` and `unflatten` against other libraries, to watch for regressions. +- `pnpm size`: minified and gzipped size of each library's functions, for the README table. +- `pnpm claims`: `claims.spec.ts` checks every statement the README makes about the other libraries, + against the versions installed from `package.json`. It runs in CI, so a dependency update that + changes a behavior fails the build until the README is fixed. + +The other libraries are dev dependencies only: they are never part of the published package. +`vitest.config.mts` loads `dist/` with Node.js itself, as the other libraries are loaded from +`node_modules`; through Vitest's module runner, every import between flattify's chunks would go +through a getter and make it look slower than it is. diff --git a/package.json b/package.json index 5a3dd6d..0b3dde6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "flattify", "version": "1.0.2", - "description": "Flatten and unflatten nested objects with the resulting keys inferred by TypeScript. Safe against prototype pollution, zero dependencies.", + "description": "Flatten and unflatten nested objects without losing data: dates, nulls, empty objects and dotted keys survive the round trip. Typed paths, safe on untrusted input, zero dependencies.", "author": "Fernando Machado Bernardino ", "homepage": "https://github.com/NandoMB/flattify#readme", "type": "module", @@ -54,6 +54,8 @@ "test": "vitest run --project node", "coverage": "vitest run --project node --coverage", "bench": "pnpm run build && vitest bench --run --config bench/vitest.config.mts", + "claims": "pnpm run build && tsc -p bench && vitest run --config bench/vitest.config.mts", + "size": "pnpm run build && node bench/size.js", "build": "tsdown", "lint": "tsc", "ci": "pnpm run lint && pnpm run test && pnpm run build && pnpm run lint:package", @@ -75,6 +77,7 @@ "@vitest/browser-playwright": "^5.0.1", "@vitest/coverage-istanbul": "^5.0.1", "es-toolkit": "1.52.0", + "esbuild": "^0.28.2", "fast-check": "^4.10.2", "flat": "6.0.1", "flattie": "1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0ed98..ac9a198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,13 +182,16 @@ importers: version: 26.6.2 '@vitest/browser-playwright': specifier: ^5.0.1 - version: 5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))(vitest@5.0.1) + version: 5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(vitest@5.0.1) '@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 + esbuild: + specifier: ^0.28.1 + version: 0.28.2 fast-check: specifier: ^4.10.2 version: 4.10.2 @@ -224,7 +227,7 @@ importers: version: typescript@5.0.4 vitest: specifier: ^5.0.1 - version: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + version: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) packages: @@ -430,6 +433,162 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1227,6 +1386,11 @@ packages: es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2181,6 +2345,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -2488,30 +2730,30 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/browser-playwright@5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))(vitest@5.0.1)': + '@vitest/browser-playwright@5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(vitest@5.0.1)': dependencies: - '@vitest/browser': 5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))(vitest@5.0.1) - '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + '@vitest/browser': 5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(vitest@5.0.1) + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) playwright: 1.63.0 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))(vitest@5.0.1)': + '@vitest/browser@5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(vitest@5.0.1)': dependencies: '@blazediff/core': 1.10.0 - '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) '@vitest/ui': 5.0.1(vitest@5.0.1) '@vitest/utils': 5.0.1 magic-string: 1.4.2 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -2530,7 +2772,7 @@ snapshots: magicast: 0.5.5 obug: 2.2.1 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -2552,14 +2794,14 @@ snapshots: '@vitest/istanbul-lib-coverage': 1.0.1 obug: 2.2.1 - '@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))': + '@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.4.2 optionalDependencies: - vite: 8.3.1(@types/node@26.6.2)(yaml@2.9.1) + vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) '@vitest/pretty-format@5.0.1': dependencies: @@ -2575,7 +2817,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) '@vitest/utils@5.0.1': dependencies: @@ -2755,6 +2997,35 @@ snapshots: es-toolkit@1.52.0: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} estree-walker@3.0.3: @@ -3218,7 +3489,7 @@ snapshots: verkit@0.4.1: {} - vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1): + vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3227,13 +3498,14 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.2 + esbuild: 0.28.2 fsevents: 2.3.3 yaml: 2.9.1 - vitest@5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)): + vitest@5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1)) + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3244,11 +3516,11 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.3.1(@types/node@26.6.2)(yaml@2.9.1) + vite: 8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.6.2 - '@vitest/browser-playwright': 5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(yaml@2.9.1))(vitest@5.0.1) + '@vitest/browser-playwright': 5.0.1(playwright@1.63.0)(vite@8.3.1(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(vitest@5.0.1) '@vitest/coverage-istanbul': 5.0.1(vitest@5.0.1) '@vitest/ui': 5.0.1(vitest@5.0.1) transitivePeerDependencies: diff --git a/src/flatten/flatten.spec.ts b/src/flatten/flatten.spec.ts index a263ecd..2cd7e7d 100644 --- a/src/flatten/flatten.spec.ts +++ b/src/flatten/flatten.spec.ts @@ -75,6 +75,21 @@ describe('flatten', () => { expect((result as Record).isAdmin).toBeUndefined(); }); + test('Should walk plain objects whose prototype has keys, as from another realm, reading own keys only', () => { + const realmPrototype = Object.assign(Object.create(null) as object, { inherited: 1 }); + const input = { a: Object.assign(Object.create(realmPrototype) as object, { own: 2 }) }; + expect(flatten(input)).toEqual({ 'a.own': 2 }); + }); + + test('Should let errors thrown by getters through', () => { + const input = Object.defineProperty({}, 'broken', { enumerable: true, get: () => { throw new Error('getter failed'); } }); + expect(() => flatten(input)).toThrow(new Error('getter failed')); + }); + + test('Should keep working with many different delimiters', () => { + for (let i = 0; i < 100; i++) expect(flatten({ a: { b: 1 } }, { delimiter: `~${i}~` })).toEqual({ [`a~${i}~b`]: 1 }); + }); + test('Should flatten objects nested 100k levels deep without overflowing the stack', () => { let deep: Record = { leaf: true }; for (let i = 0; i < 100_000; i++) deep = { n: deep }; diff --git a/src/flatten/flatten.ts b/src/flatten/flatten.ts index 7cef81c..264da3b 100644 --- a/src/flatten/flatten.ts +++ b/src/flatten/flatten.ts @@ -3,25 +3,18 @@ import { pathFormat, validateNotation } from '../shared/notation.ts'; import { validate } from '../shared/validate.ts'; import type { Flatten, FlattenOptions } from './types.ts'; +/** An object or array being walked: its keys, the next one to visit, and its path. */ interface Frame { value: Record; - /** Object keys to visit, or `undefined` for an array, walked by index. */ - keys: string[] | undefined; + keys: string[]; index: number; - length: number; + isArray: boolean; 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; + return { value, keys: Object.keys(value), index: 0, isArray: Array.isArray(value), path, depth }; } function validateWalk(maxDepth: number, circular: string): void { @@ -33,7 +26,8 @@ function validateWalk(maxDepth: number, circular: string): void { * Flattens a nested object (or array) into a single-level object whose keys are the paths to each value. * * Only plain objects and arrays are walked: dates, maps, sets, class instances and any other object are - * kept as values. The walk uses an explicit stack, so deeply nested input cannot overflow the call stack. + * kept as values. The walk uses an explicit stack instead of recursion, so deeply nested input cannot + * overflow the call stack. * * @example * ```ts @@ -64,8 +58,8 @@ export function flatten(input: object, options: FlattenOptions = {}): Record = {}; // Containers on the current path, to tell a circular reference from the same object reused twice. @@ -74,37 +68,29 @@ export function flatten(input: object, options: FlattenOptions = {}): Record 0) { const current = stack[stack.length - 1]; - if (current.index === current.length) { + if (current.index === current.keys.length) { stack.pop(); ancestors.delete(current.value); continue; } - 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); - } + const key = current.keys[current.index++]; + const value = current.value[key]; + const path = join(current.path, transformKey && !current.isArray ? transformKey(key) : key, current.isArray); - if (typeof value === 'object' && value !== null && isContainer(value, safe) && current.depth < maxDepth && !(preserve && preserve(path, value))) { + if (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; } - if (isEmpty(value)) { + const child = frame(value, path, current.depth + 1); + if (child.keys.length === 0) { + // An empty object or array is a value of its own. if (keepEmpty) assign(result, path, value); continue; } ancestors.add(value); - stack.push(frame(value, path, current.depth + 1)); + stack.push(child); continue; } diff --git a/src/path/path.spec.ts b/src/path/path.spec.ts index 0b62086..82de6e2 100644 --- a/src/path/path.spec.ts +++ b/src/path/path.spec.ts @@ -62,6 +62,15 @@ describe('get', () => { expect(get({ a: { b: 1 } }, 'a_b', { delimiter: '_' })).toBe(1); }); + test('Should read the JSON Pointer "" as the whole object, as JSON Schema validators report it', () => { + const o = order(); + expect(get(o, '', { notation: 'pointer' })).toBe(o); + expect(has(o, '', { notation: 'pointer' })).toBe(true); + expect(del(o, '', { notation: 'pointer' })).toBe(false); + // @ts-expect-error the root has no key to set, which the types reject too + expect(() => set(o as unknown as Record, '', 1, { notation: 'pointer' })).toThrow(new TypeError('set() cannot replace the whole object: the JSON Pointer "" has no key')); + }); + test('Should throw on a non-object input or invalid options', () => { expect(() => get(null as unknown as object, 'a')).toThrow(new TypeError('get() expects an object or an array')); expect(() => get({}, 'a', { notation: 'x' as 'dot' })).toThrow(TypeError); diff --git a/src/path/path.ts b/src/path/path.ts index 6b0a161..6ce5eca 100644 --- a/src/path/path.ts +++ b/src/path/path.ts @@ -39,7 +39,8 @@ function checkTarget(input: unknown, name: string): void { } /** - * Reads the value at `path`, or `undefined` when a key is missing along the way. + * Reads the value at `path`, or `undefined` when a key is missing along the way. The JSON Pointer `''` + * is `obj` itself. * * The path is autocompleted and the result typed from `obj`. Plain objects and arrays are read through * their own keys only: `get({}, 'constructor')` is `undefined`. @@ -111,6 +112,7 @@ export function set(obj: object, path: string, value: unknown, options: SetOptio const { object = false, arrayLimit = 1000 } = options; if (arrayLimit !== Infinity && (!Number.isInteger(arrayLimit) || arrayLimit < 0)) throw new RangeError('`arrayLimit` must be a non-negative integer or Infinity'); const segments = formatOf(options).split(path); + if (segments.length === 0) throw new TypeError('set() cannot replace the whole object: the JSON Pointer "" has no key'); if (segments.includes('__proto__')) return obj; let target = obj as Target; @@ -142,6 +144,7 @@ export function del(obj: T, path: export function del(obj: object, path: string, options: PathOptions = {}): boolean { checkTarget(obj, 'del'); const segments = formatOf(options).split(path); + if (segments.length === 0) return false; let target: unknown = obj; for (let i = 0; i < segments.length - 1; i++) { if (!isTraversable(target)) return false; diff --git a/src/shared/notation.ts b/src/shared/notation.ts index b51ec7c..00fcbf8 100644 --- a/src/shared/notation.ts +++ b/src/shared/notation.ts @@ -18,7 +18,22 @@ export function validateNotation(notation: string): void { if (notation !== 'dot' && notation !== 'bracket' && notation !== 'pointer') throw new TypeError("`notation` must be 'dot', 'bracket' or 'pointer'"); } +const formats = new Map(); + +/** The path format for these options, built once and reused: most calls use the same few options. */ export function pathFormat(notation: Notation, delimiter: string, escape: boolean): PathFormat { + const cacheKey = `${notation}${escape ? 1 : 0}${delimiter}`; + let format = formats.get(cacheKey); + if (format === undefined) { + // Bounded, in case delimiters are generated dynamically. + if (formats.size >= 64) formats.clear(); + format = createPathFormat(notation, delimiter, escape); + formats.set(cacheKey, format); + } + return format; +} + +function createPathFormat(notation: Notation, delimiter: string, escape: boolean): PathFormat { if (notation === 'pointer') { return { join: (path, key) => `${path ?? ''}/${escapePointer(key)}`, @@ -27,14 +42,9 @@ export function pathFormat(notation: Notation, delimiter: string, escape: boolea } const bracket = notation === 'bracket'; const escapeKey = escape ? keyEscaper(delimiter, bracket ? '[' : '') : undefined; - const digitDelimiter = /\d/.test(delimiter); return { join: (path, key, isIndex) => { - 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; - } + if (bracket && isIndex) return `${path ?? ''}[${key}]`; const segment = escapeKey ? escapeKey(key) : key; return path === undefined ? segment : path + delimiter + segment; }, diff --git a/src/shared/path.spec.ts b/src/shared/path.spec.ts index 25350ef..12f43cb 100644 --- a/src/shared/path.spec.ts +++ b/src/shared/path.spec.ts @@ -130,7 +130,11 @@ describe('JSON Pointer', () => { expect(splitPointer(pointer)).toEqual(expected); }); - test.each(['', 'a/b', '#/a'])('Should reject %j, which does not start with "/"', (pointer) => { + test('Should read "" as the whole document', () => { + expect(splitPointer('')).toEqual([]); + }); + + 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 56bb9e7..3295f04 100644 --- a/src/shared/path.ts +++ b/src/shared/path.ts @@ -132,8 +132,9 @@ 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']`). */ +/** Splits a JSON Pointer (`/a/0/b~1c` → `['a', '0', 'b/c']`). `''` is the whole document: no keys. */ export function splitPointer(pointer: string): string[] { + if (pointer === '') return []; if (!pointer.startsWith('/')) throw new TypeError(`A JSON Pointer must start with "/", got "${pointer}"`); return pointer .slice(1) diff --git a/src/unflatten/unflatten.spec.ts b/src/unflatten/unflatten.spec.ts index fad1c56..be1f2f8 100644 --- a/src/unflatten/unflatten.spec.ts +++ b/src/unflatten/unflatten.spec.ts @@ -54,6 +54,25 @@ describe('unflatten', () => { expect(meta).toEqual({ a: 1 }); }); + test('Should tell empty values apart even when Object.prototype was polluted by other code', () => { + const proto = Object.prototype as Record; + proto.polluted = true; + try { + expect(Object.keys(unflatten({ 'a.b': 1, a: {} }, { overwrite: true }).a as object)).toEqual(['b']); + } finally { + delete proto.polluted; + } + }); + + test('Should never modify objects nested in values of the input', () => { + const inner = { y: 1 }; + const list = [{ id: 1 }]; + const input = { a: { x: inner }, 'a.x.z': 2, b: list, 'b.0.name': 'x' }; + expect(unflatten(input)).toEqual({ a: { x: { y: 1, z: 2 } }, b: [{ id: 1, name: 'x' }] }); + expect(inner).toEqual({ y: 1 }); + expect(list).toEqual([{ id: 1 }]); + }); + test('Should keep values by reference', () => { const date = new Date(); const nested = { deep: true }; @@ -152,6 +171,7 @@ describe('unflatten options', () => { 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"')); + expect(() => unflatten({ '': 1 }, { notation: 'pointer' })).toThrow(new TypeError('The JSON Pointer "" (the whole document) cannot be a key of a flat object')); }); test('notation: Should not pollute Object.prototype with any notation', () => { diff --git a/src/unflatten/unflatten.ts b/src/unflatten/unflatten.ts index c238d47..81a2473 100644 --- a/src/unflatten/unflatten.ts +++ b/src/unflatten/unflatten.ts @@ -12,42 +12,44 @@ 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'); } +/** An object built for a key followed by an array index (`list` for `'list.0'`): it may become an array. */ +type Candidate = [parent: Container, key: string, object: Container]; + /** * Walks from `parent` into `key`, creating the object when it is missing. Returns `undefined` when a * value that is not an object or array is in the way and `overwrite` is off. + * + * `owned` holds the objects built by `unflatten`. Objects and arrays that came in as values of `input` + * are not in it: they are copied before new keys go in, never modified. */ -function descend(parent: Container, key: string, overwrite: boolean, owned: WeakSet): Container | undefined { +function descend(parent: Container, key: string, overwrite: boolean, owned: Set, candidates: Candidate[] | undefined): Container | undefined { // Own properties only: `constructor` or `toString` must not resolve to what `Object.prototype` inherits. const current = hasOwn(parent, key) ? parent[key] : undefined; - if (typeof current === 'object' && current !== null && owned.has(current)) return current as Container; + if (isContainer(current, false) && owned.has(current)) return current; if (current !== undefined && !isContainer(current, false) && !overwrite) return undefined; const next: Container = {}; - // An object or array coming from the input is copied before new keys go in, never modified. if (isContainer(current, false)) for (const k of Object.keys(current)) assign(next, k, current[k]); owned.add(next); assign(parent, key, next); + candidates?.push([parent, key, next]); return next; } -/** Turns the objects built by `unflatten` whose keys are all array indices into arrays. */ -function restoreArrays(root: Container, arrayLimit: number, owned: WeakSet): void { - const stack: Container[] = [root]; - while (stack.length > 0) { - const node = stack.pop() as Container; - for (const key of Object.keys(node)) { - const child = node[key]; - if (typeof child !== 'object' || child === null || !owned.has(child)) continue; - const keys = Object.keys(child); - let next = child as Container; - if (keys.length > 0 && keys.every((k) => isIndex(k, arrayLimit))) { - const array: unknown[] = []; - for (const k of keys) array[Number(k)] = (child as Container)[k]; - next = array as unknown as Container; - node[key] = array; - } - stack.push(next); - } +/** + * Turns the candidates whose keys are all array indices into arrays. They are visited from the last one + * built, so nested arrays are in place before the object that holds them is converted. + */ +function restoreArrays(candidates: Candidate[], arrayLimit: number): void { + for (let c = candidates.length - 1; c >= 0; c--) { + const [parent, key, object] = candidates[c]; + // Replaced by a later key with `overwrite`. + if (parent[key] !== object) continue; + const keys = Object.keys(object); + if (!keys.every((k) => isIndex(k, arrayLimit))) continue; + const array: unknown[] = []; + for (const k of keys) array[Number(k)] = object[k]; + parent[key] = array; } } @@ -93,11 +95,12 @@ export function unflatten(input: object, options: UnflattenOptions = {}): Record if (!isPlainObject(input)) throw new TypeError('unflatten() expects a plain object'); const result: Container = {}; - // Objects created here, as opposed to objects that came in as values and must not be modified. - const owned = new WeakSet([result]); + const owned = new Set([result]); + const candidates: Candidate[] | undefined = object ? undefined : []; entries: for (const key of Object.keys(input)) { const segments = split(key); + if (segments.length === 0) throw new TypeError('The JSON Pointer "" (the whole document) cannot be a key of a flat object'); 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]); @@ -105,7 +108,7 @@ export function unflatten(input: object, options: UnflattenOptions = {}): Record let target = result; for (let i = 0; i < segments.length - 1; i++) { - const next = descend(target, segments[i], overwrite, owned); + const next = descend(target, segments[i], overwrite, owned, candidates && isIndex(segments[i + 1], arrayLimit) ? candidates : undefined); if (next === undefined) continue entries; target = next; } @@ -113,13 +116,14 @@ export function unflatten(input: object, options: UnflattenOptions = {}): Record const last = segments[segments.length - 1]; const value = input[key]; const current = hasOwn(target, last) ? target[last] : undefined; - // An empty object or array (kept by `flatten`) never replaces keys that were already set below it. + // Keys already set below this path are kept, unless `overwrite` replaces them with this value. An + // empty object or array (kept by `flatten`) never replaces them. const isEmpty = isContainer(value, false) && Object.keys(value).length === 0; - if (isContainer(current, false) && (isEmpty || !overwrite)) continue; + if (isContainer(current, false) && (!overwrite || isEmpty)) continue; assign(target, last, value); } - if (!object) restoreArrays(result, arrayLimit, owned); + if (candidates) restoreArrays(candidates, arrayLimit); return asArray ? toRootArray(result, arrayLimit) : result; } diff --git a/vitest.config.mts b/vitest.config.mts index bd0e8cd..4f5ae47 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // bench/ has its own config: `pnpm bench` and `pnpm claims`. + include: ['src/**/*.spec.ts'], allowOnly: true, coverage: { provider: 'istanbul', From 2dc214848a146d75f1d50ffdf9aad0fcedea82de Mon Sep 17 00:00:00 2001 From: Fernando Machado Bernardino Date: Sat, 26 Sep 2026 02:33:35 -0300 Subject: [PATCH 2/2] docs: rewrite the README for 2.0, with verified comparisons A lean README led by data integrity and a feature comparison with flat, flattie/nestie, es-toolkit and radashi, where every row is checked by bench/claims.spec.ts (pnpm claims, also in CI). bench/size.js measures sizes (pnpm size) and benchmarks load dist/ natively. unflatten builds arrays from candidates tracked while building, and the JSON Pointer '' reads as the whole document in get and has. --- README.md | 2 +- src/flatten/flatten.spec.ts | 4 ---- src/shared/guards.ts | 7 +------ src/shared/notation.ts | 15 --------------- src/shared/path.ts | 8 +------- 5 files changed, 3 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 279c258..675a96b 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Every row is checked by [`bench/claims.spec.ts`](./bench/claims.spec.ts) against | Bracket notation and JSON Pointer | ✅ | ❌ | ❌ | ❌ | ❌ | | `get` / `set` with autocompleted paths | ✅ | ❌ | ❌ | ❌ | ❌ | | ESM and CommonJS | ✅ | ❌ v6 is ESM only | ✅ | ✅ | ✅ | -| Size (min + gzip) | 2.4 kB | 0.7 kB | 0.35 kB | 0.34 kB | 0.8 kB | +| Size (min + gzip) | 2.3 kB | 0.7 kB | 0.35 kB | 0.34 kB | 0.8 kB | flattify is bigger because it does more: the checks above, three notations, escaping and clear error messages. `flattify/path` is a separate import, so it only counts when you use it. diff --git a/src/flatten/flatten.spec.ts b/src/flatten/flatten.spec.ts index 2cd7e7d..e93de5c 100644 --- a/src/flatten/flatten.spec.ts +++ b/src/flatten/flatten.spec.ts @@ -86,10 +86,6 @@ describe('flatten', () => { expect(() => flatten(input)).toThrow(new Error('getter failed')); }); - test('Should keep working with many different delimiters', () => { - for (let i = 0; i < 100; i++) expect(flatten({ a: { b: 1 } }, { delimiter: `~${i}~` })).toEqual({ [`a~${i}~b`]: 1 }); - }); - test('Should flatten objects nested 100k levels deep without overflowing the stack', () => { let deep: Record = { leaf: true }; for (let i = 0; i < 100_000; i++) deep = { n: deep }; diff --git a/src/shared/guards.ts b/src/shared/guards.ts index 43e2baf..77cf9f0 100644 --- a/src/shared/guards.ts +++ b/src/shared/guards.ts @@ -19,11 +19,6 @@ export function isContainer(input: unknown, safe: boolean): input is Record, key: string, value: unknown): void { - if (key === '__proto__') defineOwn(target, key, value); + if (key === '__proto__') Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); 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 00fcbf8..693ad6a 100644 --- a/src/shared/notation.ts +++ b/src/shared/notation.ts @@ -18,22 +18,7 @@ export function validateNotation(notation: string): void { if (notation !== 'dot' && notation !== 'bracket' && notation !== 'pointer') throw new TypeError("`notation` must be 'dot', 'bracket' or 'pointer'"); } -const formats = new Map(); - -/** The path format for these options, built once and reused: most calls use the same few options. */ export function pathFormat(notation: Notation, delimiter: string, escape: boolean): PathFormat { - const cacheKey = `${notation}${escape ? 1 : 0}${delimiter}`; - let format = formats.get(cacheKey); - if (format === undefined) { - // Bounded, in case delimiters are generated dynamically. - if (formats.size >= 64) formats.clear(); - format = createPathFormat(notation, delimiter, escape); - formats.set(cacheKey, format); - } - return format; -} - -function createPathFormat(notation: Notation, delimiter: string, escape: boolean): PathFormat { if (notation === 'pointer') { return { join: (path, key) => `${path ?? ''}/${escapePointer(key)}`, diff --git a/src/shared/path.ts b/src/shared/path.ts index 3295f04..3194a30 100644 --- a/src/shared/path.ts +++ b/src/shared/path.ts @@ -53,13 +53,7 @@ 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 { - 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; + return /^(?:0|[1-9]\d*)$/.test(segment) && Number(segment) <= limit; } /**