Skip to content

Commit

Permalink
Add propertyOrder option to ParseOptions to control the order of … (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jun 5, 2024
1 parent ae55d07 commit 4c6bc7f
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 12 deletions.
73 changes: 73 additions & 0 deletions .changeset/pretty-yaks-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
"@effect/schema": patch
---

Add `propertyOrder` option to `ParseOptions` to control the order of keys in the output, closes #2925.

The `propertyOrder` option provides control over the order of object fields in the output. This feature is particularly useful when the sequence of keys is important for the consuming processes or when maintaining the input order enhances readability and usability.

By default, the `propertyOrder` option is set to `"none"`. This means that the internal system decides the order of keys to optimize parsing speed. The order of keys in this mode should not be considered stable, and it's recommended not to rely on key ordering as it may change in future updates without notice.

Setting `propertyOrder` to `"input"` ensures that the keys are ordered as they appear in the input during the decoding/encoding process.

**Example** (Synchronous Decoding)

```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
a: Schema.Number,
b: Schema.Literal("b"),
c: Schema.Number
})

// Decoding an object synchronously without specifying the property order
console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }))
// Output decided internally: { b: 'b', a: 1, c: 2 }

// Decoding an object synchronously while preserving the order of properties as in the input
console.log(
Schema.decodeUnknownSync(schema)(
{ b: "b", c: 2, a: 1 },
{ propertyOrder: "original" }
)
)
// Output preserving input order: { b: 'b', c: 2, a: 1 }
```

**Example** (Asynchronous Decoding)

```ts
import { ParseResult, Schema } from "@effect/schema"
import type { Duration } from "effect"
import { Effect } from "effect"

// Function to simulate an asynchronous process within the schema
const effectify = (duration: Duration.DurationInput) =>
Schema.Number.pipe(
Schema.transformOrFail(Schema.Number, {
decode: (x) =>
Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)

// Define a structure with asynchronous behavior in each field
const schema = Schema.Struct({
a: effectify("200 millis"),
b: effectify("300 millis"),
c: effectify("100 millis")
}).annotations({ concurrency: 3 })

// Decoding data asynchronously without preserving order
Schema.decode(schema)({ a: 1, b: 2, c: 3 })
.pipe(Effect.runPromise)
.then(console.log)
// Output decided internally: { c: 3, a: 1, b: 2 }

// Decoding data asynchronously while preserving the original input order
Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" })
.pipe(Effect.runPromise)
.then(console.log)
// Output preserving input order: { a: 1, b: 2, c: 3 }
```
70 changes: 70 additions & 0 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,76 @@ Error: { readonly name: string; readonly age: number }
> [!NOTE]
> The [`onExcessProperty`](#excess-properties) and [`error`](#all-errors) options also affect encoding.
### Managing Property Order

The `propertyOrder` option provides control over the order of object fields in the output. This feature is particularly useful when the sequence of keys is important for the consuming processes or when maintaining the input order enhances readability and usability.

By default, the `propertyOrder` option is set to `"none"`. This means that the internal system decides the order of keys to optimize parsing speed. The order of keys in this mode should not be considered stable, and it's recommended not to rely on key ordering as it may change in future updates without notice.

Setting `propertyOrder` to `"input"` ensures that the keys are ordered as they appear in the input during the decoding/encoding process.

**Example** (Synchronous Decoding)

```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
a: Schema.Number,
b: Schema.Literal("b"),
c: Schema.Number
})

// Decoding an object synchronously without specifying the property order
console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }))
// Output decided internally: { b: 'b', a: 1, c: 2 }

// Decoding an object synchronously while preserving the order of properties as in the input
console.log(
Schema.decodeUnknownSync(schema)(
{ b: "b", c: 2, a: 1 },
{ propertyOrder: "original" }
)
)
// Output preserving input order: { b: 'b', c: 2, a: 1 }
```

**Example** (Asynchronous Decoding)

```ts
import { ParseResult, Schema } from "@effect/schema"
import type { Duration } from "effect"
import { Effect } from "effect"

// Function to simulate an asynchronous process within the schema
const effectify = (duration: Duration.DurationInput) =>
Schema.Number.pipe(
Schema.transformOrFail(Schema.Number, {
decode: (x) =>
Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)

// Define a structure with asynchronous behavior in each field
const schema = Schema.Struct({
a: effectify("200 millis"),
b: effectify("300 millis"),
c: effectify("100 millis")
}).annotations({ concurrency: 3 })

// Decoding data asynchronously without preserving order
Schema.decode(schema)({ a: 1, b: 2, c: 3 })
.pipe(Effect.runPromise)
.then(console.log)
// Output decided internally: { c: 3, a: 1, b: 2 }

// Decoding data asynchronously while preserving the original input order
Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" })
.pipe(Effect.runPromise)
.then(console.log)
// Output preserving input order: { a: 1, b: 2, c: 3 }
```

## Encoding

The `@effect/schema/Schema` module provides several `encode*` functions to encode data according to a schema:
Expand Down
49 changes: 49 additions & 0 deletions packages/schema/benchmark/preserveKeyOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as ParseResult from "@effect/schema/ParseResult"
import * as S from "@effect/schema/Schema"
import { Bench } from "tinybench"

/*
┌─────────┬────────────────────────────────────────────────────────────┬──────────────┬────────────────────┬──────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ns) │ Margin │ Samples │
├─────────┼────────────────────────────────────────────────────────────┼──────────────┼────────────────────┼──────────┼──────────┤
│ 0 │ 'decodeUnknownEither (valid input)' │ '1,249,635' │ 800.2333527737999 │ '±0.30%' │ 1249636 │
│ 1 │ 'decodeUnknownEitherPreserveInputKeyOrder (valid input)' │ '853,288' │ 1171.9369854943957 │ '±0.17%' │ 853289 │
│ 2 │ 'decodeUnknownEither (invalid input)' │ '11,534,459' │ 86.69673906736591 │ '±0.31%' │ 11534460 │
│ 3 │ 'decodeUnknownEitherPreserveInputKeyOrder (invalid input)' │ '11,435,077' │ 87.45021734921293 │ '±0.34%' │ 11435078 │
└─────────┴────────────────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────┴──────────┘
*/

const bench = new Bench({ time: 1000 })

const schema = S.Struct({
a: S.Literal("a"),
b: S.Array(S.String),
c: S.Record(S.String, S.Number),
d: S.NumberFromString,
e: S.Boolean
})

const validInput = { a: "a", b: ["b"], c: { c: 1 }, d: "1", e: true }

const invalidInput = { b: ["b"], c: { c: 1 }, d: "1", e: true, a: null }

const decodeUnknownEither = ParseResult.decodeUnknownEither(schema)
const decodeUnknownEitherPreserveInputKeyOrder = ParseResult.decodeUnknownEither(schema, { propertyOrder: "original" })

bench
.add("decodeUnknownEither (valid input)", function() {
decodeUnknownEither(validInput)
})
.add("decodeUnknownEitherPreserveInputKeyOrder (valid input)", function() {
decodeUnknownEitherPreserveInputKeyOrder(validInput)
})
.add("decodeUnknownEither (invalid input)", function() {
decodeUnknownEither(invalidInput)
})
.add("decodeUnknownEitherPreserveInputKeyOrder (invalid input)", function() {
decodeUnknownEitherPreserveInputKeyOrder(invalidInput)
})

await bench.run()

console.table(bench.table())
6 changes: 6 additions & 0 deletions packages/schema/src/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,12 @@ export interface ParseOptions {
readonly errors?: "first" | "all" | undefined
/** default "ignore" */
readonly onExcessProperty?: "ignore" | "error" | "preserve" | undefined
/**
* default "none"
*
* @since 0.67.20
*/
readonly propertyOrder?: "none" | "original" | undefined
}

/**
Expand Down
42 changes: 30 additions & 12 deletions packages/schema/src/ParseResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,10 +1147,12 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
}

const propertySignatures: Array<readonly [Parser, AST.PropertySignature]> = []
const expectedKeys: Record<PropertyKey, null> = {}
const expectedKeysMap: Record<PropertyKey, null> = {}
const expectedKeys: Array<PropertyKey> = []
for (const ps of ast.propertySignatures) {
propertySignatures.push([goMemo(ps.type, isDecoding), ps])
expectedKeys[ps.name] = null
expectedKeysMap[ps.name] = null
expectedKeys.push(ps.name)
}

const indexSignatures = ast.indexSignatures.map((is) =>
Expand All @@ -1162,9 +1164,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
)
const expectedAST = AST.Union.make(
ast.indexSignatures.map((is): AST.AST => is.parameter).concat(
util_.ownKeys(expectedKeys).map((key) =>
Predicate.isSymbol(key) ? new AST.UniqueSymbol(key) : new AST.Literal(key)
)
expectedKeys.map((key) => Predicate.isSymbol(key) ? new AST.UniqueSymbol(key) : new AST.Literal(key))
)
)
const expected = goMemo(expectedAST, isDecoding)
Expand All @@ -1184,8 +1184,10 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
const onExcessPropertyError = options?.onExcessProperty === "error"
const onExcessPropertyPreserve = options?.onExcessProperty === "preserve"
const output: any = {}
let inputKeys: Array<PropertyKey> | undefined
if (onExcessPropertyError || onExcessPropertyPreserve) {
for (const key of util_.ownKeys(input)) {
inputKeys = util_.ownKeys(input)
for (const key of inputKeys) {
const eu = eitherOrUndefined(expected(key, options))!
if (Either.isLeft(eu)) {
// key is unexpected
Expand Down Expand Up @@ -1302,7 +1304,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
return Either.left(new TypeLiteral(ast, input, [e], output))
}
} else {
if (!Object.prototype.hasOwnProperty.call(expectedKeys, key)) {
if (!Object.prototype.hasOwnProperty.call(expectedKeysMap, key)) {
output[key] = veu.right
}
}
Expand All @@ -1326,7 +1328,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
return Either.left(new TypeLiteral(ast, input, [e], output))
}
} else {
if (!Object.prototype.hasOwnProperty.call(expectedKeys, key)) {
if (!Object.prototype.hasOwnProperty.call(expectedKeysMap, key)) {
output[key] = tv.right
}
return Effect.void
Expand All @@ -1341,10 +1343,26 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
// ---------------------------------------------
// compute result
// ---------------------------------------------
const computeResult = ({ es, output }: State) =>
Arr.isNonEmptyArray(es) ?
Either.left(new TypeLiteral(ast, input, sortByIndex(es), output)) :
Either.right(output)
const computeResult = ({ es, output }: State) => {
if (Arr.isNonEmptyArray(es)) {
return Either.left(new TypeLiteral(ast, input, sortByIndex(es), output))
}
if (options?.propertyOrder === "original") {
// preserve input keys order
const keys = inputKeys || util_.ownKeys(input)
for (const name of expectedKeys) {
if (keys.indexOf(name) === -1) {
keys.push(name)
}
}
const out: any = {}
for (const key of keys) {
out[key] = output[key]
}
return Either.right(out)
}
return Either.right(output)
}
if (queue && queue.length > 0) {
const cqueue = queue
return Effect.suspend(() => {
Expand Down
71 changes: 71 additions & 0 deletions packages/schema/test/Schema/ParseOptions-preserveKeyOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as ParseResult from "@effect/schema/ParseResult"
import * as S from "@effect/schema/Schema"
import type { Duration } from "effect"
import * as Effect from "effect/Effect"
import { describe, expect, it } from "vitest"

describe("`preserveKeyOrder` option", () => {
const b = Symbol.for("@effect/schema/test/b")
const Sync = S.Struct({
a: S.Literal("a"),
[b]: S.Array(S.String),
c: S.Record(S.String, S.Number),
d: S.NumberFromString,
e: S.Boolean,
f: S.optional(S.String)
})

const effectify = (duration: Duration.DurationInput) =>
S.NumberFromString.pipe(
S.transformOrFail(S.Number, {
decode: (x) => Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))),
encode: ParseResult.succeed
})
)

const Async = S.Struct({
a: effectify("20 millis"),
[b]: effectify("30 millis"),
c: effectify("10 millis")
}).annotations({ concurrency: 3 })

describe("decoding", () => {
it("should preserve the order of input properties (sync)", () => {
const input = { [b]: ["b"], c: { c: 1 }, d: "1", e: true, a: "a", other: 1 }
const output = S.decodeUnknownSync(Sync)(input, { propertyOrder: "original", onExcessProperty: "preserve" })
const expectedOutput = { [b]: ["b"], c: { c: 1 }, d: 1, e: true, a: "a", other: 1, f: undefined }
expect(output).toStrictEqual(expectedOutput)
expect(Reflect.ownKeys(output)).toStrictEqual(Reflect.ownKeys(expectedOutput))
})

it("should preserve the order of input properties (async)", async () => {
const input = { a: "1", c: "3", [b]: "2", other: 1 }
const output = await Effect.runPromise(
S.decodeUnknown(Async)(input, { propertyOrder: "original", onExcessProperty: "preserve" })
)
const expectedOutput = { a: 1, c: 3, [b]: 2, other: 1 }
expect(output).toStrictEqual(expectedOutput)
expect(Reflect.ownKeys(output)).toStrictEqual(Reflect.ownKeys(expectedOutput))
})
})

describe("encoding", () => {
it("should preserve the order of input properties (sync)", () => {
const input = { [b]: ["b"], c: { c: 1 }, d: 1, e: true, a: "a", other: 1 }
const output = S.encodeUnknownSync(Sync)(input, { propertyOrder: "original", onExcessProperty: "preserve" })
const expectedOutput = { [b]: ["b"], c: { c: 1 }, d: "1", e: true, a: "a", other: 1, f: undefined }
expect(output).toStrictEqual(expectedOutput)
expect(Reflect.ownKeys(output)).toStrictEqual(Reflect.ownKeys(expectedOutput))
})

it("should preserve the order of input properties (async)", async () => {
const input = { a: 1, c: 3, [b]: 2, other: 1 }
const output = await Effect.runPromise(
S.encodeUnknown(Async)(input, { propertyOrder: "original", onExcessProperty: "preserve" })
)
const expectedOutput = { a: "1", c: "3", [b]: "2", other: 1 }
expect(output).toStrictEqual(expectedOutput)
expect(Reflect.ownKeys(output)).toStrictEqual(Reflect.ownKeys(expectedOutput))
})
})
})

0 comments on commit 4c6bc7f

Please sign in to comment.