Skip to content

Commit

Permalink
feat: 🎸 add json-size implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Oct 6, 2024
1 parent 8a595a4 commit 6dce811
Show file tree
Hide file tree
Showing 11 changed files with 526 additions and 0 deletions.
38 changes: 38 additions & 0 deletions src/json-size/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# `json-size`

This library implements methods to calculate the size of JSON objects.
It calculates the size of bytes necessary to store the final serialized JSON
in UTF-8 encoding.

## Usage

```ts
import {jsonSize} from 'json-joy/{lib,es6}/json-size';

jsonSize({1: 2, foo: 'bar'}); // 19
```

## Reference

- `jsonSize` — calculates exact JSON size, as `JSON.stringify()` would return.
- `jsonSizeApprox` — a faster version, which uses string nominal length for calculation.
- `jsonSizeFast` — the fastest version, which uses nominal values for all JSON types. See
source code for description.
- `msgpackSizeFast` — same as `jsonSizeFast`, but for MessagePack values. In addition
to regular JSON values it also supports binary data (by `Buffer` or `Uint8Array`),
`JsonPackExtension`, and `JsonPackValue`.

## Performance

In most cases `json-size` will be faster than `JSON.stringify`.

```
node benchmarks/json-size.js
json-joy/json-size jsonSize() x 377,980 ops/sec ±0.12% (100 runs sampled), 2646 ns/op
json-joy/json-size jsonSizeApprox() x 377,841 ops/sec ±0.09% (98 runs sampled), 2647 ns/op
json-joy/json-size jsonSizeFast() x 2,229,344 ops/sec ±0.30% (101 runs sampled), 449 ns/op
json-joy/json-size msgpackSizeFast() x 1,260,284 ops/sec ±0.10% (96 runs sampled), 793 ns/op
JSON.stringify x 349,696 ops/sec ±0.08% (100 runs sampled), 2860 ns/op
JSON.stringify + utf8Count x 182,977 ops/sec ±0.10% (100 runs sampled), 5465 ns/op
Fastest is json-joy/json-size jsonSizeFast()
```
80 changes: 80 additions & 0 deletions src/json-size/__bench__/json-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* tslint:disable no-console */

// npx ts-node src/json-size/__bench__/json-size.ts

import * as Benchmark from 'benchmark';
import {utf8Size} from '@jsonjoy.com/util/lib/strings/utf8';
import {jsonSize, jsonSizeApprox} from '../json';
import {jsonSizeFast} from '../jsonSizeFast';
import {msgpackSizeFast} from '../msgpackSizeFast';

const json = [
{op: 'add', path: '/foo/baz', value: 666},
{op: 'add', path: '/foo/bx', value: 666},
{op: 'add', path: '/asdf', value: 'asdfadf asdf'},
{op: 'move', path: '/arr/0', from: '/arr/1'},
{op: 'replace', path: '/foo/baz', value: 'lorem ipsum'},
{
op: 'add',
path: '/docs/latest',
value: {
name: 'blog post',
json: {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
longString:
'lorem ipsum dolorem, alamorem colomorem, ipsum pipsum, lorem ipsum dolorem, alamorem colomorem, ipsum pipsum, lorem ipsum dolorem, alamorem colomorem, ipsum pipsum, lorem ipsum dolorem, alamorem colomorem, ipsum pipsum, lorem ipsum dolorem, alamorem colomorem, ipsum pipsum',
author: {
name: 'John 💪',
handle: '@johny',
},
lastSeen: -12345,
tags: [null, 'Sports 🏀', 'Personal', 'Travel'],
pins: [
{
id: 1239494,
},
],
marks: [
{
x: 1,
y: 1.234545,
w: 0.23494,
h: 0,
},
],
hasRetweets: false,
approved: true,
'👍': 33,
},
},
},
];

const suite = new Benchmark.Suite();

