From 68e0198e0f2edb2453fee9d538f7edbd29b022a5 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 28 Jan 2025 18:43:28 +0100 Subject: [PATCH] Schema: Add `standard` API to Generate a Standard Schema --- .changeset/twenty-owls-hunt.md | 19 ++ packages/effect/package.json | 1 + packages/effect/src/Schema.ts | 81 ++++++ .../Schema/Schema/standardSchemaV1.test.ts | 249 ++++++++++++++++++ pnpm-lock.yaml | 48 +--- 5 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 .changeset/twenty-owls-hunt.md create mode 100644 packages/effect/test/Schema/Schema/standardSchemaV1.test.ts diff --git a/.changeset/twenty-owls-hunt.md b/.changeset/twenty-owls-hunt.md new file mode 100644 index 00000000000..70870ee0eb7 --- /dev/null +++ b/.changeset/twenty-owls-hunt.md @@ -0,0 +1,19 @@ +--- +"effect": minor +--- + +Schema: Add `standardSchemaV1` API to Generate a [Standard Schema v1](https://standardschema.dev/). + +**Example** + +```ts +import { Schema } from "effect" + +const schema = Schema.Struct({ + name: Schema.String +}) + +// ┌─── StandardSchemaV1<{ readonly name: string; }> +// ▼ +const standardSchema = Schema.standardSchemaV1(schema) +``` diff --git a/packages/effect/package.json b/packages/effect/package.json index 29acf397262..f2337521084 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -50,6 +50,7 @@ "zod": "^3.23.5" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } } diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index f375205ae2f..5ec5b6df15c 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -2,6 +2,7 @@ * @since 3.10.0 */ +import type { StandardSchemaV1 } from "@standard-schema/spec" import * as internalCause_ from "effect/internal/cause" import type { ArbitraryAnnotation, ArbitraryGenerationContext, LazyArbitrary } from "./Arbitrary.js" import * as array_ from "./Array.js" @@ -44,6 +45,7 @@ import type * as pretty_ from "./Pretty.js" import * as record_ from "./Record.js" import * as redacted_ from "./Redacted.js" import * as Request from "./Request.js" +import * as Runtime from "./Runtime.js" import type { ParseOptions } from "./SchemaAST.js" import * as AST from "./SchemaAST.js" import * as sortedSet_ from "./SortedSet.js" @@ -128,6 +130,85 @@ const variance = { _R: (_: never) => _ } +const makeStandardFailureResult = (message: string): StandardSchemaV1.FailureResult => ({ + issues: [{ message }] +}) + +/** + * Returns a "Standard Schema" object conforming to the [Standard Schema + * v1](https://standardschema.dev/) specification. + * + * This function creates a schema whose `validate` method attempts to decode and + * validate the provided input synchronously. If the underlying `Schema` + * includes any asynchronous components (e.g., asynchronous message resolutions + * or checks), then validation will necessarily return a `Promise` instead. + * + * Any detected defects will be reported via a single issue containing no + * `path`. + * + * @example + * ```ts + * import { Schema } from "effect" + * + * const schema = Schema.Struct({ + * name: Schema.String + * }) + * + * // ┌─── StandardSchemaV1<{ readonly name: string; }> + * // ▼ + * const standardSchema = Schema.standardSchemaV1(schema) + * ``` + * + * @category Standard Schema + * @since 3.13.0 + */ +export const standardSchemaV1 = (schema: Schema): StandardSchemaV1 => { + const decodeUnknown = ParseResult.decodeUnknown(schema) + return { + "~standard": { + version: 1, + vendor: "effect", + validate: (value) => { + const validation: Effect.Effect> = decodeUnknown(value).pipe( + Effect.matchEffect({ + onFailure: (issue) => + Effect.map(ParseResult.ArrayFormatter.formatIssue(issue), (issues) => ({ + issues: issues.map((issue) => ({ + path: issue.path, + message: issue.message + })) + })), + onSuccess: (value) => Effect.succeed({ value }) + }), + Effect.catchAllCause((cause) => Effect.succeed(makeStandardFailureResult(cause_.pretty(cause)))) + ) + try { + return Effect.runSync(validation) + } catch (e) { + if (Runtime.isFiberFailure(e)) { + const cause = e[Runtime.FiberFailureCauseId] + if (cause_.isDieType(cause) && Runtime.isAsyncFiberException(cause.defect)) { + // The error is caused by using runSync on an effect that performs async work. + // We can wait for the fiber to complete and return a promise + const fiber = cause.defect.fiber + return new Promise((resolve, reject) => { + fiber.addObserver((exit) => { + if (exit_.isFailure(exit)) { + reject(makeStandardFailureResult(cause_.pretty(exit.cause))) + } else { + resolve(exit.value as any) + } + }) + }) + } + } + return makeStandardFailureResult(`Unknown error: ${e}`) + } + } + } + } +} + interface AllAnnotations> extends Annotations.Schema, PropertySignature.Annotations {} diff --git a/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts new file mode 100644 index 00000000000..00ddec464c9 --- /dev/null +++ b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts @@ -0,0 +1,249 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec" +import { Context, Effect, ParseResult, Predicate, Schema } from "effect" +import { assertTrue, deepStrictEqual, strictEqual } from "effect/test/util" +import { describe, it } from "vitest" +import { AsyncString } from "../TestUtils.js" + +function validate( + schema: StandardSchemaV1, + input: unknown +): StandardSchemaV1.Result | Promise> { + return schema["~standard"].validate(input) +} + +const isPromise = (value: unknown): value is Promise => value instanceof Promise + +const expectSuccess = async ( + result: StandardSchemaV1.Result, + a: A +) => { + deepStrictEqual(result, { value: a }) +} + +const expectFailure = async ( + result: StandardSchemaV1.Result, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + if (result.issues !== undefined) { + if (Predicate.isFunction(issues)) { + issues(result.issues) + } else { + deepStrictEqual(result.issues, issues) + } + } else { + throw new Error("Expected issues, got undefined") + } +} + +const expectSyncSuccess = ( + schema: StandardSchemaV1, + input: unknown, + a: A +) => { + const result = validate(schema, input) + if (isPromise(result)) { + throw new Error("Expected value, got promise") + } else { + expectSuccess(result, a) + } +} + +const expectAsyncSuccess = async ( + schema: StandardSchemaV1, + input: unknown, + a: A +) => { + const result = validate(schema, input) + if (isPromise(result)) { + expectSuccess(await result, a) + } else { + throw new Error("Expected promise, got value") + } +} + +const expectSyncFailure = ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + const result = validate(schema, input) + if (isPromise(result)) { + throw new Error("Expected value, got promise") + } else { + expectFailure(result, issues) + } +} + +const expectAsyncFailure = async ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + const result = validate(schema, input) + if (isPromise(result)) { + expectFailure(await result, issues) + } else { + throw new Error("Expected promise, got value") + } +} + +const AsyncNonEmptyString = AsyncString.pipe(Schema.minLength(1)) + +describe("standardSchemaV1", () => { + it("sync decoding + sync issue formatting", () => { + const schema = Schema.NonEmptyString + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + expectSyncFailure(standardSchema, "", [ + { + message: `Expected a non empty string, actual ""`, + path: [] + } + ]) + }) + + it("sync decoding + sync custom message", () => { + const schema = Schema.NonEmptyString.annotations({ message: () => Effect.succeed("my message") }) + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + expectSyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("sync decoding + async custom message", async () => { + const schema = Schema.NonEmptyString.annotations({ + message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis")) + }) + const standardSchema = Schema.standardSchemaV1(schema) + expectSyncSuccess(standardSchema, "a", "a") + await expectAsyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("async decoding + sync issue formatting", async () => { + const schema = AsyncNonEmptyString + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: `Expected a string at least 1 character(s) long, actual ""`, + path: [] + } + ]) + }) + + it("async decoding + sync custom message", async () => { + const schema = AsyncNonEmptyString.annotations({ message: () => Effect.succeed("my message") }) + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + expectSyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + it("async decoding + async custom message", async () => { + const schema = AsyncNonEmptyString.annotations({ + message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis")) + }) + const standardSchema = Schema.standardSchemaV1(schema) + await expectAsyncSuccess(standardSchema, "a", "a") + await expectAsyncFailure(standardSchema, null, [ + { + message: "Expected string, actual null", + path: [] + } + ]) + await expectAsyncFailure(standardSchema, "", [ + { + message: "my message", + path: [] + } + ]) + }) + + describe("missing dependencies", () => { + class MagicNumber extends Context.Tag("Min")() {} + + it("sync decoding should throw", () => { + const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, { + strict: true, + decode: (n) => + Effect.gen(function*(_) { + const magicNumber = yield* MagicNumber + return n * magicNumber + }), + encode: ParseResult.succeed + }) + + const schema = DepString + const standardSchema = Schema.standardSchemaV1(schema as any) + expectSyncFailure(standardSchema, 1, (issues) => { + strictEqual(issues.length, 1) + deepStrictEqual(issues[0].path, undefined) + assertTrue(issues[0].message.includes("Service not found: Min")) + }) + }) + + it("async decoding should throw", () => { + const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, { + strict: true, + decode: (n) => + Effect.gen(function*(_) { + const magicNumber = yield* MagicNumber + yield* Effect.sleep("10 millis") + return n * magicNumber + }), + encode: ParseResult.succeed + }) + + const schema = DepString + const standardSchema = Schema.standardSchemaV1(schema as any) + expectSyncFailure(standardSchema, 1, (issues) => { + strictEqual(issues.length, 1) + deepStrictEqual(issues[0].path, undefined) + assertTrue(issues[0].message.includes("Service not found: Min")) + }) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 481933071c0..5b8733b7e96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: packages/effect: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 fast-check: specifier: ^3.23.1 version: 3.23.1 @@ -317,7 +320,7 @@ importers: version: 0.14.2 jscodeshift: specifier: ^0.16.1 - version: 0.16.1(@babel/preset-env@7.26.0(@babel/core@7.26.0)) + version: 0.16.1(@babel/preset-env@7.26.0(@babel/core@7.25.2)) tinybench: specifier: ^2.9.0 version: 2.9.0 @@ -3200,6 +3203,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testcontainers/mssqlserver@10.11.0': resolution: {integrity: sha512-/Rh/MEwCrsVbIQ/IJm1ScCY4dZ6wS3nOpG/xqdBTxAnQMN0pVpCIoQ1KzksKmoofP3XHDtveHrKp2cahfxsgeQ==} @@ -3332,9 +3338,6 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@18.19.70': - resolution: {integrity: sha512-RE+K0+KZoEpDUbGGctnGdkrLFwi1eYKTlIHNl2Um98mUkGsm1u2Ff6Ltd0e8DktTtC98uy7rSj+hO8t/QuLoVQ==} - '@types/node@20.12.14': resolution: {integrity: sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==} @@ -5533,6 +5536,7 @@ packages: libsql@0.4.5: resolution: {integrity: sha512-sorTJV6PNt94Wap27Sai5gtVLIea4Otb2LUiAUyr3p6BPOScGMKGt5F1b5X/XgkNtcsDKeX5qfeBDj+PdShclQ==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] libsql@0.4.6: @@ -10795,6 +10799,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.0.0': {} + '@testcontainers/mssqlserver@10.11.0': dependencies: testcontainers: 10.11.0 @@ -10954,10 +10960,6 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@18.19.70': - dependencies: - undici-types: 5.26.5 - '@types/node@20.12.14': dependencies: undici-types: 5.26.5 @@ -10990,7 +10992,7 @@ snapshots: '@types/ssh2@1.15.1': dependencies: - '@types/node': 18.19.70 + '@types/node': 22.9.3 '@types/stack-utils@2.0.3': {} @@ -11696,7 +11698,7 @@ snapshots: bun-types@1.1.36: dependencies: - '@types/node': 20.12.14 + '@types/node': 22.9.3 '@types/ws': 8.5.12 optional: true @@ -13413,32 +13415,6 @@ snapshots: transitivePeerDependencies: - supports-color - jscodeshift@0.16.1(@babel/preset-env@7.26.0(@babel/core@7.26.0)): - dependencies: - '@babel/core': 7.25.2 - '@babel/parser': 7.24.8 - '@babel/plugin-transform-class-properties': 7.24.7(@babel/core@7.25.2) - '@babel/plugin-transform-modules-commonjs': 7.24.8(@babel/core@7.25.2) - '@babel/plugin-transform-nullish-coalescing-operator': 7.24.7(@babel/core@7.25.2) - '@babel/plugin-transform-optional-chaining': 7.24.8(@babel/core@7.25.2) - '@babel/plugin-transform-private-methods': 7.24.7(@babel/core@7.25.2) - '@babel/preset-flow': 7.24.7(@babel/core@7.25.2) - '@babel/preset-typescript': 7.24.7(@babel/core@7.25.2) - '@babel/register': 7.24.6(@babel/core@7.25.2) - chalk: 4.1.2 - flow-parser: 0.239.1 - graceful-fs: 4.2.11 - micromatch: 4.0.7 - neo-async: 2.6.2 - node-dir: 0.1.17 - recast: 0.23.9 - temp: 0.9.4 - write-file-atomic: 5.0.1 - optionalDependencies: - '@babel/preset-env': 7.26.0(@babel/core@7.26.0) - transitivePeerDependencies: - - supports-color - jsesc@2.5.2: {} jsesc@3.0.2: {}