| 1 | import { assert, describe, it, vi } from "@effect/vitest" |
| 2 | import { Effect, Option, Schema, SchemaAST, SchemaParser } from "effect" |
| 3 | import * as Codegen from "effect/internal/schema/codegen" |
| 4 | import * as Registry from "effect/internal/schema/compilerRegistry" |
| 5 | import { SchemaCompiler, SchemaJITCompiler } from "effect/schema" |
| 6 | import { constructionCases, constructionEvents, constructionOptions } from "./fixtures/construction.ts" |
| 7 | |
| 8 | describe("Schema compiler construction", { concurrent: false }, () => { |
| 9 | it.effect("initializes and caches makers on first access", () => |
| 10 | Effect.gen(function*() { |
| 11 | const schema = Schema.Struct({ value: Schema.NumberFromString }) |
| 12 | assert.isFalse(Object.hasOwn(schema, "make")) |
| 13 | assert.isFalse(Object.hasOwn(schema, "makeEffect")) |
| 14 | assert.isFalse(Object.hasOwn(schema, "makeOption")) |
| 15 | |
| 16 | const { make, makeEffect, makeOption } = schema |
| 17 | assert.strictEqual(schema.make, make) |
| 18 | assert.strictEqual(schema.makeEffect, makeEffect) |
| 19 | assert.strictEqual(schema.makeOption, makeOption) |
| 20 | assert.deepStrictEqual(make({ value: 0 }), { value: 0 }) |
| 21 | assert.deepStrictEqual(yield* makeEffect({ value: 1 }), { value: 1 }) |
| 22 | assert.deepStrictEqual(makeOption({ value: 2 }), Option.some({ value: 2 })) |
| 23 | })) |
| 24 | |
| 25 | it.effect("matches interpreted construction, effects and options", () => |
| 26 | Effect.gen(function*() { |
| 27 | const fixtures = Object.entries(constructionCases) |
| 28 | const snapshot = Effect.fnUntraced(function*(fixture: typeof fixtures[number][1]) { |
| 29 | const out = [] |
| 30 | const make = SchemaParser.makeEffect(fixture.schema) |
| 31 | for (const parseOptions of constructionOptions) { |
| 32 | for (const input of fixture.inputs) { |
| 33 | constructionEvents.length = 0 |
| 34 | const result = yield* Effect.result(make(input as never, { parseOptions })) |
| 35 | out.push({ result, events: [...constructionEvents] }) |
| 36 | } |
| 37 | } |
| 38 | return out |
| 39 | }) |
| 40 | // Capture every interpreted result before installing shared child ASTs. |
| 41 | const interpreted = yield* Effect.forEach(fixtures, ([, fixture]) => snapshot(fixture)) |
| 42 | for (const [index, [name, fixture]] of fixtures.entries()) { |
| 43 | SchemaJITCompiler.enable(SchemaAST.toType(fixture.schema.ast)) |
| 44 | assert.deepStrictEqual(yield* snapshot(fixture), interpreted[index], name) |
| 45 | } |
| 46 | })) |
| 47 | |
| 48 | it("keeps selective compilation below an interpreted Suspend", () => { |
| 49 | let forced = 0 |
| 50 | const child = Schema.Struct({ value: Schema.String }) |
| 51 | const schema = Schema.suspend(() => { |
| 52 | forced++ |
| 53 | return child |
| 54 | }) |
| 55 | SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) |
| 56 | assert.strictEqual(forced, 0) |
| 57 | assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) |
| 58 | assert.strictEqual(forced, 1) |
| 59 | }) |
| 60 | |
| 61 | it("compiles only the raw constructor when construction succeeds", () => { |
| 62 | const schema = Schema.Struct({ a: Schema.String }) |
| 63 | const emit = vi.spyOn(Codegen, "generate") |
| 64 | try { |
| 65 | SchemaJITCompiler.enable(schema.ast) |
| 66 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) |
| 67 | assert.strictEqual( |
| 68 | emit.mock.calls.filter(([, operation]) => operation === "decode" || operation === "is").length, |
| 69 | 0 |
| 70 | ) |
| 71 | assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) |
| 72 | assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) |
| 73 | } finally { |
| 74 | emit.mockRestore() |
| 75 | } |
| 76 | }) |
| 77 | |
| 78 | it("does not compile construction when only decoding is used", () => { |
| 79 | const schema = Schema.Struct({ a: Schema.String }) |
| 80 | const emit = vi.spyOn(Codegen, "generate") |
| 81 | try { |
| 82 | SchemaJITCompiler.enable(schema.ast) |
| 83 | assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) |
| 84 | assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) |
| 85 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) |
| 86 | assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) |
| 87 | assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) |
| 88 | } finally { |
| 89 | emit.mockRestore() |
| 90 | } |
| 91 | }) |
| 92 | |
| 93 | for (const compiled of [false, true]) { |
| 94 | it(`initializes only reached constructor children, compiled=${compiled}`, () => { |
| 95 | const child = Schema.String.annotate({ title: "lazy constructor child" }) |
| 96 | let reads = 0 |
| 97 | const decoder: SchemaCompiler.CompiledDecoder = Object.freeze({ |
| 98 | decodeEffect: Effect.succeed, |
| 99 | get makeEffect() { |
| 100 | assert.strictEqual<unknown>(this, decoder) |
| 101 | reads++ |
| 102 | return Effect.succeed |
| 103 | } |
| 104 | }) |
| 105 | SchemaCompiler.set(child.ast, decoder) |
| 106 | const schema = Schema.Struct({ first: Schema.Number, child }) |
| 107 | if (compiled) SchemaJITCompiler.enable(schema.ast) |
| 108 | const make = SchemaParser.make(schema) |
| 109 | assert.throws(() => make({ first: "invalid", child: "unreached" } as never)) |
| 110 | assert.strictEqual(reads, 0) |
| 111 | assert.deepStrictEqual(make({ first: 1, child: "a" }), { first: 1, child: "a" }) |
| 112 | assert.deepStrictEqual(make({ first: 2, child: "b" }), { first: 2, child: "b" }) |
| 113 | assert.strictEqual(reads, 1) |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | it("prepares selective Declaration parameters for public decoders in the callback", () => { |
| 118 | const child = Schema.Struct({ value: Schema.String }) |
| 119 | const schema = Schema.ReadonlySet(child) |
| 120 | SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) |
| 121 | assert.deepStrictEqual(SchemaParser.make(schema)(new Set([{ value: "a" }])), new Set([{ value: "a" }])) |
| 122 | assert.strictEqual(Registry.resolve(child.ast).compiled !== undefined, true) |
| 123 | }) |
| 124 | |
| 125 | it("does not restart a parent if compilation fails after a default", () => { |
| 126 | let defaults = 0 |
| 127 | const child = Schema.Struct({ value: Schema.String }) |
| 128 | const schema = Schema.Struct({ |
| 129 | child: child.pipe(Schema.withConstructorDefault(Effect.sync(() => { |
| 130 | defaults++ |
| 131 | return { value: "default" } |
| 132 | }))) |
| 133 | }) |
| 134 | const childAST = schema.fields.child.ast |
| 135 | const emit = Codegen.generate |
| 136 | const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { |
| 137 | if (ast === childAST && operation === "makeEffect") { |
| 138 | assert.strictEqual(defaults, 1) |
| 139 | throw new Error("child compile failed") |
| 140 | } |
| 141 | return emit(ast, operation) |
| 142 | }) |
| 143 | try { |
| 144 | SchemaJITCompiler.enable(schema.ast) |
| 145 | assert.deepStrictEqual(SchemaParser.make(schema)({}), { child: { value: "default" } }) |
| 146 | assert.strictEqual(defaults, 1) |
| 147 | } finally { |
| 148 | failure.mockRestore() |
| 149 | } |
| 150 | }) |
| 151 | |
| 152 | for (const operation of ["make", "decode"] as const) { |
| 153 | it(`keeps the other operation compiled after ${operation} compilation fails`, () => { |
| 154 | const schema = Schema.Struct({ a: Schema.String }) |
| 155 | SchemaJITCompiler.enable(schema.ast) |
| 156 | const generate = Codegen.generate |
| 157 | const failed = vi.spyOn(Codegen, "generate").mockImplementation((ast, key) => { |
| 158 | if (ast === schema.ast && key === (operation === "make" ? "make" : "decode")) { |
| 159 | throw new Error("compile failed") |
| 160 | } |
| 161 | return generate(ast, key) |
| 162 | }) |
| 163 | try { |
| 164 | const first = operation === "make" ? SchemaParser.make(schema) : SchemaParser.decodeUnknownSync(schema) |
| 165 | assert.deepStrictEqual(first({ a: "a" }), { a: "a" }) |
| 166 | const second = operation === "make" ? SchemaParser.decodeUnknownSync(schema) : SchemaParser.make(schema) |
| 167 | assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) |
| 168 | assert( |
| 169 | failed.mock.calls.some(([ast, key]) => |
| 170 | ast === schema.ast && key === (operation === "make" ? "decode" : "make") |
| 171 | ) |
| 172 | ) |
| 173 | } finally { |
| 174 | failed.mockRestore() |
| 175 | } |
| 176 | }) |
| 177 | } |
| 178 | it("resolves installed construction lazily and independently from decoding", () => { |
| 179 | const schema = Schema.Struct({ a: Schema.String }) |
| 180 | let reads = 0 |
| 181 | let calls = 0 |
| 182 | SchemaCompiler.set(schema.ast, { |
| 183 | get decodeEffect(): SchemaCompiler.DecodeEffect { |
| 184 | throw new Error("unused decoder") |
| 185 | }, |
| 186 | get decode(): SchemaCompiler.Decode { |
| 187 | throw new Error("unused fast decoder") |
| 188 | }, |
| 189 | get is(): SchemaCompiler.Is { |
| 190 | throw new Error("unused guard") |
| 191 | }, |
| 192 | get makeEffect() { |
| 193 | reads++ |
| 194 | return (input: unknown) => { |
| 195 | calls++ |
| 196 | return Effect.succeed(input) |
| 197 | } |
| 198 | } |
| 199 | }) |
| 200 | const make = SchemaParser.make(schema) |
| 201 | assert.strictEqual(reads, 0) |
| 202 | assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) |
| 203 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "b" }), { a: "b" }) |
| 204 | assert.strictEqual(reads, 1) |
| 205 | assert.strictEqual(calls, 2) |
| 206 | }) |
| 207 | |
| 208 | it("uses an installed synchronous constructor without resolving makeEffect", () => { |
| 209 | const schema = Schema.Struct({ a: Schema.String }) |
| 210 | let calls = 0 |
| 211 | SchemaCompiler.set(schema.ast, { |
| 212 | decodeEffect: Effect.succeed, |
| 213 | make: (input, options) => { |
| 214 | calls++ |
| 215 | assert.strictEqual(options, SchemaAST.defaultParseOptions) |
| 216 | return { a: `${(input as { readonly a: string }).a}!` } |
| 217 | }, |
| 218 | get makeEffect(): SchemaCompiler.MakeEffect { |
| 219 | throw new Error("unused detailed constructor") |
| 220 | } |
| 221 | }) |
| 222 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a!" }) |
| 223 | assert.strictEqual(calls, 1) |
| 224 | }) |
| 225 | |
| 226 | it("falls back to makeEffect after an installed synchronous constructor fails", () => { |
| 227 | const schema = Schema.Struct({ a: Schema.String }) |
| 228 | let fast = 0 |
| 229 | let detailed = 0 |
| 230 | SchemaCompiler.set(schema.ast, { |
| 231 | decodeEffect: Effect.succeed, |
| 232 | make: () => { |
| 233 | fast++ |
| 234 | return SchemaCompiler.invalid |
| 235 | }, |
| 236 | makeEffect: (input) => { |
| 237 | detailed++ |
| 238 | return Effect.succeed(input) |
| 239 | } |
| 240 | }) |
| 241 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) |
| 242 | assert.strictEqual(fast, 1) |
| 243 | assert.strictEqual(detailed, 1) |
| 244 | }) |
| 245 | |
| 246 | it("does not return a missing result from an installed synchronous constructor", () => { |
| 247 | const schema = Schema.Struct({ a: Schema.String }) |
| 248 | SchemaCompiler.set(schema.ast, { |
| 249 | decodeEffect: Effect.succeed, |
| 250 | make: () => SchemaCompiler.missing, |
| 251 | makeEffect: () => Effect.succeed(SchemaCompiler.missing) |
| 252 | }) |
| 253 | assert.throws(() => SchemaParser.make(schema)({ a: "a" }), /Schema validation failed/) |
| 254 | }) |
| 255 | |
| 256 | it("composes an installed synchronous child constructor in a compiled Array", () => { |
| 257 | const child = Schema.Struct({ a: Schema.String }) |
| 258 | SchemaCompiler.set(child.ast, { |
| 259 | decodeEffect: Effect.succeed, |
| 260 | make: (input) => ({ a: `${(input as { readonly a: string }).a}!` }), |
| 261 | makeEffect: () => { |
| 262 | throw new Error("unused detailed child constructor") |
| 263 | } |
| 264 | }) |
| 265 | const schema = Schema.Array(child) |
| 266 | SchemaJITCompiler.enable(schema.ast) |
| 267 | assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }, { a: "b" }]), [{ a: "a!" }, { a: "b!" }]) |
| 268 | }) |
| 269 | |
| 270 | it("composes a compiled synchronous child constructor in a compiled Array", () => { |
| 271 | const child = Schema.Struct({ a: Schema.String }) |
| 272 | SchemaJITCompiler.enable(child.ast) |
| 273 | const childEntry = Registry.resolve(child.ast) |
| 274 | const schema = Schema.Array(child) |
| 275 | SchemaJITCompiler.enable(schema.ast) |
| 276 | assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }]), [{ a: "a" }]) |
| 277 | assert.isTrue(Object.hasOwn(childEntry, "make")) |
| 278 | }) |
| 279 | |
| 280 | it("falls back to detailed construction for an invalid compiled Array", () => { |
| 281 | const schema = Schema.Array(Schema.Struct({ a: Schema.String })) |
| 282 | SchemaJITCompiler.enable(schema.ast) |
| 283 | const make = SchemaParser.make(schema) |
| 284 | assert.deepStrictEqual(make([{ a: "a" }]), [{ a: "a" }]) |
| 285 | assert.throws(() => make([{ a: 1 } as never]), /Schema validation failed/) |
| 286 | }) |
| 287 | |
| 288 | it("caches interpreted construction when an installed bundle omits it", () => { |
| 289 | const schema = Schema.Struct({ a: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) |
| 290 | SchemaCompiler.set(schema.ast, { |
| 291 | decodeEffect: () => { |
| 292 | throw new Error("not a constructor") |
| 293 | } |
| 294 | }) |
| 295 | const entry = Registry.resolve(schema.ast) |
| 296 | const make = entry.makeEffect |
| 297 | assert.strictEqual(entry.makeEffect, make) |
| 298 | assert.deepStrictEqual(SchemaParser.make(schema)({}), { a: 1 }) |
| 299 | }) |
| 300 | |
| 301 | it("keeps previously captured constructors after whole-entry replacement", () => { |
| 302 | const schema = Schema.Struct({ a: Schema.String }) |
| 303 | const make = SchemaParser.make(schema) |
| 304 | assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) |
| 305 | SchemaCompiler.set(schema.ast, { |
| 306 | decodeEffect: Effect.succeed, |
| 307 | makeEffect: () => Effect.succeed({ a: "installed" }) |
| 308 | }) |
| 309 | assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) |
| 310 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "installed" }) |
| 311 | SchemaCompiler.set(schema.ast, { decodeEffect: Effect.succeed }) |
| 312 | assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) |
| 313 | }) |
| 314 | |
| 315 | it("uses type-side identity without applying root defaults", () => { |
| 316 | const schema = Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(1))) |
| 317 | const ast = SchemaAST.toType(schema.ast) |
| 318 | SchemaCompiler.set(ast, { decodeEffect: Effect.succeed, makeEffect: Effect.succeed }) |
| 319 | assert.strictEqual(SchemaParser.make(schema)(2), 2) |
| 320 | const number = Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) |
| 321 | SchemaJITCompiler.enable(number.ast) |
| 322 | assert.throws(() => SchemaParser.make(number)(undefined as never)) |
| 323 | assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ number }))({}), { number: 1 }) |
| 324 | }) |
| 325 | |
| 326 | it("preserves missing separately from undefined for installed children", () => { |
| 327 | const child = Schema.optionalKey(Schema.Undefined) |
| 328 | const inputs: Array<unknown> = [] |
| 329 | SchemaCompiler.set(child.ast, { |
| 330 | decodeEffect: Effect.succeed, |
| 331 | makeEffect: (input) => { |
| 332 | inputs.push(input) |
| 333 | return Effect.succeed(input) |
| 334 | } |
| 335 | }) |
| 336 | const schema = Schema.Struct({ child }) |
| 337 | assert.deepStrictEqual(SchemaParser.make(schema)({}), {}) |
| 338 | assert.deepStrictEqual(SchemaParser.make(schema)({ child: undefined }), { child: undefined }) |
| 339 | assert.deepStrictEqual(inputs, [SchemaCompiler.missing, undefined]) |
| 340 | }) |
| 341 | |
| 342 | it("lets parents and public roots handle missing constructor outputs", () => { |
| 343 | const required = Schema.String.annotate({ title: "Required construction output" }) |
| 344 | const optional = Schema.optionalKey(required) |
| 345 | for (const schema of [required, optional]) { |
| 346 | SchemaCompiler.set(schema.ast, { |
| 347 | decodeEffect: Effect.succeed, |
| 348 | makeEffect: () => Effect.succeed(SchemaCompiler.missing) |
| 349 | }) |
| 350 | } |
| 351 | const schema = Schema.Struct({ required, optional }) |
| 352 | SchemaJITCompiler.enable(schema.ast) |
| 353 | assert.throws(() => SchemaParser.make(schema)({ required: "a" }), /Schema validation failed/) |
| 354 | assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ optional }))({ optional: "a" }), {}) |
| 355 | assert.throws(() => SchemaParser.make(required)("a"), /Schema validation failed/) |
| 356 | }) |
| 357 | |
| 358 | it("compiles construction only without nested records, unions or constructor defaults", () => { |
| 359 | const compilesMake = (schema: Schema.Top) => Codegen.generate(schema.ast, "make") !== undefined |
| 360 | assert.isTrue(compilesMake(Schema.Struct({ a: Schema.Struct({ b: Schema.String }) }))) |
| 361 | assert.isFalse(compilesMake(Schema.Struct({ a: Schema.Record(Schema.String, Schema.Number) }))) |
| 362 | assert.isFalse(compilesMake(Schema.Struct({ a: Schema.Union([Schema.String, Schema.Number]) }))) |
| 363 | assert.isFalse(compilesMake(Schema.Struct({ |
| 364 | a: Schema.Struct({ b: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("b"))) }) |
| 365 | }))) |
| 366 | }) |
| 367 | |
| 368 | it.effect("executes async defaults once, including on later failure", () => |
| 369 | Effect.gen(function*() { |
| 370 | let defaults = 0 |
| 371 | const schema = Schema.Struct({ |
| 372 | a: Schema.String.pipe(Schema.withConstructorDefault(Effect.gen(function*() { |
| 373 | yield* Effect.yieldNow |
| 374 | defaults++ |
| 375 | return "default" |
| 376 | }))), |
| 377 | b: Schema.Number |
| 378 | }) |
| 379 | SchemaJITCompiler.enable(schema.ast) |
| 380 | const make = SchemaParser.makeEffect(schema) |
| 381 | assert.deepStrictEqual(yield* make({ b: 1 }), { a: "default", b: 1 }) |
| 382 | assert.strictEqual((yield* Effect.exit(make({ b: "bad" } as never)))._tag, "Failure") |
| 383 | assert.strictEqual(defaults, 2) |
| 384 | })) |
| 385 | }) |