suite
.add(`json-joy/json-size jsonSize()`, () => {
jsonSize(json);
})
.add(`json-joy/json-size jsonSizeApprox()`, () => {
jsonSizeApprox(json);
})
.add(`json-joy/json-size jsonSizeFast()`, () => {
jsonSizeFast(json);
})
.add(`json-joy/json-size msgpackSizeFast()`, () => {
msgpackSizeFast(json);
})
.add(`JSON.stringify`, () => {
JSON.stringify(json).length;
})
.add(`JSON.stringify + utf8Count`, () => {
utf8Size(JSON.stringify(json));
})
.on('cycle', (event: any) => {
console.log(String(event.target) + `, ${Math.round(1000000000 / event.target.hz)} ns/op`);
})
.on('complete', () => {
console.log('Fastest is ' + suite.filter('fastest').map('name'));
})
.run();
16 changes: 16 additions & 0 deletions src/json-size/__tests__/fuzz.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {jsonSize} from '..';
import {RandomJson} from '../../json-random/RandomJson';
import {utf8Size} from '../../strings/utf8';

const random = new RandomJson();
const iterations = 100;

for (let i = 0; i < iterations; i++) {
test(`calculates json size - ${i + 1}`, () => {
const json = random.create();
// console.log(json);
const size1 = jsonSize(json);
const size2 = utf8Size(JSON.stringify(json));
expect(size1).toBe(size2);
});
}
10 changes: 10 additions & 0 deletions src/json-size/__tests__/json.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {jsonSize, jsonSizeApprox} from '../json';
import {testJsonSize} from './testJsonSize';

describe('jsonSize', () => {
testJsonSize(jsonSize);
});

describe('jsonSizeApprox', () => {
testJsonSize(jsonSizeApprox, {simpleStringsOnly: true});
});
61 changes: 61 additions & 0 deletions src/json-size/__tests__/jsonSizeFast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {jsonSizeFast} from '../jsonSizeFast';

test('computes size of single values', () => {
expect(jsonSizeFast(null)).toBe(1);
expect(jsonSizeFast(true)).toBe(1);
expect(jsonSizeFast(false)).toBe(1);
expect(jsonSizeFast(1)).toBe(9);
expect(jsonSizeFast(1.1)).toBe(9);
expect(jsonSizeFast('123')).toBe(7);
expect(jsonSizeFast('')).toBe(4);
expect(jsonSizeFast('A')).toBe(5);
expect(jsonSizeFast([])).toBe(2);
expect(jsonSizeFast({})).toBe(2);
});

test('computes size complex object', () => {
// prettier-ignore
const json = { // 2
a: 1, // 2 + 1 + 9
b: true, // 2 + 1 + 1
c: false, // 2 + 1 + 1
d: null, // 2 + 1 + 1
'e.e': 2.2, // 2 + 3 + 9
f: '', // 2 + 1 + 4 + 0
g: 'asdf', // 2 + 1 + 4 + 4
h: {}, // 2 + 1 + 2
i: [ // 2 + 1 + 2
1, // 9
true, // 1
false, // 1
null, // 1
2.2, // 9
'', // 4 + 0
'asdf', // 4 + 4
{}, // 2
],
};
const size = jsonSizeFast(json);

// prettier-ignore
expect(size).toBe(
2 +
2 + 1 + 9 +
2 + 1 + 1 +
2 + 1 + 1 +
2 + 1 + 1 +
2 + 3 + 9 +
2 + 1 + 4 + 0 +
2 + 1 + 4 + 4 +
2 + 1 + 2 +
2 + 1 + 2 +
9 +
1 +
1 +
1 +
9 +
4 + 0 +
4 + 4 +
2
);
});
47 changes: 47 additions & 0 deletions src/json-size/__tests__/maxEncodingCapacity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {maxEncodingCapacity} from '../maxEncodingCapacity';

