Skip to content

Commit

Permalink
feat: accept comparator function as deterministic option (#49)
Browse files Browse the repository at this point in the history
Accept `Array#sort(comparator)` comparator method as deterministic option value to use that comparator for sorting object keys.

```js
import { configure } from 'safe-stable-stringify'

const object = {
  a: 1,
  b: 2,
  c: 3,
}

const stringify = configure({
  deterministic: (a, b) => b.localeCompare(a)
})

stringify(object)
// '{"c": 3,"b":2,"a":1}'
  • Loading branch information
flobernd authored Aug 1, 2024
1 parent 7b7ec1b commit 9a988d0
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 9 deletions.
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function stringify(value: unknown, replacer?: ((key: string, value: unkno
export interface StringifyOptions {
bigint?: boolean,
circularValue?: string | null | TypeErrorConstructor | ErrorConstructor,
deterministic?: boolean,
deterministic?: boolean | ((a: string, b: string) => number),
maximumBreadth?: number,
maximumDepth?: number,
strict?: boolean,
Expand Down
34 changes: 26 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ function strEscape (str) {
return JSON.stringify(str)
}

function insertSort (array) {
// Insertion sort is very efficient for small input sizes but it has a bad
function insertSort (array, comparator) {
// Insertion sort is very efficient for small input sizes, but it has a bad
// worst case complexity. Thus, use native array sort for bigger values.
if (array.length > 2e2) {
return array.sort()
if (array.length > 2e2 || comparator) {
return array.sort(comparator)
}
for (let i = 1; i < array.length; i++) {
const currentValue = array[i]
Expand Down Expand Up @@ -97,6 +97,23 @@ function getCircularValueOption (options) {
return '"[Circular]"'
}

function getDeterministicOption (options, key) {
let value
if (hasOwnProperty.call(options, key)) {
value = options[key]
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'function') {
return value
}
}
if (value === undefined) {
return true
}
throw new TypeError(`The "${key}" argument must be of type boolean or comparator function`)
}

function getBooleanOption (options, key) {
let value
if (hasOwnProperty.call(options, key)) {
Expand Down Expand Up @@ -171,7 +188,8 @@ function configure (options) {
}
const circularValue = getCircularValueOption(options)
const bigint = getBooleanOption(options, 'bigint')
const deterministic = getBooleanOption(options, 'deterministic')
const deterministic = getDeterministicOption(options, 'deterministic')
const comparator = typeof deterministic === 'function' ? deterministic : undefined
const maximumDepth = getPositiveIntegerOption(options, 'maximumDepth')
const maximumBreadth = getPositiveIntegerOption(options, 'maximumBreadth')

Expand Down Expand Up @@ -248,7 +266,7 @@ function configure (options) {
}
const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth)
if (deterministic && !isTypedArrayWithEntries(value)) {
keys = insertSort(keys)
keys = insertSort(keys, comparator)
}
stack.push(value)
for (let i = 0; i < maximumPropertiesToStringify; i++) {
Expand Down Expand Up @@ -447,7 +465,7 @@ function configure (options) {
separator = join
}
if (deterministic) {
keys = insertSort(keys)
keys = insertSort(keys, comparator)
}
stack.push(value)
for (let i = 0; i < maximumPropertiesToStringify; i++) {
Expand Down Expand Up @@ -551,7 +569,7 @@ function configure (options) {
separator = ','
}
if (deterministic) {
keys = insertSort(keys)
keys = insertSort(keys, comparator)
}
stack.push(value)
for (let i = 0; i < maximumPropertiesToStringify; i++) {
Expand Down
41 changes: 41 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1303,3 +1303,44 @@ test('strict option replacer array', (assert) => {

assert.end()
})

test('deterministic option possibilities', (assert) => {
assert.throws(() => {
// @ts-expect-error
stringify.configure({ deterministic: 1 })
}, {
message: 'The "deterministic" argument must be of type boolean or comparator function',
name: 'TypeError'
})

const serializer1 = stringify.configure({ deterministic: false })
serializer1(NaN)

const serializer2 = stringify.configure({ deterministic: (a, b) => a.localeCompare(b) })
serializer2(NaN)

assert.end()
})

test('deterministic default sorting', function (assert) {
const serializer = stringify.configure({ deterministic: true })

const obj = { b: 2, c: 3, a: 1 }
const expected = '{\n "a": 1,\n "b": 2,\n "c": 3\n}'
const actual = serializer(obj, null, 1)
assert.equal(actual, expected)

assert.end()
})

test('deterministic custom sorting', function (assert) {
// Descending
const serializer = stringify.configure({ deterministic: (a, b) => b.localeCompare(a) })

const obj = { b: 2, c: 3, a: 1 }
const expected = '{\n "c": 3,\n "b": 2,\n "a": 1\n}'
const actual = serializer(obj, null, 1)
assert.equal(actual, expected)

assert.end()
})

0 comments on commit 9a988d0

Please sign in to comment.