| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Effect, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, SchemaTransformation } from "effect" |
| 3 | import * as Codegen from "effect/internal/schema/codegen" |
| 4 | import * as Registry from "effect/internal/schema/compilerRegistry" |
| 5 | import { SchemaJITCompiler } from "effect/schema" |
| 6 | import * as SchemaAOTCompiler from "effect/schema/SchemaAOTCompiler" |
| 7 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs" |
| 8 | import { join } from "node:path" |
| 9 | import { fileURLToPath, pathToFileURL } from "node:url" |
| 10 | import { deepStrictEqual } from "../utils/assert.ts" |
| 11 | |
| 12 | type Options = Parameters<ReturnType<typeof SchemaParser.decodeUnknownResult>>[1] |
| 13 | |
| 14 | const optionSets: ReadonlyArray<Options> = [ |
| 15 | undefined, |
| 16 | { reportInput: true }, |
| 17 | { errors: "all", reportInput: true }, |
| 18 | { onExcessProperty: "error", reportInput: true }, |
| 19 | { disableChecks: true, reportInput: true } |
| 20 | ] |
| 21 | |
| 22 | const counters = { checks: 0, transforms: 0 } |
| 23 | |
| 24 | // Every case builds its own schemas: the compiler registry is global, so a |
| 25 | // shared child AST enabled by an earlier case would make a later "interpreted" |
| 26 | // snapshot use compiled children. |
| 27 | const makeFixtures = () => { |
| 28 | const counted = <A, B>(f: (a: A) => B) => (a: A): B => { |
| 29 | counters.transforms++ |
| 30 | return f(a) |
| 31 | } |
| 32 | // Every source check is counted. |
| 33 | const countedFilter = (predicate: (s: string) => boolean, expected: string) => |
| 34 | Schema.makeFilter((s: string) => { |
| 35 | counters.checks++ |
| 36 | return predicate(s) |
| 37 | }, { expected }) |
| 38 | const Digits = Schema.String.check(countedFilter((s) => /^[0-9]{1,6}$/.test(s), "digits")) |
| 39 | const Short = Schema.String.check( |
| 40 | countedFilter((s) => s.length >= 1, "non-empty"), |
| 41 | countedFilter((s) => s.length <= 3, "short") |
| 42 | ) |
| 43 | const Checked = Schema.String.check(Schema.isPattern(/^[a-z ]*$/)) |
| 44 | // "13" transforms into a value that fails the target type. |
| 45 | const toNumber = { |
| 46 | decode: SchemaGetter.transform(counted((s: string) => s === "13" ? "thirteen" as unknown as number : Number(s))), |
| 47 | encode: SchemaGetter.transform((n: number) => String(n)) |
| 48 | } |
| 49 | const prefixed = SchemaTransformation.transform({ |
| 50 | decode: counted((s: string) => s === "7" ? 7 as unknown as string : `#${s}`), |
| 51 | encode: (s: string) => s.slice(1) |
| 52 | }) |
| 53 | return { |
| 54 | Digits, |
| 55 | Checked, |
| 56 | OneOf: Schema.TemplateLiteral([Schema.Union([Schema.String, Schema.Literal("x")], { mode: "oneOf" })]), |
| 57 | identity: SchemaTransformation.transform({ decode: counted((s: string) => s), encode: (s: string) => s }), |
| 58 | DigitsToNumber: Digits.pipe(Schema.decodeTo(Schema.Number, toNumber)), |
| 59 | ShortToNumber: Short.pipe(Schema.decodeTo(Schema.Number, toNumber)), |
| 60 | StringToNumber: Schema.String.pipe(Schema.decodeTo(Schema.Number, toNumber)), |
| 61 | DigitsToString: Digits.pipe(Schema.decodeTo(Schema.String, prefixed)), |
| 62 | DigitsPassthrough: Digits.pipe(Schema.decodeTo(Schema.String)), |
| 63 | // Two links: Digits -> String (prefixed) -> Number (toNumber, after dropping the prefix). |
| 64 | TwoLinks: Digits.pipe(Schema.decodeTo( |
| 65 | Schema.String.pipe(Schema.decodeTo( |
| 66 | Schema.Number, |
| 67 | { |
| 68 | decode: SchemaGetter.transform( |
| 69 | counted((s: string) => s === "#13" ? "thirteen" as unknown as number : Number(s.slice(1))) |
| 70 | ), |
| 71 | encode: SchemaGetter.transform((n: number) => `#${n}`) |
| 72 | } |
| 73 | )), |
| 74 | prefixed |
| 75 | )), |
| 76 | countedFilter, |
| 77 | toNumber, |
| 78 | prefixed |
| 79 | } |
| 80 | } |
| 81 | type Fixtures = ReturnType<typeof makeFixtures> |
| 82 | |
| 83 | const values: ReadonlyArray<unknown> = ["1", "123456", "13", "7", "", "abc", "1234567", 1, null, undefined] |
| 84 | |
| 85 | interface Case { |
| 86 | readonly name: string |
| 87 | readonly schema: (f: Fixtures) => Schema.Codec<unknown, unknown> |
| 88 | readonly inputs: ReadonlyArray<unknown> |
| 89 | readonly make?: ReadonlyArray<unknown> |
| 90 | // Compiled parsers may run a failing check a second time in their |
| 91 | // diagnostic pass. That is only allowed on paths this change does not |
| 92 | // inline: "always" for schemas that keep their previous parser, "fallback" |
| 93 | // when a Struct delegates to its fallback (`errors: "all"` or |
| 94 | // `onExcessProperty`) and a field's own parser is not inlined. Everywhere |
| 95 | // else each check must run exactly as often as in the interpreter. |
| 96 | readonly replay?: "always" | "fallback" | undefined |
| 97 | } |
| 98 | |
| 99 | const fallbackOptions = (options: Options): boolean => |
| 100 | options?.errors === "all" || options?.onExcessProperty !== undefined |
| 101 | |
| 102 | // A field alone, in a Struct of its own, and in a Struct with a valid checked |
| 103 | // sibling (per-property layout), so that no earlier failure hides the field. |
| 104 | const inLayouts = ( |
| 105 | name: string, |
| 106 | field: (f: Fixtures) => Schema.Codec<unknown, unknown>, |
| 107 | inputs: ReadonlyArray<unknown>, |
| 108 | options: Pick<Case, "replay"> & { readonly ownParserReplays?: boolean } = {} |
| 109 | ): ReadonlyArray<Case> => [ |
| 110 | { |
| 111 | name, |
| 112 | schema: field, |
| 113 | inputs, |
| 114 | replay: options.replay ?? (options.ownParserReplays ? "always" : undefined) |
| 115 | }, |
| 116 | { |
| 117 | name: `${name} in a Struct`, |
| 118 | schema: (f) => Schema.Struct({ n: field(f) }), |
| 119 | inputs: inputs.map((n) => ({ n })), |
| 120 | replay: options.replay ?? (options.ownParserReplays ? "fallback" : undefined) |
| 121 | }, |
| 122 | { |
| 123 | name: `${name} next to a checked field`, |
| 124 | schema: (f) => Schema.Struct({ s: f.Checked, n: field(f) }), |
| 125 | inputs: inputs.map((n) => ({ s: "ok", n })), |
| 126 | replay: options.replay ?? (options.ownParserReplays ? "fallback" : undefined) |
| 127 | } |
| 128 | ] |
| 129 | |
| 130 | const cases: ReadonlyArray<Case> = [ |
| 131 | ...inLayouts( |
| 132 | "TemplateLiteral with overlapping oneOf parts", |
| 133 | (f) => f.OneOf, |
| 134 | ["x", "y", "", 1] |
| 135 | ), |
| 136 | ...inLayouts( |
| 137 | "nested TemplateLiteral with overlapping oneOf parts", |
| 138 | (f) => Schema.TemplateLiteral(["nested:", f.OneOf]), |
| 139 | ["nested:x", "nested:y", "wrong", 1] |
| 140 | ), |
| 141 | ...inLayouts( |
| 142 | "TemplateLiteral with oneOf inside anyOf", |
| 143 | () => |
| 144 | Schema.TemplateLiteral([Schema.Union([ |
| 145 | Schema.Union([Schema.String, Schema.Literal("x")], { mode: "oneOf" }), |
| 146 | Schema.Literal("z") |
| 147 | ])]), |
| 148 | ["x", "y", "z", 1] |
| 149 | ), |
| 150 | ...inLayouts( |
| 151 | "TemplateLiteral with overlapping numeric oneOf parts", |
| 152 | () => Schema.TemplateLiteral([Schema.Union([Schema.Number, Schema.Literal("1")], { mode: "oneOf" })]), |
| 153 | ["1", "2", "wrong", 1] |
| 154 | ), |
| 155 | ...inLayouts( |
| 156 | "TemplateLiteral transformation target", |
| 157 | (f) => |
| 158 | Schema.String.pipe(Schema.decodeTo( |
| 159 | Schema.TemplateLiteral(["x-", Schema.String]), |
| 160 | f.identity as SchemaTransformation.Transformation<`x-${string}`, string> |
| 161 | )), |
| 162 | ["x-a", "wrong", 1] |
| 163 | ), |
| 164 | ...inLayouts( |
| 165 | "TemplateLiteral transformation target with overlapping oneOf parts", |
| 166 | (f) => Schema.String.pipe(Schema.decodeTo(f.OneOf, f.identity)), |
| 167 | ["x", "y", 1] |
| 168 | ), |
| 169 | ...inLayouts( |
| 170 | "checked source with a oneOf TemplateLiteral target", |
| 171 | (f) => f.Checked.pipe(Schema.decodeTo(f.OneOf, f.identity)), |
| 172 | ["x", "y", "INVALID", 1] |
| 173 | ), |
| 174 | ...inLayouts( |
| 175 | "TemplateLiteral transformation source", |
| 176 | (f) => |
| 177 | Schema.TemplateLiteral(["x-", Schema.String]).pipe(Schema.decodeTo( |
| 178 | Schema.String, |
| 179 | f.identity as SchemaTransformation.Transformation<string, `x-${string}`> |
| 180 | )), |
| 181 | ["x-a", "wrong", 1] |
| 182 | ), |
| 183 | ...inLayouts( |
| 184 | "TemplateLiteral intermediate transformation", |
| 185 | (f) => |
| 186 | Schema.String.pipe(Schema.decodeTo( |
| 187 | Schema.TemplateLiteral(["x-", Schema.String]).pipe(Schema.decodeTo( |
| 188 | Schema.String, |
| 189 | f.identity as SchemaTransformation.Transformation<string, `x-${string}`> |
| 190 | )), |
| 191 | f.identity as SchemaTransformation.Transformation<`x-${string}`, string> |
| 192 | )), |
| 193 | ["x-a", "wrong", 1] |
| 194 | ), |
| 195 | ...inLayouts("checked source", (f) => f.DigitsToNumber, values), |
| 196 | ...inLayouts("several source checks", (f) => f.ShortToNumber, values), |
| 197 | ...inLayouts("string target", (f) => f.DigitsToString, values), |
| 198 | // emitEncoding only compiles a single Transform link, so these fields keep |
| 199 | // their previous parser on their own; inside a Struct they are inlined. |
| 200 | ...inLayouts("passthrough", (f) => f.DigitsPassthrough, values, { ownParserReplays: true }), |
| 201 | ...inLayouts("two-link chain", (f) => f.TwoLinks, values, { ownParserReplays: true }), |
| 202 | { |
| 203 | name: "Struct with only transformed fields", |
| 204 | // Passthrough fields have their own layout cases, which allow their |
| 205 | // fallback replay; here every field is inlined, so counts are exact. |
| 206 | schema: (f) => Schema.Struct({ a: f.DigitsToNumber, b: f.StringToNumber, c: f.DigitsToString }), |
| 207 | inputs: [ |
| 208 | ...values.map((a) => ({ a, b: "2", c: "3" })), |
| 209 | ...values.map((c) => ({ a: "1", b: "2", c })), |
| 210 | { b: "2", c: "3" }, |
| 211 | { a: "1", c: "3" }, |
| 212 | { a: "abc", b: 2, c: "3" }, |
| 213 | { a: "1", b: "2", c: "3", d: "4" }, |
| 214 | null, |
| 215 | [] |
| 216 | ] |
| 217 | }, |
| 218 | { |
| 219 | name: "Struct mixing checked and transformed fields", |
| 220 | schema: (f) => Schema.Struct({ s: f.Checked, n: f.DigitsToNumber, m: f.ShortToNumber }), |
| 221 | inputs: [ |
| 222 | ...values.map((n) => ({ s: "ok", n, m: "12" })), |
| 223 | ...values.map((m) => ({ s: "ok", n: "1", m })), |
| 224 | { s: "NOT OK", n: "abc", m: "1234" }, |
| 225 | { s: "ok", m: "12" }, |
| 226 | { s: "ok", n: "1", m: "12", extra: true } |
| 227 | ] |
| 228 | }, |
| 229 | { |
| 230 | name: "Struct with several checked transformations", |
| 231 | schema: (f) => Schema.Struct({ a: f.DigitsToNumber, b: f.DigitsToNumber, c: f.DigitsToString }), |
| 232 | inputs: [ |
| 233 | { a: "1", b: "2", c: "3" }, |
| 234 | { a: "x", b: "y", c: "z" }, |
| 235 | { a: "1", b: "13", c: "3" }, |
| 236 | { a: "1", b: "2", c: "7" }, |
| 237 | { a: "1", b: "2", c: 3 } |
| 238 | ] |
| 239 | }, |
| 240 | { |
| 241 | name: "optional keys", |
| 242 | schema: (f) => Schema.Struct({ a: Schema.optionalKey(f.DigitsToNumber), b: Schema.optional(f.DigitsToNumber) }), |
| 243 | inputs: [{}, { a: "1" }, { a: "x" }, { b: undefined }, { b: "1" }, { b: "x" }, { a: undefined }] |
| 244 | }, |
| 245 | { |
| 246 | name: "constructor defaults", |
| 247 | schema: (f) => Schema.Struct({ n: f.DigitsToNumber.pipe(Schema.withConstructorDefault(Effect.succeed(0))) }), |
| 248 | inputs: [{}, { n: undefined }, { n: "1" }, { n: "x" }], |
| 249 | make: [{}, { n: undefined }, { n: 5 }, { n: "x" }] |
| 250 | }, |
| 251 | { |
| 252 | name: "__proto__ key", |
| 253 | schema: (f) => Schema.Struct({ ["__proto__"]: f.DigitsToNumber, a: f.DigitsToNumber }), |
| 254 | inputs: [ |
| 255 | JSON.parse(`{"__proto__":"1","a":"2"}`), |
| 256 | JSON.parse(`{"__proto__":"x","a":"2"}`), |
| 257 | JSON.parse(`{"a":"2"}`), |
| 258 | { a: "2" } |
| 259 | ] |
| 260 | }, |
| 261 | { |
| 262 | name: "symbol key", |
| 263 | schema: (f) => Schema.Struct({ [Symbol.for("n")]: f.DigitsToNumber, a: f.DigitsToNumber }), |
| 264 | inputs: [{ [Symbol.for("n")]: "1", a: "2" }, { [Symbol.for("n")]: "x", a: "2" }, { a: "2" }] |
| 265 | }, |
| 266 | { |
| 267 | name: "nested Struct", |
| 268 | schema: (f) => Schema.Struct({ outer: Schema.Struct({ s: f.Checked, n: f.DigitsToNumber }), n: f.DigitsToNumber }), |
| 269 | inputs: [ |
| 270 | { outer: { s: "ok", n: "1" }, n: "2" }, |
| 271 | { outer: { s: "ok", n: "x" }, n: "2" }, |
| 272 | { outer: { s: "ok", n: "1" }, n: "x" }, |
| 273 | { outer: { s: "OK", n: "x" }, n: "x" } |
| 274 | ] |
| 275 | }, |
| 276 | { |
| 277 | name: "Array of Structs", |
| 278 | schema: (f) => Schema.Array(Schema.Struct({ s: f.Checked, n: f.DigitsToNumber })), |
| 279 | inputs: [[{ s: "ok", n: "1" }, { s: "ok", n: "2" }], [{ s: "ok", n: "1" }, { s: "ok", n: "x" }, { |
| 280 | s: "OK", |
| 281 | n: "y" |
| 282 | }]] |
| 283 | }, |
| 284 | { |
| 285 | name: "target checks", |
| 286 | schema: (f) => |
| 287 | Schema.Struct({ n: f.Digits.pipe(Schema.decodeTo(Schema.Number.check(Schema.isLessThan(100)), f.toNumber)) }), |
| 288 | inputs: [{ n: "1" }, { n: "100" }, { n: "x" }] |
| 289 | }, |
| 290 | // TemplateLiteral chains retain their parsers for structured diagnostics. |
| 291 | ...inLayouts( |
| 292 | "checked TemplateLiteral source", |
| 293 | (f) => |
| 294 | Schema.TemplateLiteral(["x-", Schema.String]).check(f.countedFilter((s) => s.length <= 4, "short")).pipe( |
| 295 | Schema.decodeTo( |
| 296 | Schema.String, |
| 297 | f.prefixed as unknown as SchemaTransformation.Transformation<string, `x-${string}`> |
| 298 | ) |
| 299 | ), |
| 300 | ["x-a", "x-abc", "wrong", 1], |
| 301 | { replay: "always" } |
| 302 | ), |
| 303 | ...inLayouts( |
| 304 | "checked source with a TemplateLiteral target", |
| 305 | (f) => |
| 306 | f.Digits.pipe(Schema.decodeTo( |
| 307 | Schema.TemplateLiteral(["#", Schema.String]), |
| 308 | f.prefixed as unknown as SchemaTransformation.Transformation<`#${string}`, string> |
| 309 | )), |
| 310 | ["1", "7", "x"], |
| 311 | { replay: "always" } |
| 312 | ), |
| 313 | ...inLayouts( |
| 314 | "checked TemplateLiteral source with a oneOf part", |
| 315 | (f) => |
| 316 | f.OneOf |
| 317 | .check(f.countedFilter((s) => s.length >= 1, "non-empty")).pipe(Schema.decodeTo(Schema.String, f.prefixed)), |
| 318 | ["x", "y"], |
| 319 | { replay: "always" } |
| 320 | ) |
| 321 | ] |
| 322 | |
| 323 | // Objects that belong to the schema (AST nodes, filters) or to the inputs are |
| 324 | // compared by identity; issues and objects built while parsing (for example the |
| 325 | // ASTs a TemplateLiteral builds for its diagnostics) are compared by structure. |
| 326 | const collectKnown = (root: unknown, inputs: ReadonlyArray<unknown>): Set<unknown> => { |
| 327 | const known = new Set<unknown>() |
| 328 | const visit = (value: unknown): void => { |
| 329 | if ((typeof value !== "object" && typeof value !== "function") || value === null || known.has(value)) return |
| 330 | known.add(value) |
| 331 | for (const key of Reflect.ownKeys(value)) visit((value as any)[key]) |
| 332 | } |
| 333 | visit(root) |
| 334 | inputs.forEach(visit) |
| 335 | return known |
| 336 | } |
| 337 | |
| 338 | const makeDescriber = (known: Set<unknown>) => { |
| 339 | const identities = new Map<unknown, string>() |
| 340 | const identity = (value: unknown): string => { |
| 341 | let id = identities.get(value) |
| 342 | if (id === undefined) { |
| 343 | id = `#${identities.size}` |
| 344 | identities.set(value, id) |
| 345 | } |
| 346 | return id |
| 347 | } |
| 348 | const describeValue = (value: unknown, depth = 0): unknown => { |
| 349 | if (known.has(value)) return identity(value) |
| 350 | if (typeof value === "function") return "<function>" |
| 351 | if (typeof value !== "object" || value === null) { |
| 352 | return typeof value === "number" && Number.isNaN(value) ? "NaN" : value |
| 353 | } |
| 354 | if (depth > 32) return "<deep>" |
| 355 | if (Array.isArray(value)) return value.map((item) => describeValue(item, depth + 1)) |
| 356 | return Reflect.ownKeys(value).map((key) => [describeKey(key), describeValue((value as any)[key], depth + 1)]) |
| 357 | } |
| 358 | // Symbol keys keep their identity, so that they differ from their string form |
| 359 | // and from other symbols with the same description. |
| 360 | const describeKey = (key: PropertyKey) => typeof key === "symbol" ? { symbol: identity(key) } : key |
| 361 | const describeOutput = (value: unknown): unknown => |
| 362 | Array.isArray(value) |
| 363 | ? value.map(describeOutput) |
| 364 | : typeof value === "object" && value !== null && !known.has(value) |
| 365 | ? Reflect.ownKeys(value).map((key) => [describeKey(key), describeOutput((value as any)[key])]) |
| 366 | : describeValue(value) |
| 367 | const describeResult = (result: Result.Result<unknown, unknown>) => |
| 368 | Result.isSuccess(result) ? { success: describeOutput(result.success) } : { failure: describeValue(result.failure) } |
| 369 | return { describeResult, describeOutput } |
| 370 | } |
| 371 | |
| 372 | const snapshot = ( |
| 373 | schema: Schema.Codec<unknown, unknown>, |
| 374 | { inputs, make = [] }: Case, |
| 375 | describeResult: ReturnType<typeof makeDescriber>["describeResult"], |
| 376 | checks: Array<{ readonly count: number; readonly fallback: boolean }> |
| 377 | ) => { |
| 378 | const decode = SchemaParser.decodeUnknownResult(schema) |
| 379 | const construct = SchemaParser.make(schema as Schema.Codec<unknown, unknown> & Schema.Top) |
| 380 | const run = (fallback: boolean, f: () => Result.Result<unknown, unknown>) => { |
| 381 | counters.checks = 0 |
| 382 | counters.transforms = 0 |
| 383 | const result = describeResult(f()) |
| 384 | checks.push({ count: counters.checks, fallback }) |
| 385 | return { result, transforms: counters.transforms } |
| 386 | } |
| 387 | return [ |
| 388 | ...optionSets.flatMap((options) => |
| 389 | inputs.map((input) => run(fallbackOptions(options), () => decode(input, options))) |
| 390 | ), |
| 391 | ...make.map((input) => |
| 392 | run(false, () => { |
| 393 | try { |
| 394 | return Result.succeed(construct(input as never)) |
| 395 | } catch (error) { |
| 396 | // Construction throws an Error whose cause is the schema issue. |
| 397 | const cause = error instanceof Error ? error.cause : undefined |
| 398 | if (typeof cause !== "object" || cause === null || !("~effect/SchemaIssue/Issue" in cause)) throw error |
| 399 | return Result.fail(cause) |
| 400 | } |
| 401 | }) |
| 402 | ) |
| 403 | ] |
| 404 | } |
| 405 | |
| 406 | const install = { |
| 407 | jit: async (schema: Schema.Top) => { |
| 408 | SchemaJITCompiler.enable(schema.ast) |
| 409 | SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) |
| 410 | }, |
| 411 | aot: async (schema: Schema.Top) => { |
| 412 | const asts = [schema.ast, SchemaAST.toType(schema.ast)] |
| 413 | const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-checked-sources-", import.meta.url))) |
| 414 | try { |
| 415 | const file = join(directory, "module.mjs") |
| 416 | writeFileSync(file, SchemaAOTCompiler.compile(asts.map((ast) => ({ ast, operations: ["decode", "is", "make"] })))) |
| 417 | const generated = await import(pathToFileURL(file).href) |
| 418 | generated.install(asts) |
| 419 | } finally { |
| 420 | rmSync(directory, { recursive: true, force: true }) |
| 421 | } |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | describe("Schema compilers: transformations with checked sources", { concurrent: false }, () => { |
| 426 | for (const compiler of ["jit", "aot"] as const) { |
| 427 | it(`${compiler} preserves oneOf TemplateLiteral guards and constructors`, async () => { |
| 428 | const shapes: ReadonlyArray<(f: Fixtures) => readonly [Schema.Top, (value: string) => unknown]> = [ |
| 429 | (f) => [f.OneOf, (value) => value], |
| 430 | (f) => [Schema.Struct({ value: f.OneOf }), (value) => ({ value })], |
| 431 | (f) => [Schema.Array(f.OneOf), (value) => [value]], |
| 432 | (f) => [Schema.TemplateLiteral(["nested:", f.OneOf]), (value) => `nested:${value}`] |
| 433 | ] |
| 434 | for (const shape of shapes) { |
| 435 | const [schema, input] = shape(makeFixtures()) |
| 436 | for (const compiled of [false, true]) { |
| 437 | if (compiled) await install[compiler](schema) |
| 438 | const is = SchemaParser.is(schema) |
| 439 | const make = SchemaParser.make(schema) |
| 440 | assert.isFalse(is(input("x"))) |
| 441 | assert.isTrue(is(input("y"))) |
| 442 | assert.throws(() => make(input("x") as never)) |
| 443 | deepStrictEqual(make(input("y") as never), input("y")) |
| 444 | } |
| 445 | } |
| 446 | }) |
| 447 | |
| 448 | for (const testCase of cases) { |
| 449 | it(`${compiler} matches the interpreter: ${testCase.name}`, async () => { |
| 450 | const schema = testCase.schema(makeFixtures()) as Schema.Codec<unknown, unknown> & Schema.Top |
| 451 | const { describeResult } = makeDescriber( |
| 452 | collectKnown([schema.ast, SchemaAST.toType(schema.ast)], [...testCase.inputs, ...testCase.make ?? []]) |
| 453 | ) |
| 454 | assert.isUndefined(Registry.resolve(schema.ast).compiled) |
| 455 | const interpretedChecks: Array<{ readonly count: number; readonly fallback: boolean }> = [] |
| 456 | const compiledChecks: Array<{ readonly count: number; readonly fallback: boolean }> = [] |
| 457 | const interpreted = snapshot(schema, testCase, describeResult, interpretedChecks) |
| 458 | await install[compiler](schema) |
| 459 | assert.isDefined(Registry.resolve(schema.ast).compiled) |
| 460 | const compiled = snapshot(schema, testCase, describeResult, compiledChecks) |
| 461 | deepStrictEqual(compiled, interpreted) |
| 462 | compiledChecks.forEach(({ count, fallback }, index) => { |
| 463 | const expected = interpretedChecks[index].count |
| 464 | if (testCase.replay === "always" || testCase.replay === "fallback" && fallback) { |
| 465 | assert.isAtLeast(count, expected) |
| 466 | assert.isAtMost(count, 2 * expected) |
| 467 | } else { |
| 468 | assert.strictEqual(count, expected, `check runs for decode #${index}`) |
| 469 | } |
| 470 | }) |
| 471 | }) |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | it("inlines a transformation whose source has checks", () => { |
| 476 | const f = makeFixtures() |
| 477 | const field = Codegen.generate(f.DigitsToNumber.ast, "decodeEffect") |
| 478 | assert.isDefined(field) |
| 479 | assert.notInclude(field, "R.decode(") |
| 480 | assert.include(field, "R.getCheckIssues(") |
| 481 | |
| 482 | const struct = Codegen.generate(Schema.Struct({ a: f.DigitsToNumber, b: f.StringToNumber }).ast, "decodeEffect") |
| 483 | assert.isDefined(struct) |
| 484 | // Every property is inlined, so the properties are resolved lazily. |
| 485 | assert.include(struct, "const p=i=>getProperties()[i]") |
| 486 | assert.include(struct, "invalidSource(") |
| 487 | |
| 488 | const mixed = Codegen.generate(Schema.Struct({ s: f.Checked, n: f.TwoLinks }).ast, "decodeEffect") |
| 489 | assert.isDefined(mixed) |
| 490 | assert.include(mixed, "R.invalidEncodingChecks(p1.type,v1,") |
| 491 | }) |
| 492 | |
| 493 | for (const compiler of ["jit", "aot"] as const) { |
| 494 | it(`${compiler} runs inlined source checks once`, async () => { |
| 495 | const shapes: ReadonlyArray<(f: Fixtures) => readonly [Schema.Top, (n: string) => unknown]> = [ |
| 496 | (f) => [f.DigitsToNumber, (n) => n], |
| 497 | (f) => [Schema.Struct({ n: f.DigitsToNumber }), (n) => ({ n })], |
| 498 | (f) => [Schema.Struct({ s: f.Checked, n: f.DigitsToNumber }), (n) => ({ s: "ok", n })], |
| 499 | (f) => [Schema.Struct({ n: f.TwoLinks }), (n) => ({ n })], |
| 500 | (f) => [Schema.Struct({ s: f.Checked, n: f.TwoLinks }), (n) => ({ s: "ok", n })], |
| 501 | (f) => [Schema.Struct({ n: f.DigitsPassthrough }), (n) => ({ n })] |
| 502 | ] |
| 503 | for (const shape of shapes) { |
| 504 | const [schema, input] = shape(makeFixtures()) |
| 505 | await install[compiler](schema) |
| 506 | const decode = SchemaParser.decodeUnknownResult(schema as Schema.Codec<unknown>) |
| 507 | for (const [value, succeeds] of [["1", true], ["x", false]] as const) { |
| 508 | counters.checks = 0 |
| 509 | assert.strictEqual(Result.isSuccess(decode(input(value))), succeeds) |
| 510 | assert.strictEqual(counters.checks, 1) |
| 511 | } |
| 512 | } |
| 513 | }) |
| 514 | } |
| 515 | |
| 516 | it("distinguishes symbol keys in snapshots", () => { |
| 517 | const { describeOutput } = makeDescriber(new Set()) |
| 518 | const a = Symbol("n") |
| 519 | const b = Symbol("n") |
| 520 | assert.notDeepEqual(describeOutput({ [Symbol.for("n")]: 1 }), describeOutput({ "Symbol(n)": 1 })) |
| 521 | assert.notDeepEqual(describeOutput({ [a]: 1 }), describeOutput({ [b]: 1 })) |
| 522 | assert.deepEqual(describeOutput({ [a]: 1 }), describeOutput({ [a]: 1 })) |
| 523 | }) |
| 524 | |
| 525 | it("uses TemplateLiteral parsers for transformation diagnostics", () => { |
| 526 | const f = makeFixtures() |
| 527 | const source = Schema.TemplateLiteral(["x-", Schema.String]).check(Schema.isMaxLength(4)).pipe( |
| 528 | Schema.decodeTo( |
| 529 | Schema.String, |
| 530 | f.prefixed as unknown as SchemaTransformation.Transformation<string, `x-${string}`> |
| 531 | ) |
| 532 | ) |
| 533 | const target = f.Digits.pipe(Schema.decodeTo( |
| 534 | Schema.TemplateLiteral(["#", Schema.String]), |
| 535 | f.prefixed as unknown as SchemaTransformation.Transformation<`#${string}`, string> |
| 536 | )) |
| 537 | const unchecked = Schema.String.pipe(Schema.decodeTo( |
| 538 | Schema.TemplateLiteral(["#", Schema.String]), |
| 539 | f.prefixed as SchemaTransformation.Transformation<`#${string}`, string> |
| 540 | )) |
| 541 | for (const schema of [source, target, unchecked]) { |
| 542 | assert.include(Codegen.generate(schema.ast, "decodeEffect"), "R.decode(") |
| 543 | assert.notInclude(Codegen.generate(Schema.Struct({ n: schema }).ast, "decodeEffect"), "R.getCheckIssues(") |
| 544 | } |
| 545 | }) |
| 546 | }) |