test('computes size of single values', () => {
expect(maxEncodingCapacity(null)).toBe(4);
expect(maxEncodingCapacity(true)).toBe(5);
expect(maxEncodingCapacity(false)).toBe(5);
expect(maxEncodingCapacity(1)).toBe(22);
expect(maxEncodingCapacity(1.1)).toBe(22);
expect(maxEncodingCapacity('123')).toBe(20);
expect(maxEncodingCapacity('')).toBe(5);
expect(maxEncodingCapacity('A')).toBe(10);
expect(maxEncodingCapacity([])).toBe(5);
expect(maxEncodingCapacity({})).toBe(5);
expect(maxEncodingCapacity({foo: 1})).toBe(49);
expect(maxEncodingCapacity({foo: [1]})).toBe(55);
});

test('a larger value', () => {
expect(
maxEncodingCapacity({
name: 'cooking receipt',
json: {
id: '0001',
type: 'donut',
name: 'Cake',
ppu: 0.55,
batters: {
batter: [
{id: '1001', type: 'Regular'},
{id: '1002', type: 'Chocolate'},
{id: '1003', type: 'Blueberry'},
{id: '1004', type: "Devil's Food"},
],
},
topping: [
{id: '5001', type: 'None'},
{id: '5002', type: 'Glazed'},
{id: '5005', type: 'Sugar'},
{id: '5007', type: 'Powdered Sugar'},
{id: '5006', type: 'Chocolate with Sprinkles'},
{id: '5003', type: 'Chocolate'},
{id: '5004', type: 'Maple'},
],
},
}),
).toBe(1875);
});
66 changes: 66 additions & 0 deletions src/json-size/__tests__/testJsonSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {utf8Size} from '../../strings/utf8';

export const testJsonSize = (
jsonSize: (val: unknown) => number,
{simpleStringsOnly = false}: {simpleStringsOnly?: boolean} = {},
) => {
test('calculates null size', () => {
expect(jsonSize(null)).toBe(4);
});

test('calculates boolean sizes', () => {
expect(jsonSize(true)).toBe(4);
expect(jsonSize(false)).toBe(5);
});

test('calculates number sizes', () => {
expect(jsonSize(1)).toBe(1);
expect(jsonSize(1.1)).toBe(3);
expect(jsonSize(0)).toBe(1);
expect(jsonSize(1.123)).toBe(5);
expect(jsonSize(-1.123)).toBe(6);
});

if (!simpleStringsOnly) {
test('calculates string sizes', () => {
expect(jsonSize('')).toBe(2);
expect(jsonSize('a')).toBe(3);
expect(jsonSize('abc')).toBe(5);
expect(jsonSize('👨‍👩‍👦‍👦')).toBe(27);
expect(jsonSize('büro')).toBe(7);
expect(jsonSize('office')).toBe(8);
});
}

if (!simpleStringsOnly) {
test('calculates string sizes with escaped characters', () => {
expect(jsonSize('\\')).toBe(4);
expect(jsonSize('"')).toBe(4);
expect(jsonSize('\b')).toBe(4);
expect(jsonSize('\f')).toBe(4);
expect(jsonSize('\n')).toBe(4);
expect(jsonSize('\r')).toBe(4);
expect(jsonSize('\t')).toBe(4);
});
}

test('calculates array sizes', () => {
expect(jsonSize([])).toBe(2);
expect(jsonSize([1])).toBe(3);
expect(jsonSize([1, 2, 3])).toBe(7);
expect(jsonSize([1, 'büro', 3])).toBe(13);
});

test('calculates object sizes', () => {
expect(jsonSize({})).toBe(2);
expect(jsonSize({a: 1})).toBe(2 + 3 + 1 + 1);
expect(jsonSize({1: 2, foo: 'bar'})).toBe(2 + 3 + 1 + 1 + 1 + 5 + 1 + 5);
});

test('calculates size of array of length 2 that begins with empty string', () => {
const json = ['', -1];
const size1 = jsonSize(json);
const size2 = utf8Size(JSON.stringify(json));
expect(size1).toBe(size2);
});
};
3 changes: 3 additions & 0 deletions src/json-size/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './json';
export * from './jsonSizeFast';
export * from './maxEncodingCapacity';
Loading

0 comments on commit 6dce811

Please sign in to comment.