| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Schema, SchemaParser } from "effect" |
| 3 | import * as Registry from "effect/internal/schema/compilerRegistry" |
| 4 | // oxlint-disable-next-line no-unassigned-import |
| 5 | import "effect/schema/SchemaJITCompiler/enable" |
| 6 | import { assertSchemaIssueError, deepStrictEqual, throws } from "../utils/assert.ts" |
| 7 | |
| 8 | const strict = { onExcessProperty: "error" } as const |
| 9 | |
| 10 | const compiled = (schema: Schema.Top) => { |
| 11 | const { decode, is } = Registry.resolve(schema.ast) |
| 12 | assert.ok(decode, "Expected a compiled decoder") |
| 13 | assert.ok(is, "Expected a compiled type guard") |
| 14 | return { decode, is } |
| 15 | } |
| 16 | |
| 17 | describe("SchemaJITCompiler excess properties", () => { |
| 18 | it("ignores non-enumerable own properties without fallback or getter access", () => { |
| 19 | const schema = Schema.Struct({ a: Schema.String }) |
| 20 | const { decode, is } = compiled(schema) |
| 21 | const input = Object.assign(Object.create(null), { a: "a" }) |
| 22 | for (const key of ["stack", "propertyIsEnumerable", Symbol("hidden")]) { |
| 23 | Object.defineProperty(input, key, { |
| 24 | get() { |
| 25 | throw new Error("Non-enumerable excess properties must not be read") |
| 26 | }, |
| 27 | enumerable: false |
| 28 | }) |
| 29 | } |
| 30 | deepStrictEqual(decode(input, strict), { a: "a" }) |
| 31 | assert.isTrue(is(input, strict)) |
| 32 | }) |
| 33 | |
| 34 | it("rejects enumerable excess properties", () => { |
| 35 | const schema = Schema.Struct({ a: Schema.String }) |
| 36 | const compiledParser = compiled(schema) |
| 37 | for (const key of ["b", Symbol("b")]) { |
| 38 | const input = { a: "a", [key]: "b" } |
| 39 | assert.strictEqual(compiledParser.decode(input, strict), Registry.invalid) |
| 40 | assert.isFalse(compiledParser.is(input, strict)) |
| 41 | } |
| 42 | const decode = SchemaParser.decodeUnknownSync(schema, strict) |
| 43 | throws( |
| 44 | () => decode({ a: "a", b: "b" }), |
| 45 | (error) => { |
| 46 | assertSchemaIssueError(error, `Expected no excess property\n at ["b"]`) |
| 47 | } |
| 48 | ) |
| 49 | }) |
| 50 | |
| 51 | it("symbol index signatures ignore non-enumerable properties in decode and encode", () => { |
| 52 | const visible = Symbol("visible") |
| 53 | const hidden = Symbol("hidden") |
| 54 | const input = { [visible]: 1 } |
| 55 | Object.defineProperty(input, hidden, { |
| 56 | get() { |
| 57 | throw new Error("Non-enumerable record entries must not be read") |
| 58 | }, |
| 59 | enumerable: false |
| 60 | }) |
| 61 | const expected = { [visible]: 1 } |
| 62 | const schema = Schema.Record(Schema.Symbol, Schema.Number) |
| 63 | |
| 64 | const { decode, is } = compiled(schema) |
| 65 | for (const options of [{}, strict]) { |
| 66 | deepStrictEqual(decode(input, options), expected) |
| 67 | assert.isTrue(is(input, options)) |
| 68 | assert.strictEqual(decode({ [visible]: "invalid" }, options), Registry.invalid) |
| 69 | assert.isFalse(is({ [visible]: "invalid" }, options)) |
| 70 | deepStrictEqual(SchemaParser.encodeUnknownSync(schema, options)(input), expected) |
| 71 | } |
| 72 | }) |
| 73 | |
| 74 | it("validates non-enumerable declared string and symbol properties", () => { |
| 75 | const symbol = Symbol("declared") |
| 76 | const schema = Schema.Struct({ a: Schema.Number, [symbol]: Schema.String }) |
| 77 | const { decode, is } = compiled(schema) |
| 78 | const input = Object.defineProperties({}, { |
| 79 | a: { value: 1, enumerable: false }, |
| 80 | [symbol]: { value: "a", enumerable: false } |
| 81 | }) |
| 82 | deepStrictEqual(decode(input, strict), { a: 1, [symbol]: "a" }) |
| 83 | assert.isTrue(is(input, strict)) |
| 84 | deepStrictEqual(SchemaParser.encodeUnknownSync(schema, strict)(input), { a: 1, [symbol]: "a" }) |
| 85 | for (const key of ["a", symbol]) { |
| 86 | const invalid = Object.defineProperty({ a: 1, [symbol]: "a" }, key, { value: null, enumerable: false }) |
| 87 | assert.strictEqual(decode(invalid, strict), Registry.invalid) |
| 88 | assert.isFalse(is(invalid, strict)) |
| 89 | } |
| 90 | }) |
| 91 | |
| 92 | it("handles fixed fields and multiple index signatures", () => { |
| 93 | const symbol = Symbol("visible") |
| 94 | const schema = Schema.StructWithRest(Schema.Struct({ fixed: Schema.String }), [ |
| 95 | Schema.Record(Schema.TemplateLiteral(["field-", Schema.Number]), Schema.Number), |
| 96 | Schema.Record(Schema.Symbol, Schema.Number) |
| 97 | ]) |
| 98 | const { decode, is } = compiled(schema) |
| 99 | const expected = { fixed: "a", "field-1": 1, [symbol]: 2 } |
| 100 | const input = Object.defineProperties({ ...expected }, { |
| 101 | "field-2": { value: "invalid", enumerable: false }, |
| 102 | [Symbol("hidden")]: { value: "invalid", enumerable: false } |
| 103 | }) |
| 104 | deepStrictEqual(decode(input, strict), expected) |
| 105 | assert.isTrue(is(input, strict)) |
| 106 | assert.strictEqual(decode({ ...input, extra: 1 }, strict), Registry.invalid) |
| 107 | assert.isFalse(is({ ...input, extra: 1 }, strict)) |
| 108 | assert.strictEqual(decode({ ...input, [symbol]: "invalid" }, strict), Registry.invalid) |
| 109 | deepStrictEqual(decode({ fixed: "b", "field-3": 3 }, strict), { fixed: "b", "field-3": 3 }) |
| 110 | }) |
| 111 | |
| 112 | it("reuses the strict key snapshot before reading declared getters", () => { |
| 113 | const visible = Symbol("visible") |
| 114 | const added = Symbol("added") |
| 115 | const schema = Schema.StructWithRest(Schema.Struct({ fixed: Schema.Boolean }), [ |
| 116 | Schema.Record(Schema.Symbol, Schema.Number) |
| 117 | ]) |
| 118 | const { decode, is } = compiled(schema) |
| 119 | const input = () => { |
| 120 | const record: Record<PropertyKey, unknown> = { [visible]: 1 } |
| 121 | Object.defineProperty(record, "fixed", { |
| 122 | enumerable: true, |
| 123 | get() { |
| 124 | record[added] = "added after excess checking" |
| 125 | return true |
| 126 | } |
| 127 | }) |
| 128 | return record |
| 129 | } |
| 130 | deepStrictEqual(decode(input(), strict), { fixed: true, [visible]: 1 }) |
| 131 | assert.isTrue(is(input(), strict)) |
| 132 | assert.strictEqual(decode(input(), {}), Registry.invalid) |
| 133 | assert.isFalse(is(input(), {})) |
| 134 | }) |
| 135 | |
| 136 | it("encodes tagged errors carrying runtime internals", () => { |
| 137 | class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {} |
| 138 | const encode = SchemaParser.encodeUnknownSync(NotFound, strict) |
| 139 | deepStrictEqual(encode(new NotFound({ id: 1 })), { _tag: "NotFound", id: 1 }) |
| 140 | }) |
| 141 | }) |