| 1 | import { assert, describe, it, vi } from "@effect/vitest" |
| 2 | import { Effect, Schema, SchemaAST, SchemaParser } from "effect" |
| 3 | import { SchemaCompiler, SchemaJITCompiler } from "effect/schema" |
| 4 | import { deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" |
| 5 | |
| 6 | describe("SchemaCompiler", () => { |
| 7 | it("resolves a type guard on first invocation", () => { |
| 8 | const schema = Schema.String.annotate({ title: "lazy type guard" }) |
| 9 | const ast = SchemaAST.toType(schema.ast) |
| 10 | const guard = SchemaParser.is(schema) |
| 11 | let calls = 0 |
| 12 | |
| 13 | SchemaCompiler.set(ast, { |
| 14 | is: () => { |
| 15 | calls++ |
| 16 | return true |
| 17 | }, |
| 18 | decodeEffect: Effect.succeed |
| 19 | }) |
| 20 | |
| 21 | strictEqual(guard(1), true) |
| 22 | strictEqual(guard(2), true) |
| 23 | strictEqual(calls, 2) |
| 24 | |
| 25 | SchemaCompiler.set(ast, { |
| 26 | is: () => false, |
| 27 | decodeEffect: Effect.succeed |
| 28 | }) |
| 29 | |
| 30 | strictEqual(guard(3), true) |
| 31 | strictEqual(SchemaParser.is(schema)(3), false) |
| 32 | strictEqual(calls, 3) |
| 33 | }) |
| 34 | |
| 35 | it("reuses interpreted candidates inside a selectively compiled Union", () => { |
| 36 | const child = Schema.String.annotate({ title: "interpreted Union candidate" }) |
| 37 | const schema = Schema.Union([child, Schema.Number]) |
| 38 | const initialize = vi.spyOn(child.ast, "getParser") |
| 39 | try { |
| 40 | SchemaJITCompiler.enable(schema.ast) |
| 41 | const make = SchemaParser.make(schema) |
| 42 | strictEqual(make("first"), "first") |
| 43 | strictEqual(make("second"), "second") |
| 44 | strictEqual(initialize.mock.calls.length, 1) |
| 45 | } finally { |
| 46 | initialize.mockRestore() |
| 47 | } |
| 48 | }) |
| 49 | |
| 50 | it("retains a resolved Union member parser after replacement", () => { |
| 51 | const child = Schema.String.annotate({ title: "resolved Union candidate" }) |
| 52 | const decode = SchemaParser.decodeUnknownSync(Schema.Union([child, Schema.Number])) |
| 53 | |
| 54 | strictEqual(decode("original"), "original") |
| 55 | |
| 56 | SchemaCompiler.set(child.ast, { |
| 57 | decode: () => "replacement", |
| 58 | decodeEffect: () => Effect.succeed("replacement") |
| 59 | }) |
| 60 | |
| 61 | strictEqual(decode("cached"), "cached") |
| 62 | strictEqual(SchemaParser.decodeUnknownSync(child)("new consumer"), "replacement") |
| 63 | }) |
| 64 | |
| 65 | it("uses a replacement for an unresolved Union member", () => { |
| 66 | const child = Schema.String.annotate({ title: "unresolved Union candidate" }) |
| 67 | const decode = SchemaParser.decodeUnknownSync(Schema.Union([Schema.Number, child])) |
| 68 | |
| 69 | strictEqual(decode(1), 1) |
| 70 | |
| 71 | SchemaCompiler.set(child.ast, { |
| 72 | decode: () => "replacement", |
| 73 | decodeEffect: () => Effect.succeed("replacement") |
| 74 | }) |
| 75 | |
| 76 | strictEqual(decode("first visit"), "replacement") |
| 77 | }) |
| 78 | |
| 79 | it("installs a decoder in the shared registry", () => { |
| 80 | const schema = Schema.Struct({ value: Schema.String }) |
| 81 | const early = SchemaParser.decodeUnknownSync(schema) |
| 82 | deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) |
| 83 | |
| 84 | let decodes = 0 |
| 85 | SchemaCompiler.set(schema.ast, { |
| 86 | is: () => true, |
| 87 | decode: (_input, options) => |
| 88 | options.reportInput === true |
| 89 | ? { value: "compiled" } |
| 90 | : SchemaCompiler.invalid, |
| 91 | decodeEffect: () => { |
| 92 | decodes++ |
| 93 | return Effect.succeed({ value: "detailed" }) |
| 94 | } |
| 95 | }) |
| 96 | |
| 97 | const late = SchemaParser.decodeUnknownSync(schema) |
| 98 | deepStrictEqual(late({ value: 1 }, { reportInput: true }), { value: "compiled" }) |
| 99 | deepStrictEqual(late({ value: 1 }), { value: "detailed" }) |
| 100 | strictEqual(decodes, 1) |
| 101 | |
| 102 | // Parsers that resolved the old entry before set keep using it. |
| 103 | deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) |
| 104 | }) |
| 105 | |
| 106 | it("uses is only for type guards", () => { |
| 107 | const schema = Schema.Struct({ value: Schema.String }) |
| 108 | let validations = 0 |
| 109 | SchemaCompiler.set(schema.ast, { |
| 110 | is: (input, options) => { |
| 111 | strictEqual(options, SchemaAST.defaultParseOptions) |
| 112 | return (input as { readonly value?: unknown }).value === "accepted" |
| 113 | }, |
| 114 | decode: (input) => { |
| 115 | validations++ |
| 116 | return input |
| 117 | }, |
| 118 | decodeEffect: Effect.succeed |
| 119 | }) |
| 120 | |
| 121 | strictEqual(SchemaParser.is(schema)({ value: "accepted" }), true) |
| 122 | strictEqual(SchemaParser.is(schema)({ value: "rejected" }), false) |
| 123 | deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "decoded" }), { value: "decoded" }) |
| 124 | strictEqual(validations, 1) |
| 125 | }) |
| 126 | |
| 127 | it("retains one resolved entry across sync option paths", () => { |
| 128 | for (const direction of ["decode", "encode"]) { |
| 129 | for (const firstOptions of [undefined, { reportInput: true }]) { |
| 130 | const schema = Schema.Struct({ value: Schema.String }) |
| 131 | const makeSync = () => |
| 132 | direction === "decode" |
| 133 | ? SchemaParser.decodeUnknownSync(schema) |
| 134 | : SchemaParser.encodeUnknownSync(schema) |
| 135 | const decode = makeSync() |
| 136 | const input = { value: "original" } |
| 137 | deepStrictEqual(decode(input, firstOptions), input) |
| 138 | |
| 139 | SchemaCompiler.set(schema.ast, { |
| 140 | decode: () => ({ value: "replacement" }), |
| 141 | decodeEffect: () => Effect.succeed({ value: "replacement" }) |
| 142 | }) |
| 143 | |
| 144 | deepStrictEqual(decode(input), input) |
| 145 | deepStrictEqual(decode(input, { reportInput: true }), input) |
| 146 | deepStrictEqual(makeSync()(input), { value: "replacement" }) |
| 147 | } |
| 148 | } |
| 149 | }) |
| 150 | |
| 151 | it("does not resolve child operations until the child is parsed", () => { |
| 152 | const child = Schema.String.annotate({ title: "lazy child" }) |
| 153 | let reads = 0 |
| 154 | SchemaCompiler.set(child.ast, { |
| 155 | get decode() { |
| 156 | reads++ |
| 157 | return undefined |
| 158 | }, |
| 159 | get decodeEffect() { |
| 160 | reads++ |
| 161 | return Effect.succeed |
| 162 | } |
| 163 | }) |
| 164 | const schema = Schema.Struct({ first: Schema.Number, child }) |
| 165 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 166 | throws(() => decode({ first: "invalid", child: "unreached" })) |
| 167 | strictEqual(reads, 0) |
| 168 | deepStrictEqual(decode({ first: 1, child: "reached" }), { first: 1, child: "reached" }) |
| 169 | strictEqual(reads, 2) |
| 170 | deepStrictEqual(decode({ first: 2, child: "cached" }), { first: 2, child: "cached" }) |
| 171 | strictEqual(reads, 2) |
| 172 | }) |
| 173 | |
| 174 | it("uses an installed child decoder from an interpreted Array", () => { |
| 175 | const child = Schema.String.annotate({ title: "installed array child" }) |
| 176 | SchemaCompiler.set(child.ast, { |
| 177 | decode: (input) => typeof input === "string" ? `${input}!` : SchemaCompiler.invalid, |
| 178 | decodeEffect: (input) => Effect.succeed(`${input}!`) |
| 179 | }) |
| 180 | |
| 181 | deepStrictEqual( |
| 182 | SchemaParser.decodeUnknownSync(Schema.Array(child))(["a"]), |
| 183 | ["a!"] |
| 184 | ) |
| 185 | }) |
| 186 | |
| 187 | it("exposes the canonical missing value to installed decoders", () => { |
| 188 | const schema = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) |
| 189 | assert(schema.ast._tag === "Objects") |
| 190 | const value = schema.ast.propertySignatures[0].type |
| 191 | let sawMissing = false |
| 192 | SchemaCompiler.set(value, { |
| 193 | decode: (input) => typeof input === "string" ? input : SchemaCompiler.invalid, |
| 194 | decodeEffect: (input) => { |
| 195 | sawMissing = input === SchemaCompiler.missing |
| 196 | return Effect.succeed(input) |
| 197 | } |
| 198 | }) |
| 199 | |
| 200 | deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({}), {}) |
| 201 | strictEqual(sawMissing, true) |
| 202 | }) |
| 203 | |
| 204 | it("installs encoders on the flipped AST", () => { |
| 205 | const schema = Schema.FiniteFromString |
| 206 | SchemaCompiler.set(SchemaAST.flip(schema.ast), { |
| 207 | decode: () => "aot", |
| 208 | decodeEffect: () => Effect.succeed("detailed") |
| 209 | }) |
| 210 | |
| 211 | strictEqual(SchemaParser.encodeUnknownSync(schema)(1), "aot") |
| 212 | }) |
| 213 | }) |
| 214 | |
| 215 | describe("SchemaJITCompiler", () => { |
| 216 | it("reuses option-independent generated functions for explicit options", () => { |
| 217 | const schema = Schema.Struct({ nested: Schema.Struct({ value: Schema.String }) }) |
| 218 | const input = { nested: { value: "valid" } } |
| 219 | const Function = globalThis.Function |
| 220 | let constructions = 0 |
| 221 | try { |
| 222 | globalThis.Function = ((...args: ReadonlyArray<string>) => { |
| 223 | constructions++ |
| 224 | return Function(...args) |
| 225 | }) as FunctionConstructor |
| 226 | |
| 227 | SchemaJITCompiler.enable(schema.ast) |
| 228 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 229 | deepStrictEqual(decode(input), input) |
| 230 | strictEqual(SchemaParser.is(schema)(input), true) |
| 231 | const initialized = constructions |
| 232 | assert(initialized > 1) |
| 233 | for (const options of [{}, { reportInput: true }, { errors: "all" }, { disableChecks: true }] as const) { |
| 234 | deepStrictEqual(decode(input, options), input) |
| 235 | strictEqual(SchemaParser.is(schema)(input), true) |
| 236 | strictEqual(constructions, initialized) |
| 237 | } |
| 238 | } finally { |
| 239 | globalThis.Function = Function |
| 240 | } |
| 241 | }) |
| 242 | |
| 243 | it("keeps generated operations lazy", () => { |
| 244 | const schema = Schema.Struct({ value: Schema.String }) |
| 245 | const Function = globalThis.Function |
| 246 | let constructions = 0 |
| 247 | try { |
| 248 | globalThis.Function = ((...args: ReadonlyArray<string>) => { |
| 249 | constructions++ |
| 250 | return Function(...args) |
| 251 | }) as FunctionConstructor |
| 252 | |
| 253 | SchemaJITCompiler.enable(schema.ast) |
| 254 | strictEqual(constructions, 1) |
| 255 | deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "valid" }), { value: "valid" }) |
| 256 | assert(constructions > 1) |
| 257 | } finally { |
| 258 | globalThis.Function = Function |
| 259 | } |
| 260 | }) |
| 261 | |
| 262 | it("replaces only the selected AST and leaves resolved parsers intact", () => { |
| 263 | const selected = Schema.Struct({ value: Schema.String }) |
| 264 | const untouched = Schema.Struct({ value: Schema.String }) |
| 265 | const early = SchemaParser.decodeUnknownSync(selected) |
| 266 | deepStrictEqual(early({ value: "valid" }), { value: "valid" }) |
| 267 | |
| 268 | SchemaJITCompiler.enable(selected.ast) |
| 269 | |
| 270 | let earlyReads = 0 |
| 271 | throws(() => |
| 272 | early({ |
| 273 | get value() { |
| 274 | earlyReads++ |
| 275 | return 1 |
| 276 | } |
| 277 | }) |
| 278 | ) |
| 279 | strictEqual(earlyReads, 1) |
| 280 | |
| 281 | let selectedReads = 0 |
| 282 | throws(() => |
| 283 | SchemaParser.decodeUnknownSync(selected)({ |
| 284 | get value() { |
| 285 | selectedReads++ |
| 286 | return 1 |
| 287 | } |
| 288 | }) |
| 289 | ) |
| 290 | strictEqual(selectedReads, 2) |
| 291 | |
| 292 | let untouchedReads = 0 |
| 293 | throws(() => |
| 294 | SchemaParser.decodeUnknownSync(untouched)({ |
| 295 | get value() { |
| 296 | untouchedReads++ |
| 297 | return 1 |
| 298 | } |
| 299 | }) |
| 300 | ) |
| 301 | strictEqual(untouchedReads, 1) |
| 302 | }) |
| 303 | |
| 304 | it("compiles descendants through an unsupported lazy root", () => { |
| 305 | const child = Schema.Struct({ value: Schema.String }) |
| 306 | const schema = Schema.suspend(() => child) |
| 307 | SchemaJITCompiler.enable(schema.ast) |
| 308 | |
| 309 | let reads = 0 |
| 310 | throws(() => |
| 311 | SchemaParser.decodeUnknownSync(schema)({ |
| 312 | get value() { |
| 313 | reads++ |
| 314 | return 1 |
| 315 | } |
| 316 | }) |
| 317 | ) |
| 318 | strictEqual(reads, 2) |
| 319 | }) |
| 320 | |
| 321 | it("prepares declaration type parameters with the selective compiler on first use", () => { |
| 322 | const child = Schema.Struct({ value: Schema.String }) |
| 323 | const schema = Schema.ReadonlySet(child) |
| 324 | SchemaJITCompiler.enable(schema.ast) |
| 325 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 326 | deepStrictEqual(decode(new Set([{ value: "a", extra: true }])), new Set([{ value: "a" }])) |
| 327 | throws(() => decode(new Set([{ value: 1 }]))) |
| 328 | }) |
| 329 | |
| 330 | it("does not initialize unused declaration type parameter operations", () => { |
| 331 | const child = Schema.Struct({ value: Schema.String }) |
| 332 | let reads = 0 |
| 333 | SchemaCompiler.set(child.ast, { |
| 334 | get decode() { |
| 335 | reads++ |
| 336 | return undefined |
| 337 | }, |
| 338 | get decodeEffect() { |
| 339 | reads++ |
| 340 | return Effect.succeed |
| 341 | } |
| 342 | }) |
| 343 | const schema = Schema.ReadonlySet(child) |
| 344 | SchemaJITCompiler.enable(schema.ast) |
| 345 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 346 | strictEqual(reads, 0) |
| 347 | deepStrictEqual(decode(new Set()), new Set()) |
| 348 | strictEqual(reads, 0) |
| 349 | deepStrictEqual(decode(new Set([{ value: "a" }])), new Set([{ value: "a" }])) |
| 350 | strictEqual(reads, 2) |
| 351 | }) |
| 352 | |
| 353 | it("preserves an installed decoder when dynamic code generation is unavailable", () => { |
| 354 | const schema = Schema.Struct({ value: Schema.String }) |
| 355 | SchemaCompiler.set(schema.ast, { |
| 356 | decode: () => ({ value: "installed" }), |
| 357 | decodeEffect: () => Effect.succeed({ value: "installed" }) |
| 358 | }) |
| 359 | const Function = globalThis.Function |
| 360 | try { |
| 361 | globalThis.Function = (() => { |
| 362 | throw new Error("dynamic function generation unavailable") |
| 363 | }) as any |
| 364 | SchemaJITCompiler.enable(schema.ast) |
| 365 | } finally { |
| 366 | globalThis.Function = Function |
| 367 | } |
| 368 | |
| 369 | deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: 1 }), { value: "installed" }) |
| 370 | }) |
| 371 | }) |