diff --git a/.changeset/schema-representation-refactoring.md b/.changeset/schema-representation-refactoring.md new file mode 100644 index 00000000000..6f6efe6bdf6 --- /dev/null +++ b/.changeset/schema-representation-refactoring.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Refactor the `SchemaRepresentation` module to improve clarity and maintainability. + +Add constructors for declaration, filter, and filter group revivers that infer their payload type from `payloadSchema`. diff --git a/migration/schema.md b/migration/schema.md index 5cbbd72c16b..74cfa77c6d5 100644 --- a/migration/schema.md +++ b/migration/schema.md @@ -243,7 +243,7 @@ v4 ```ts import { Schema, SchemaRepresentation } from "effect" -const doc = SchemaRepresentation.fromAST(Schema.String.ast) +const doc = SchemaRepresentation.toRepresentation(Schema.String.ast) const multi = SchemaRepresentation.toMultiDocument(doc) const codeDoc = SchemaRepresentation.toCodeDocument(multi) console.log(codeDoc.codes[0].Type) diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 7e7baa167a3..6357324caa2 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -6164,26 +6164,26 @@ Use it when you need to: At a high level: -- `fromAST` / `fromASTs` turn a schema AST into a `Document` / `MultiDocument` -- `DocumentFromJson` (schema) round-trip that document through JSON -- `toSchema` rebuilds a runtime `Schema` from the stored representation +- `toRepresentation` / `toRepresentations` turn a schema AST into a `Document` / `MultiDocument` +- `toJson` / `fromJson` round-trip that document through JSON +- `fromRepresentation` rebuilds a runtime `Schema` from the stored representation - `toJsonSchemaDocument` produces a Draft 2020-12 JSON Schema document - `toCodeDocument` prepares data for code generation (via `toMultiDocument`) ```mermaid flowchart TD - S[Schema] -->|fromAST|D{"SchemaRepresentation.Document"} - S -->|fromASTs|MD{"SchemaRepresentation.MultiDocument"} + S[Schema] -->|toRepresentation|D{"SchemaRepresentation.Document"} + S -->|toRepresentations|MD{"SchemaRepresentation.MultiDocument"} JS["JSON Schema (draft-07, draft-2020-12, openapi-3.0, openapi-3.1)"] -->JSD JD --> JS JD["JsonSchema.Document"] -->|fromJsonSchemaDocument|D - D <--> |"DocumentFromJson (schema)"|JSON + D <--> |"toJson / fromJson"|JSON D --> |toJsonSchemaDocument|JD - D --> |toSchema|S + D --> |fromRepresentation|S MD --> |toCodeDocument|CodeDocument["CodeDocument"] D --> |toMultiDocument|MD MD --> |toJsonSchemaMultiDocument|JMD[JsonSchema.MultiDocument] - MD <--> |"MultiDocumentFromJson (schema)"|JSON + MD <--> |"toJsonMultiDocument / fromJsonMultiDocument"|JSON ``` ## The data model @@ -6225,7 +6225,7 @@ Schemas that rely on transformations cannot be round-tripped, including: - `Schema.encodeTo(...)` - custom codecs or any schema that changes how values are encoded/decoded -If you serialize a transformed schema, the transformation logic will be lost. When you rebuild it with `toSchema`, you will only get the structural schema. +If you serialize a transformed schema, the transformation logic will be lost. When you rebuild it with `fromRepresentation`, you will only get the structural schema. > **Aside** (Why transformations are excluded) > @@ -6259,7 +6259,7 @@ In practice, documentation annotations like `title` and `description` are preser Some runtime schemas are represented as `Declaration` nodes. Rebuilding them requires a "reviver" function. -`toSchema` ships with a default reviver (`toSchemaDefaultReviver`) that recognizes a fixed set of constructors, including: +`fromRepresentation` ships with a default reviver (`toSchemaDefaultReviver`) that recognizes a fixed set of constructors, including: - `effect/Option`, `effect/Result`, `effect/Exit`, ... - `ReadonlyMap`, `ReadonlySet` @@ -6267,7 +6267,7 @@ Some runtime schemas are represented as `Declaration` nodes. Rebuilding them req - `FormData`, `URLSearchParams`, `Uint8Array` - `DateTime.Utc`, `effect/Duration` -If your document contains other declarations, pass a custom `reviver` to `toSchema`. +If your document contains other declarations, pass a custom `reviver` to `fromRepresentation`. ## JSON round-tripping @@ -6280,9 +6280,9 @@ Internally, these functions use a canonical JSON codec for `Document$`. This is ## Rebuilding runtime schemas -### `toSchema` +### `fromRepresentation` -`toSchema(document)` walks the representation tree and recreates a runtime schema. +`fromRepresentation(document)` walks the representation tree and recreates a runtime schema. What it does: @@ -6295,7 +6295,7 @@ What it does: If you need custom handling for declarations: ```ts -SchemaRepresentation.toSchema(document, { +SchemaRepresentation.fromRepresentation(document, { reviver: (declaration, recur) => { // Return a runtime schema to override how a Declaration is rebuilt. // Return undefined to fall back to the default behavior. diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 454dc397abf..f780777520f 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -37,10 +37,11 @@ import * as HashSet_ from "./HashSet.ts" import * as core from "./internal/core.ts" import * as InternalRecord from "./internal/record.ts" import * as InternalAnnotations from "./internal/schema/annotations.ts" -import * as InternalArbitrary from "./internal/schema/arbitrary.ts" -import * as InternalEquivalence from "./internal/schema/equivalence.ts" -import * as InternalStandard from "./internal/schema/representation.ts" import * as InternalSchema from "./internal/schema/schema.ts" +import * as InternalArbitrary from "./internal/schema/toArbitrary.ts" +import * as InternalEquivalence from "./internal/schema/toEquivalence.ts" +import * as InternalToJsonSchemaDocument from "./internal/schema/toJsonSchemaDocument.ts" +import * as InternalToRepresentation from "./internal/schema/toRepresentation.ts" import * as JsonPatch from "./JsonPatch.ts" import * as JsonSchema from "./JsonSchema.ts" import { remainder } from "./Number.ts" @@ -6535,6 +6536,17 @@ export function makeFilterGroup( return new SchemaAST.FilterGroup(checks, annotations) } +function makeFixedDeclarationReviver( + id: string, + schema: Top +): SchemaRepresentation.DeclarationReviver { + return InternalSchema.makeDeclarationReviver( + id, + Null, + ({ annotations }) => annotations === undefined ? schema : schema.annotate(annotations) + ) +} + const TRIMMED_PATTERN = "^\\S[\\s\\S]*\\S$|^\\S$|^$" /** @@ -6556,14 +6568,17 @@ const TRIMMED_PATTERN = "^\\S[\\s\\S]*\\S$|^\\S$|^$" * @since 4.0.0 */ export function isTrimmed(annotations?: Annotations.Filter) { + const regExp = new globalThis.RegExp(TRIMMED_PATTERN) return makeFilter( (s: string) => s.trim() === s, { expected: "a string with no leading or trailing whitespace", - meta: { - _tag: "isTrimmed", - regExp: new globalThis.RegExp(TRIMMED_PATTERN) + representation: { + id: "effect/schema/isTrimmed", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isTrimmed()" }), arbitrary: { constraint: { patterns: [TRIMMED_PATTERN] @@ -6574,6 +6589,24 @@ export function isTrimmed(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isTrimmed` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isTrimmed}. + * + * @see {@link isTrimmed} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isTrimmedReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isTrimmed", + Null, + ({ annotations }) => isTrimmed(annotations) +) + /** * Validates that a string matches the specified regular expression pattern. * @@ -6591,8 +6624,51 @@ export function isTrimmed(annotations?: Annotations.Filter) { * @category String checks * @since 4.0.0 */ -export const isPattern: (regExp: globalThis.RegExp, annotations?: Annotations.Filter) => SchemaAST.Filter = - SchemaAST.isPattern +export function isPattern( + regExp: globalThis.RegExp, + annotations?: Annotations.Filter +): SchemaAST.Filter { + const source = regExp.source + const flags = regExp.flags + const runtimeRegExp = flags === "" + ? `new RegExp(${format(source)})` + : `new RegExp(${format(source)}, ${format(flags)})` + return SchemaAST.isPattern(regExp, { + toCode: () => ({ runtime: `Schema.isPattern(${runtimeRegExp})` }), + ...annotations + }) +} + +const IsPatternPayload = Struct({ + source: String, + flags: String +}).check(makeFilter((payload: { readonly source: string; readonly flags: string }) => { + const result = Result_.try(() => new globalThis.RegExp(payload.source, payload.flags)) + return Result_.isSuccess(result) && + result.success.source === payload.source && + result.success.flags === payload.flags +})) + +/** + * Reviver for persisted `isPattern` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isPattern}. + * + * @see {@link isPattern} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isPatternReviver: SchemaRepresentation.FilterReviver<{ + readonly source: string + readonly flags: string +}> = { + id: "effect/schema/isPattern", + payloadSchema: IsPatternPayload, + revive: ({ annotations, payload }) => isPattern(new globalThis.RegExp(payload.source, payload.flags), annotations) +} /** * Validates that a string represents a finite number. @@ -6612,7 +6688,30 @@ export const isPattern: (regExp: globalThis.RegExp, annotations?: Annotations.Fi * @category String checks * @since 4.0.0 */ -export const isStringFinite: (annotations?: Annotations.Filter) => SchemaAST.Filter = SchemaAST.isStringFinite +export function isStringFinite(annotations?: Annotations.Filter): SchemaAST.Filter { + return SchemaAST.isStringFinite({ + toCode: () => ({ runtime: "Schema.isStringFinite()" }), + ...annotations + }) +} + +/** + * Reviver for persisted `isStringFinite` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isStringFinite}. + * + * @see {@link isStringFinite} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isStringFiniteReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isStringFinite", + Null, + ({ annotations }) => isStringFinite(annotations) +) /** * Validates that a string is a signed base-10 integer literal for Effect's @@ -6631,7 +6730,30 @@ export const isStringFinite: (annotations?: Annotations.Filter) => SchemaAST.Fil * @category String checks * @since 4.0.0 */ -export const isStringBigInt: (annotations?: Annotations.Filter) => SchemaAST.Filter = SchemaAST.isStringBigInt +export function isStringBigInt(annotations?: Annotations.Filter): SchemaAST.Filter { + return SchemaAST.isStringBigInt({ + toCode: () => ({ runtime: "Schema.isStringBigInt()" }), + ...annotations + }) +} + +/** + * Reviver for persisted `isStringBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isStringBigInt}. + * + * @see {@link isStringBigInt} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isStringBigIntReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isStringBigInt", + Null, + ({ annotations }) => isStringBigInt(annotations) +) /** * Validates that a string has the `Symbol(description)` format used by Effect's @@ -6645,7 +6767,30 @@ export const isStringBigInt: (annotations?: Annotations.Filter) => SchemaAST.Fil * @category String checks * @since 4.0.0 */ -export const isStringSymbol: (annotations?: Annotations.Filter) => SchemaAST.Filter = SchemaAST.isStringSymbol +export function isStringSymbol(annotations?: Annotations.Filter): SchemaAST.Filter { + return SchemaAST.isStringSymbol({ + toCode: () => ({ runtime: "Schema.isStringSymbol()" }), + ...annotations + }) +} + +/** + * Reviver for persisted `isStringSymbol` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isStringSymbol}. + * + * @see {@link isStringSymbol} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isStringSymbolReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isStringSymbol", + Null, + ({ annotations }) => isStringSymbol(annotations) +) /** * Returns a RegExp for validating an RFC 9562 / RFC 4122 UUID. @@ -6697,16 +6842,37 @@ export function isUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, annotations?: An regExp, { expected: version ? `a UUID v${version}` : "a UUID", - meta: { - _tag: "isUUID", - regExp, - version + representation: { + id: "effect/schema/isUUID", + payload: { version: version ?? null } }, + toJsonSchema: () => ({ pattern: regExp.source, format: "uuid" }), + toCode: () => ({ runtime: version === undefined ? "Schema.isUUID()" : `Schema.isUUID(${version})` }), ...annotations } ) } +/** + * Reviver for persisted `isUUID` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isUUID}. + * + * @see {@link isUUID} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isUUIDReviver: SchemaRepresentation.FilterReviver<{ + readonly version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null +}> = InternalSchema.makeFilterReviver( + "effect/schema/isUUID", + Struct({ version: Union([Literals([1, 2, 3, 4, 5, 6, 7, 8]), Null]) }), + ({ annotations, payload }) => isUUID(payload.version ?? undefined, annotations) +) + const GUID_REGEXP = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/ /** @@ -6738,15 +6904,35 @@ export function isGUID(annotations?: Annotations.Filter) { GUID_REGEXP, { expected: "a GUID", - meta: { - _tag: "isGUID", - regExp: GUID_REGEXP + representation: { + id: "effect/schema/isGUID", + payload: null }, + toJsonSchema: () => ({ pattern: GUID_REGEXP.source }), + toCode: () => ({ runtime: "Schema.isGUID()" }), ...annotations } ) } +/** + * Reviver for persisted `isGUID` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGUID}. + * + * @see {@link isGUID} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isGUIDReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isGUID", + Null, + ({ annotations }) => isGUID(annotations) +) + /** * Validates that a string is a valid ULID (Universally Unique Lexicographically * Sortable Identifier). @@ -6771,15 +6957,35 @@ export function isULID(annotations?: Annotations.Filter) { return isPattern( regExp, { - meta: { - _tag: "isULID", - regExp + representation: { + id: "effect/schema/isULID", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isULID()" }), ...annotations } ) } +/** + * Reviver for persisted `isULID` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isULID}. + * + * @see {@link isULID} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isULIDReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isULID", + Null, + ({ annotations }) => isULID(annotations) +) + /** * Validates that a string is valid Base64 encoded data. * @@ -6804,15 +7010,35 @@ export function isBase64(annotations?: Annotations.Filter) { regExp, { expected: "a base64 encoded string", - meta: { - _tag: "isBase64", - regExp + representation: { + id: "effect/schema/isBase64", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isBase64()" }), ...annotations } ) } +/** + * Reviver for persisted `isBase64` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isBase64}. + * + * @see {@link isBase64} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isBase64Reviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isBase64", + Null, + ({ annotations }) => isBase64(annotations) +) + /** * Validates that a string is valid Base64URL encoded data (Base64 with URL-safe * characters). @@ -6838,15 +7064,35 @@ export function isBase64Url(annotations?: Annotations.Filter) { regExp, { expected: "a base64url encoded string", - meta: { - _tag: "isBase64Url", - regExp + representation: { + id: "effect/schema/isBase64Url", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isBase64Url()" }), ...annotations } ) } +/** + * Reviver for persisted `isBase64Url` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isBase64Url}. + * + * @see {@link isBase64Url} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isBase64UrlReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isBase64Url", + Null, + ({ annotations }) => isBase64Url(annotations) +) + /** * Validates at runtime that a string starts with the specified literal prefix. * @@ -6862,15 +7108,17 @@ export function isBase64Url(annotations?: Annotations.Filter) { */ export function isStartsWith(startsWith: string, annotations?: Annotations.Filter) { const formatted = JSON.stringify(startsWith) + const regExp = new globalThis.RegExp(`^${startsWith}`) return makeFilter( (s: string) => s.startsWith(startsWith), { expected: `a string starting with ${formatted}`, - meta: { - _tag: "isStartsWith", - startsWith, - regExp: new globalThis.RegExp(`^${startsWith}`) + representation: { + id: "effect/schema/isStartsWith", + payload: { startsWith } }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: `Schema.isStartsWith(${format(startsWith)})` }), arbitrary: { constraint: { patterns: [`^${startsWith}`] @@ -6881,6 +7129,26 @@ export function isStartsWith(startsWith: string, annotations?: Annotations.Filte ) } +/** + * Reviver for persisted `isStartsWith` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isStartsWith}. + * + * @see {@link isStartsWith} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isStartsWithReviver: SchemaRepresentation.FilterReviver<{ + readonly startsWith: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isStartsWith", + Struct({ startsWith: String }), + ({ annotations, payload }) => isStartsWith(payload.startsWith, annotations) +) + /** * Validates at runtime that a string ends with the specified literal suffix. * @@ -6896,15 +7164,17 @@ export function isStartsWith(startsWith: string, annotations?: Annotations.Filte */ export function isEndsWith(endsWith: string, annotations?: Annotations.Filter) { const formatted = JSON.stringify(endsWith) + const regExp = new globalThis.RegExp(`${endsWith}$`) return makeFilter( (s: string) => s.endsWith(endsWith), { expected: `a string ending with ${formatted}`, - meta: { - _tag: "isEndsWith", - endsWith, - regExp: new globalThis.RegExp(`${endsWith}$`) + representation: { + id: "effect/schema/isEndsWith", + payload: { endsWith } }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: `Schema.isEndsWith(${format(endsWith)})` }), arbitrary: { constraint: { patterns: [`${endsWith}$`] @@ -6915,6 +7185,26 @@ export function isEndsWith(endsWith: string, annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isEndsWith` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isEndsWith}. + * + * @see {@link isEndsWith} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isEndsWithReviver: SchemaRepresentation.FilterReviver<{ + readonly endsWith: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isEndsWith", + Struct({ endsWith: String }), + ({ annotations, payload }) => isEndsWith(payload.endsWith, annotations) +) + /** * Validates at runtime that a string contains the specified literal substring. * @@ -6930,15 +7220,17 @@ export function isEndsWith(endsWith: string, annotations?: Annotations.Filter) { */ export function isIncludes(includes: string, annotations?: Annotations.Filter) { const formatted = JSON.stringify(includes) + const regExp = new globalThis.RegExp(includes) return makeFilter( (s: string) => s.includes(includes), { expected: `a string including ${formatted}`, - meta: { - _tag: "isIncludes", - includes, - regExp: new globalThis.RegExp(includes) + representation: { + id: "effect/schema/isIncludes", + payload: { includes } }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: `Schema.isIncludes(${format(includes)})` }), arbitrary: { constraint: { patterns: [includes] @@ -6949,6 +7241,26 @@ export function isIncludes(includes: string, annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isIncludes` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isIncludes}. + * + * @see {@link isIncludes} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isIncludesReviver: SchemaRepresentation.FilterReviver<{ + readonly includes: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isIncludes", + Struct({ includes: String }), + ({ annotations, payload }) => isIncludes(payload.includes, annotations) +) + const UPPERCASED_PATTERN = "^[^a-z]*$" /** @@ -6964,14 +7276,17 @@ const UPPERCASED_PATTERN = "^[^a-z]*$" * @since 4.0.0 */ export function isUppercased(annotations?: Annotations.Filter) { + const regExp = new globalThis.RegExp(UPPERCASED_PATTERN) return makeFilter( (s: string) => s.toUpperCase() === s, { expected: "a string with all characters in uppercase", - meta: { - _tag: "isUppercased", - regExp: new globalThis.RegExp(UPPERCASED_PATTERN) + representation: { + id: "effect/schema/isUppercased", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isUppercased()" }), arbitrary: { constraint: { patterns: [UPPERCASED_PATTERN] @@ -6982,6 +7297,24 @@ export function isUppercased(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isUppercased` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isUppercased}. + * + * @see {@link isUppercased} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isUppercasedReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isUppercased", + Null, + ({ annotations }) => isUppercased(annotations) +) + const LOWERCASED_PATTERN = "^[^A-Z]*$" /** @@ -6997,14 +7330,17 @@ const LOWERCASED_PATTERN = "^[^A-Z]*$" * @since 4.0.0 */ export function isLowercased(annotations?: Annotations.Filter) { + const regExp = new globalThis.RegExp(LOWERCASED_PATTERN) return makeFilter( (s: string) => s.toLowerCase() === s, { expected: "a string with all characters in lowercase", - meta: { - _tag: "isLowercased", - regExp: new globalThis.RegExp(LOWERCASED_PATTERN) + representation: { + id: "effect/schema/isLowercased", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isLowercased()" }), arbitrary: { constraint: { patterns: [LOWERCASED_PATTERN] @@ -7015,6 +7351,24 @@ export function isLowercased(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isLowercased` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLowercased}. + * + * @see {@link isLowercased} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isLowercasedReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isLowercased", + Null, + ({ annotations }) => isLowercased(annotations) +) + const CAPITALIZED_PATTERN = "^[^a-z]?.*$" /** @@ -7030,14 +7384,17 @@ const CAPITALIZED_PATTERN = "^[^a-z]?.*$" * @since 4.0.0 */ export function isCapitalized(annotations?: Annotations.Filter) { + const regExp = new globalThis.RegExp(CAPITALIZED_PATTERN) return makeFilter( (s: string) => s.charAt(0).toUpperCase() === s.charAt(0), { expected: "a string with the first character in uppercase", - meta: { - _tag: "isCapitalized", - regExp: new globalThis.RegExp(CAPITALIZED_PATTERN) + representation: { + id: "effect/schema/isCapitalized", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isCapitalized()" }), arbitrary: { constraint: { patterns: [CAPITALIZED_PATTERN] @@ -7048,6 +7405,24 @@ export function isCapitalized(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isCapitalized` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isCapitalized}. + * + * @see {@link isCapitalized} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isCapitalizedReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isCapitalized", + Null, + ({ annotations }) => isCapitalized(annotations) +) + const UNCAPITALIZED_PATTERN = "^[^A-Z]?.*$" /** @@ -7063,14 +7438,17 @@ const UNCAPITALIZED_PATTERN = "^[^A-Z]?.*$" * @since 4.0.0 */ export function isUncapitalized(annotations?: Annotations.Filter) { + const regExp = new globalThis.RegExp(UNCAPITALIZED_PATTERN) return makeFilter( (s: string) => s.charAt(0).toLowerCase() === s.charAt(0), { expected: "a string with the first character in lowercase", - meta: { - _tag: "isUncapitalized", - regExp: new globalThis.RegExp(UNCAPITALIZED_PATTERN) + representation: { + id: "effect/schema/isUncapitalized", + payload: null }, + toJsonSchema: () => ({ pattern: regExp.source }), + toCode: () => ({ runtime: "Schema.isUncapitalized()" }), arbitrary: { constraint: { patterns: [UNCAPITALIZED_PATTERN] @@ -7081,6 +7459,42 @@ export function isUncapitalized(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isUncapitalized` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isUncapitalized}. + * + * @see {@link isUncapitalized} for creating the corresponding check + * + * @category String checks + * @since 4.0.0 + */ +export const isUncapitalizedReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isUncapitalized", + Null, + ({ annotations }) => isUncapitalized(annotations) +) + +/** + * Type-level representation of {@link Finite}. + * + * @category Number + * @since 3.10.0 + */ +export interface Finite extends Number { + readonly "Rebuild": Finite +} + +/** + * Schema for finite numbers, rejecting `NaN`, `Infinity`, and `-Infinity`. + * + * @category Number + * @since 3.10.0 + */ +export const Finite: Finite = make(SchemaAST.finite) + /** * Validates that a number is finite (not `Infinity`, `-Infinity`, or `NaN`). * @@ -7099,24 +7513,25 @@ export function isUncapitalized(annotations?: Annotations.Filter) { * @category Number checks * @since 4.0.0 */ -export function isFinite(annotations?: Annotations.Filter) { - return makeFilter( - (n: number) => globalThis.Number.isFinite(n), - { - expected: "a finite number", - meta: { - _tag: "isFinite" - }, - arbitrary: { - constraint: { - noInfinity: true, - noNaN: true - } - }, - ...annotations - } - ) -} +export const isFinite: (annotations?: Annotations.Filter) => SchemaAST.Filter = SchemaAST.isFinite + +/** + * Reviver for persisted `isFinite` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isFinite}. + * + * @see {@link isFinite} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isFiniteReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isFinite", + Null, + ({ annotations }) => isFinite(annotations) +) /** * Creates a greater-than (`>`) check for any ordered type from an @@ -7359,13 +7774,35 @@ export function makeIsMultipleOf(options: { export const isGreaterThan = makeIsGreaterThan({ order: Order.Number, annotate: (exclusiveMinimum) => ({ - meta: { - _tag: "isGreaterThan", - exclusiveMinimum - } + representation: { + id: "effect/schema/isGreaterThan", + payload: { exclusiveMinimum } + }, + toJsonSchema: () => ({ exclusiveMinimum }), + toCode: () => ({ runtime: `Schema.isGreaterThan(${format(exclusiveMinimum)})` }) }) }) +/** + * Reviver for persisted `isGreaterThan` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThan}. + * + * @see {@link isGreaterThan} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isGreaterThanReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMinimum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThan", + Struct({ exclusiveMinimum: Finite }), + ({ annotations, payload }) => isGreaterThan(payload.exclusiveMinimum, annotations) +) + /** * Validates that a number is greater than or equal to the specified value * (inclusive). @@ -7387,13 +7824,35 @@ export const isGreaterThan = makeIsGreaterThan({ export const isGreaterThanOrEqualTo = makeIsGreaterThanOrEqualTo({ order: Order.Number, annotate: (minimum) => ({ - meta: { - _tag: "isGreaterThanOrEqualTo", - minimum - } + representation: { + id: "effect/schema/isGreaterThanOrEqualTo", + payload: { minimum } + }, + toJsonSchema: () => ({ minimum }), + toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualTo(${format(minimum)})` }) }) }) +/** + * Reviver for persisted `isGreaterThanOrEqualTo` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThanOrEqualTo}. + * + * @see {@link isGreaterThanOrEqualTo} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isGreaterThanOrEqualToReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThanOrEqualTo", + Struct({ minimum: Finite }), + ({ annotations, payload }) => isGreaterThanOrEqualTo(payload.minimum, annotations) +) + /** * Validates that a number is less than the specified value (exclusive). * @@ -7415,13 +7874,35 @@ export const isGreaterThanOrEqualTo = makeIsGreaterThanOrEqualTo({ export const isLessThan = makeIsLessThan({ order: Order.Number, annotate: (exclusiveMaximum) => ({ - meta: { - _tag: "isLessThan", - exclusiveMaximum - } + representation: { + id: "effect/schema/isLessThan", + payload: { exclusiveMaximum } + }, + toJsonSchema: () => ({ exclusiveMaximum }), + toCode: () => ({ runtime: `Schema.isLessThan(${format(exclusiveMaximum)})` }) }) }) +/** + * Reviver for persisted `isLessThan` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThan}. + * + * @see {@link isLessThan} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isLessThanReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMaximum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThan", + Struct({ exclusiveMaximum: Finite }), + ({ annotations, payload }) => isLessThan(payload.exclusiveMaximum, annotations) +) + /** * Validates that a number is less than or equal to the specified value * (inclusive). @@ -7443,13 +7924,35 @@ export const isLessThan = makeIsLessThan({ export const isLessThanOrEqualTo = makeIsLessThanOrEqualTo({ order: Order.Number, annotate: (maximum) => ({ - meta: { - _tag: "isLessThanOrEqualTo", - maximum - } + representation: { + id: "effect/schema/isLessThanOrEqualTo", + payload: { maximum } + }, + toJsonSchema: () => ({ maximum }), + toCode: () => ({ runtime: `Schema.isLessThanOrEqualTo(${format(maximum)})` }) }) }) +/** + * Reviver for persisted `isLessThanOrEqualTo` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThanOrEqualTo}. + * + * @see {@link isLessThanOrEqualTo} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isLessThanOrEqualToReviver: SchemaRepresentation.FilterReviver<{ + readonly maximum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThanOrEqualTo", + Struct({ maximum: Finite }), + ({ annotations, payload }) => isLessThanOrEqualTo(payload.maximum, annotations) +) + /** * Validates that a number is within a specified range. The range boundaries can * be inclusive or exclusive based on the provided options. @@ -7474,15 +7977,60 @@ export const isLessThanOrEqualTo = makeIsLessThanOrEqualTo({ export const isBetween = makeIsBetween({ order: Order.Number, annotate: (options) => { + const exclusiveMinimum = options.exclusiveMinimum ? true : undefined + const exclusiveMaximum = options.exclusiveMaximum ? true : undefined + const payload = { + minimum: options.minimum, + maximum: options.maximum, + ...(exclusiveMinimum && { exclusiveMinimum }), + ...(exclusiveMaximum && { exclusiveMaximum }) + } return { - meta: { - _tag: "isBetween", - ...options - } + representation: { + id: "effect/schema/isBetween", + payload + }, + toJsonSchema: () => ({ + [exclusiveMinimum ? "exclusiveMinimum" : "minimum"]: options.minimum, + [exclusiveMaximum ? "exclusiveMaximum" : "maximum"]: options.maximum + }), + toCode: () => ({ + runtime: `Schema.isBetween({ minimum: ${format(options.minimum)}, maximum: ${ + format(options.maximum) + }, exclusiveMinimum: ${format(exclusiveMinimum)}, exclusiveMaximum: ${format(exclusiveMaximum)} })` + }) } } }) +/** + * Reviver for persisted `isBetween` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isBetween}. + * + * @see {@link isBetween} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isBetweenReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined +}> = InternalSchema.makeFilterReviver( + "effect/schema/isBetween", + Struct({ + minimum: Finite, + maximum: Finite, + exclusiveMinimum: optional(Literal(true)), + exclusiveMaximum: optional(Literal(true)) + }), + ({ annotations, payload }) => isBetween(payload, annotations) +) + /** * Validates that a number is a multiple of the specified divisor. * @@ -7505,13 +8053,35 @@ export const isMultipleOf = makeIsMultipleOf({ zero: 0, annotate: (divisor) => ({ expected: `a value that is a multiple of ${divisor}`, - meta: { - _tag: "isMultipleOf", - divisor - } + representation: { + id: "effect/schema/isMultipleOf", + payload: { divisor } + }, + toJsonSchema: () => ({ multipleOf: divisor }), + toCode: () => ({ runtime: `Schema.isMultipleOf(${format(divisor)})` }) }) }) +/** + * Reviver for persisted `isMultipleOf` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMultipleOf}. + * + * @see {@link isMultipleOf} for creating the corresponding check + * + * @category Number checks + * @since 4.0.0 + */ +export const isMultipleOfReviver: SchemaRepresentation.FilterReviver<{ + readonly divisor: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMultipleOf", + Struct({ divisor: Finite }), + ({ annotations, payload }) => isMultipleOf(payload.divisor, annotations) +) + /** * Validates that a number is a safe integer (within the safe integer range * that can be exactly represented in JavaScript). @@ -7535,9 +8105,12 @@ export function isInt(annotations?: Annotations.Filter) { (n: number) => globalThis.Number.isSafeInteger(n), { expected: "an integer", - meta: { - _tag: "isInt" + representation: { + id: "effect/schema/isInt", + payload: null }, + toJsonSchema: () => ({ type: "integer" }), + toCode: () => ({ runtime: "Schema.isInt()" }), arbitrary: { constraint: { integer: true @@ -7548,6 +8121,24 @@ export function isInt(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isInt}. + * + * @see {@link isInt} for creating the corresponding check + * + * @category Integer checks + * @since 4.0.0 + */ +export const isIntReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isInt", + Null, + ({ annotations }) => isInt(annotations) +) + /** * Validates that a number is a 32-bit signed integer (range: -2,147,483,648 to * 2,147,483,647). @@ -7612,6 +8203,20 @@ export function isUint32(annotations?: Annotations.Filter) { ) } +const CanonicalDatePayload = String.check( + makeFilter((value) => { + const date = new globalThis.Date(value) + return !globalThis.Number.isNaN(date.getTime()) && date.toISOString() === value + }) +) +function encodeDatePayload(date: globalThis.Date): string | number { + return globalThis.Number.isNaN(date.getTime()) ? globalThis.Number.NaN : date.toISOString() +} + +function formatDateRuntime(date: globalThis.Date): string { + return `new Date(${format(date.getTime())})` +} + /** * Validates that a Date object represents a valid date (not an invalid date * like `new Date("invalid")`). @@ -7636,9 +8241,11 @@ export function isDateValid(annotations?: Annotations.Filter) { (date) => !isNaN(date.getTime()), { expected: "a valid date", - meta: { - _tag: "isDateValid" + representation: { + id: "effect/schema/isDateValid", + payload: null }, + toCode: () => ({ runtime: "Schema.isDateValid()" }), arbitrary: { constraint: { valid: true @@ -7649,6 +8256,24 @@ export function isDateValid(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isDateValid` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isDateValid}. + * + * @see {@link isDateValid} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isDateValidReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isDateValid", + Null, + ({ annotations }) => isDateValid(annotations) +) + /** * Validates that a Date is greater than the specified value (exclusive). * @@ -7665,14 +8290,39 @@ export function isDateValid(annotations?: Annotations.Filter) { */ export const isGreaterThanDate = makeIsGreaterThan({ order: Order.Date, - annotate: (exclusiveMinimum) => ({ - meta: { - _tag: "isGreaterThanDate", - exclusiveMinimum + annotate: (exclusiveMinimum) => { + const encoded = encodeDatePayload(exclusiveMinimum) + return { + representation: { + id: "effect/schema/isGreaterThanDate", + payload: { exclusiveMinimum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isGreaterThanDate(${formatDateRuntime(exclusiveMinimum)})` }) } - }) + } }) +/** + * Reviver for persisted `isGreaterThanDate` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThanDate}. + * + * @see {@link isGreaterThanDate} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isGreaterThanDateReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMinimum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThanDate", + Struct({ exclusiveMinimum: CanonicalDatePayload }), + ({ annotations, payload }) => isGreaterThanDate(new globalThis.Date(payload.exclusiveMinimum), annotations) +) + /** * Validates that a Date is greater than or equal to the specified date * (inclusive). @@ -7695,14 +8345,39 @@ export const isGreaterThanDate = makeIsGreaterThan({ */ export const isGreaterThanOrEqualToDate = makeIsGreaterThanOrEqualTo({ order: Order.Date, - annotate: (minimum) => ({ - meta: { - _tag: "isGreaterThanOrEqualToDate", - minimum + annotate: (minimum) => { + const encoded = encodeDatePayload(minimum) + return { + representation: { + id: "effect/schema/isGreaterThanOrEqualToDate", + payload: { minimum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualToDate(${formatDateRuntime(minimum)})` }) } - }) + } }) +/** + * Reviver for persisted `isGreaterThanOrEqualToDate` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThanOrEqualToDate}. + * + * @see {@link isGreaterThanOrEqualToDate} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isGreaterThanOrEqualToDateReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThanOrEqualToDate", + Struct({ minimum: CanonicalDatePayload }), + ({ annotations, payload }) => isGreaterThanOrEqualToDate(new globalThis.Date(payload.minimum), annotations) +) + /** * Validates that a Date is less than the specified value (exclusive). * @@ -7719,14 +8394,39 @@ export const isGreaterThanOrEqualToDate = makeIsGreaterThanOrEqualTo({ */ export const isLessThanDate = makeIsLessThan({ order: Order.Date, - annotate: (exclusiveMaximum) => ({ - meta: { - _tag: "isLessThanDate", - exclusiveMaximum + annotate: (exclusiveMaximum) => { + const encoded = encodeDatePayload(exclusiveMaximum) + return { + representation: { + id: "effect/schema/isLessThanDate", + payload: { exclusiveMaximum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isLessThanDate(${formatDateRuntime(exclusiveMaximum)})` }) } - }) + } }) +/** + * Reviver for persisted `isLessThanDate` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThanDate}. + * + * @see {@link isLessThanDate} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isLessThanDateReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMaximum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThanDate", + Struct({ exclusiveMaximum: CanonicalDatePayload }), + ({ annotations, payload }) => isLessThanDate(new globalThis.Date(payload.exclusiveMaximum), annotations) +) + /** * Validates that a Date is less than or equal to the specified date * (inclusive). @@ -7749,14 +8449,39 @@ export const isLessThanDate = makeIsLessThan({ */ export const isLessThanOrEqualToDate = makeIsLessThanOrEqualTo({ order: Order.Date, - annotate: (maximum) => ({ - meta: { - _tag: "isLessThanOrEqualToDate", - maximum + annotate: (maximum) => { + const encoded = encodeDatePayload(maximum) + return { + representation: { + id: "effect/schema/isLessThanOrEqualToDate", + payload: { maximum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isLessThanOrEqualToDate(${formatDateRuntime(maximum)})` }) } - }) + } }) +/** + * Reviver for persisted `isLessThanOrEqualToDate` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThanOrEqualToDate}. + * + * @see {@link isLessThanOrEqualToDate} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isLessThanOrEqualToDateReviver: SchemaRepresentation.FilterReviver<{ + readonly maximum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThanOrEqualToDate", + Struct({ maximum: CanonicalDatePayload }), + ({ annotations, payload }) => isLessThanOrEqualToDate(new globalThis.Date(payload.maximum), annotations) +) + /** * Validates that a Date is within a specified range. The range boundaries can * be inclusive or exclusive based on the provided options. @@ -7779,14 +8504,70 @@ export const isLessThanOrEqualToDate = makeIsLessThanOrEqualTo({ */ export const isBetweenDate = makeIsBetween({ order: Order.Date, - annotate: (options) => ({ - meta: { - _tag: "isBetweenDate", - ...options + annotate: (options) => { + const exclusiveMinimum = options.exclusiveMinimum ? true : undefined + const exclusiveMaximum = options.exclusiveMaximum ? true : undefined + const payload = { + minimum: encodeDatePayload(options.minimum), + maximum: encodeDatePayload(options.maximum), + ...(exclusiveMinimum && { exclusiveMinimum }), + ...(exclusiveMaximum && { exclusiveMaximum }) } - }) + return { + representation: { + id: "effect/schema/isBetweenDate", + payload + }, + toJsonSchema: () => ({}), + toCode: () => ({ + runtime: `Schema.isBetweenDate({ minimum: ${formatDateRuntime(options.minimum)}, maximum: ${ + formatDateRuntime(options.maximum) + }, exclusiveMinimum: ${format(exclusiveMinimum)}, exclusiveMaximum: ${format(exclusiveMaximum)} })` + }) + } + } }) +/** + * Reviver for persisted `isBetweenDate` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isBetweenDate}. + * + * @see {@link isBetweenDate} for creating the corresponding check + * + * @category Date checks + * @since 4.0.0 + */ +export const isBetweenDateReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: string + readonly maximum: string + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined +}> = InternalSchema.makeFilterReviver( + "effect/schema/isBetweenDate", + Struct({ + minimum: CanonicalDatePayload, + maximum: CanonicalDatePayload, + exclusiveMinimum: optional(Literal(true)), + exclusiveMaximum: optional(Literal(true)) + }), + ({ annotations, payload }) => + isBetweenDate( + { + minimum: new globalThis.Date(payload.minimum), + maximum: new globalThis.Date(payload.maximum), + exclusiveMinimum: payload.exclusiveMinimum, + exclusiveMaximum: payload.exclusiveMaximum + }, + annotations + ) +) + +const CanonicalBigIntPayload = String.check( + makeFilter((value) => /^(?:0|-?[1-9]\d*)$/.test(value)) +) /** * Validates that a BigInt is greater than the specified value (exclusive). * @@ -7803,14 +8584,39 @@ export const isBetweenDate = makeIsBetween({ */ export const isGreaterThanBigInt = makeIsGreaterThan({ order: Order.BigInt, - annotate: (exclusiveMinimum) => ({ - meta: { - _tag: "isGreaterThanBigInt", - exclusiveMinimum + annotate: (exclusiveMinimum) => { + const encoded = exclusiveMinimum.toString(10) + return { + representation: { + id: "effect/schema/isGreaterThanBigInt", + payload: { exclusiveMinimum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isGreaterThanBigInt(${format(exclusiveMinimum)})` }) } - }) + } }) +/** + * Reviver for persisted `isGreaterThanBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThanBigInt}. + * + * @see {@link isGreaterThanBigInt} for creating the corresponding check + * + * @category BigInt checks + * @since 4.0.0 + */ +export const isGreaterThanBigIntReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMinimum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThanBigInt", + Struct({ exclusiveMinimum: CanonicalBigIntPayload }), + ({ annotations, payload }) => isGreaterThanBigInt(globalThis.BigInt(payload.exclusiveMinimum), annotations) +) + /** * Validates that a BigInt is greater than or equal to the specified value * (inclusive). @@ -7828,14 +8634,39 @@ export const isGreaterThanBigInt = makeIsGreaterThan({ */ export const isGreaterThanOrEqualToBigInt = makeIsGreaterThanOrEqualTo({ order: Order.BigInt, - annotate: (minimum) => ({ - meta: { - _tag: "isGreaterThanOrEqualToBigInt", - minimum + annotate: (minimum) => { + const encoded = minimum.toString(10) + return { + representation: { + id: "effect/schema/isGreaterThanOrEqualToBigInt", + payload: { minimum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualToBigInt(${format(minimum)})` }) } - }) + } }) +/** + * Reviver for persisted `isGreaterThanOrEqualToBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isGreaterThanOrEqualToBigInt}. + * + * @see {@link isGreaterThanOrEqualToBigInt} for creating the corresponding check + * + * @category BigInt checks + * @since 4.0.0 + */ +export const isGreaterThanOrEqualToBigIntReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isGreaterThanOrEqualToBigInt", + Struct({ minimum: CanonicalBigIntPayload }), + ({ annotations, payload }) => isGreaterThanOrEqualToBigInt(globalThis.BigInt(payload.minimum), annotations) +) + /** * Validates that a BigInt is less than the specified value (exclusive). * @@ -7852,14 +8683,39 @@ export const isGreaterThanOrEqualToBigInt = makeIsGreaterThanOrEqualTo({ */ export const isLessThanBigInt = makeIsLessThan({ order: Order.BigInt, - annotate: (exclusiveMaximum) => ({ - meta: { - _tag: "isLessThanBigInt", - exclusiveMaximum + annotate: (exclusiveMaximum) => { + const encoded = exclusiveMaximum.toString(10) + return { + representation: { + id: "effect/schema/isLessThanBigInt", + payload: { exclusiveMaximum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isLessThanBigInt(${format(exclusiveMaximum)})` }) } - }) + } }) +/** + * Reviver for persisted `isLessThanBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThanBigInt}. + * + * @see {@link isLessThanBigInt} for creating the corresponding check + * + * @category BigInt checks + * @since 4.0.0 + */ +export const isLessThanBigIntReviver: SchemaRepresentation.FilterReviver<{ + readonly exclusiveMaximum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThanBigInt", + Struct({ exclusiveMaximum: CanonicalBigIntPayload }), + ({ annotations, payload }) => isLessThanBigInt(globalThis.BigInt(payload.exclusiveMaximum), annotations) +) + /** * Validates that a BigInt is less than or equal to the specified value * (inclusive). @@ -7877,14 +8733,39 @@ export const isLessThanBigInt = makeIsLessThan({ */ export const isLessThanOrEqualToBigInt = makeIsLessThanOrEqualTo({ order: Order.BigInt, - annotate: (maximum) => ({ - meta: { - _tag: "isLessThanOrEqualToBigInt", - maximum + annotate: (maximum) => { + const encoded = maximum.toString(10) + return { + representation: { + id: "effect/schema/isLessThanOrEqualToBigInt", + payload: { maximum: encoded } + }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isLessThanOrEqualToBigInt(${format(maximum)})` }) } - }) + } }) +/** + * Reviver for persisted `isLessThanOrEqualToBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLessThanOrEqualToBigInt}. + * + * @see {@link isLessThanOrEqualToBigInt} for creating the corresponding check + * + * @category BigInt checks + * @since 4.0.0 + */ +export const isLessThanOrEqualToBigIntReviver: SchemaRepresentation.FilterReviver<{ + readonly maximum: string +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLessThanOrEqualToBigInt", + Struct({ maximum: CanonicalBigIntPayload }), + ({ annotations, payload }) => isLessThanOrEqualToBigInt(globalThis.BigInt(payload.maximum), annotations) +) + /** * Validates that a BigInt is within a specified range. The range boundaries can * be inclusive or exclusive based on the provided options. @@ -7902,14 +8783,67 @@ export const isLessThanOrEqualToBigInt = makeIsLessThanOrEqualTo({ */ export const isBetweenBigInt = makeIsBetween({ order: Order.BigInt, - annotate: (options) => ({ - meta: { - _tag: "isBetweenBigInt", - ...options + annotate: (options) => { + const exclusiveMinimum = options.exclusiveMinimum ? true : undefined + const exclusiveMaximum = options.exclusiveMaximum ? true : undefined + const payload = { + minimum: options.minimum.toString(10), + maximum: options.maximum.toString(10), + ...(exclusiveMinimum && { exclusiveMinimum }), + ...(exclusiveMaximum && { exclusiveMaximum }) } - }) + return { + representation: { + id: "effect/schema/isBetweenBigInt", + payload + }, + toJsonSchema: () => ({}), + toCode: () => ({ + runtime: `Schema.isBetweenBigInt({ minimum: ${format(options.minimum)}, maximum: ${ + format(options.maximum) + }, exclusiveMinimum: ${format(exclusiveMinimum)}, exclusiveMaximum: ${format(exclusiveMaximum)} })` + }) + } + } }) +/** + * Reviver for persisted `isBetweenBigInt` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isBetweenBigInt}. + * + * @see {@link isBetweenBigInt} for creating the corresponding check + * + * @category BigInt checks + * @since 4.0.0 + */ +export const isBetweenBigIntReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: string + readonly maximum: string + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined +}> = InternalSchema.makeFilterReviver( + "effect/schema/isBetweenBigInt", + Struct({ + minimum: CanonicalBigIntPayload, + maximum: CanonicalBigIntPayload, + exclusiveMinimum: optional(Literal(true)), + exclusiveMaximum: optional(Literal(true)) + }), + ({ annotations, payload }) => + isBetweenBigInt( + { + minimum: globalThis.BigInt(payload.minimum), + maximum: globalThis.BigInt(payload.maximum), + exclusiveMinimum: payload.exclusiveMinimum, + exclusiveMaximum: payload.exclusiveMaximum + }, + annotations + ) +) + /** * Validates that a BigDecimal is greater than the specified value (exclusive). * @@ -7972,6 +8906,7 @@ export const isBetweenBigDecimal = makeIsBetween({ formatter: (bd) => BigDecimal_.format(bd) }) +const CanonicalLength = Number.check(makeFilter((value) => globalThis.Number.isInteger(value) && value >= 0)) /** * Validates that a value has at least the specified length. Works with strings * and arrays. @@ -8007,10 +8942,12 @@ export function isMinLength(minLength: number, annotations?: Annotations.Filter) (input) => input.length >= minLength, { expected: `a value with a length of at least ${minLength}`, - meta: { - _tag: "isMinLength", - minLength + representation: { + id: "effect/schema/isMinLength", + payload: { minLength } }, + toJsonSchema: ({ type }) => type === "array" ? { minItems: minLength } : { minLength }, + toCode: () => ({ runtime: `Schema.isMinLength(${minLength})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8022,6 +8959,26 @@ export function isMinLength(minLength: number, annotations?: Annotations.Filter) ) } +/** + * Reviver for persisted `isMinLength` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMinLength}. + * + * @see {@link isMinLength} for creating the corresponding check + * + * @category Length checks + * @since 4.0.0 + */ +export const isMinLengthReviver: SchemaRepresentation.FilterReviver<{ + readonly minLength: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMinLength", + Struct({ minLength: CanonicalLength }), + ({ annotations, payload }) => isMinLength(payload.minLength, annotations) +) + /** * Validates that a value has at least one element. Works with strings and arrays. * This is equivalent to `isMinLength(1)`. @@ -8071,10 +9028,12 @@ export function isMaxLength(maxLength: number, annotations?: Annotations.Filter) (input) => input.length <= maxLength, { expected: `a value with a length of at most ${maxLength}`, - meta: { - _tag: "isMaxLength", - maxLength + representation: { + id: "effect/schema/isMaxLength", + payload: { maxLength } }, + toJsonSchema: ({ type }) => type === "array" ? { maxItems: maxLength } : { maxLength }, + toCode: () => ({ runtime: `Schema.isMaxLength(${maxLength})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8086,6 +9045,26 @@ export function isMaxLength(maxLength: number, annotations?: Annotations.Filter) ) } +/** + * Reviver for persisted `isMaxLength` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMaxLength}. + * + * @see {@link isMaxLength} for creating the corresponding check + * + * @category Length checks + * @since 4.0.0 + */ +export const isMaxLengthReviver: SchemaRepresentation.FilterReviver<{ + readonly maxLength: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMaxLength", + Struct({ maxLength: CanonicalLength }), + ({ annotations, payload }) => isMaxLength(payload.maxLength, annotations) +) + /** * Validates that a value's length is within the specified range. Works with * strings and arrays. @@ -8115,11 +9094,16 @@ export function isLengthBetween(minimum: number, maximum: number, annotations?: expected: minimum === maximum ? `a value with a length of ${minimum}` : `a value with a length between ${minimum} and ${maximum}`, - meta: { - _tag: "isLengthBetween", - minimum, - maximum + + representation: { + id: "effect/schema/isLengthBetween", + payload: { minimum, maximum } }, + toJsonSchema: ({ type }) => + type === "array" + ? { allOf: [{ minItems: minimum }, { maxItems: maximum }] } + : { allOf: [{ minLength: minimum }, { maxLength: maximum }] }, + toCode: () => ({ runtime: `Schema.isLengthBetween(${minimum}, ${maximum})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8132,6 +9116,27 @@ export function isLengthBetween(minimum: number, maximum: number, annotations?: ) } +/** + * Reviver for persisted `isLengthBetween` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isLengthBetween}. + * + * @see {@link isLengthBetween} for creating the corresponding check + * + * @category Length checks + * @since 4.0.0 + */ +export const isLengthBetweenReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isLengthBetween", + Struct({ minimum: CanonicalLength, maximum: CanonicalLength }), + ({ annotations, payload }) => isLengthBetween(payload.minimum, payload.maximum, annotations) +) + /** * Validates that a value has at least the specified size. Works with values * that have a `size` property, such as `Set` or `Map`. @@ -8158,10 +9163,12 @@ export function isMinSize(minSize: number, annotations?: Annotations.Filter) { (input) => input.size >= minSize, { expected: `a value with a size of at least ${minSize}`, - meta: { - _tag: "isMinSize", - minSize + representation: { + id: "effect/schema/isMinSize", + payload: { minSize } }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isMinSize(${minSize})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8173,6 +9180,26 @@ export function isMinSize(minSize: number, annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isMinSize` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMinSize}. + * + * @see {@link isMinSize} for creating the corresponding check + * + * @category Size checks + * @since 4.0.0 + */ +export const isMinSizeReviver: SchemaRepresentation.FilterReviver<{ + readonly minSize: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMinSize", + Struct({ minSize: CanonicalLength }), + ({ annotations, payload }) => isMinSize(payload.minSize, annotations) +) + /** * Validates that a value has at most the specified size. Works with values * that have a `size` property, such as `Set` or `Map`. @@ -8199,10 +9226,12 @@ export function isMaxSize(maxSize: number, annotations?: Annotations.Filter) { (input) => input.size <= maxSize, { expected: `a value with a size of at most ${maxSize}`, - meta: { - _tag: "isMaxSize", - maxSize + representation: { + id: "effect/schema/isMaxSize", + payload: { maxSize } }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isMaxSize(${maxSize})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8214,6 +9243,26 @@ export function isMaxSize(maxSize: number, annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isMaxSize` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMaxSize}. + * + * @see {@link isMaxSize} for creating the corresponding check + * + * @category Size checks + * @since 4.0.0 + */ +export const isMaxSizeReviver: SchemaRepresentation.FilterReviver<{ + readonly maxSize: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMaxSize", + Struct({ maxSize: CanonicalLength }), + ({ annotations, payload }) => isMaxSize(payload.maxSize, annotations) +) + /** * Validates that a value's size is within the specified range. Works with * values that have a `size` property, such as `Set` or `Map`. @@ -8243,11 +9292,13 @@ export function isSizeBetween(minimum: number, maximum: number, annotations?: An expected: minimum === maximum ? `a value with a size of ${minimum}` : `a value with a size between ${minimum} and ${maximum}`, - meta: { - _tag: "isSizeBetween", - minimum, - maximum + + representation: { + id: "effect/schema/isSizeBetween", + payload: { minimum, maximum } }, + toJsonSchema: () => ({}), + toCode: () => ({ runtime: `Schema.isSizeBetween(${minimum}, ${maximum})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8260,6 +9311,27 @@ export function isSizeBetween(minimum: number, maximum: number, annotations?: An ) } +/** + * Reviver for persisted `isSizeBetween` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isSizeBetween}. + * + * @see {@link isSizeBetween} for creating the corresponding check + * + * @category Size checks + * @since 4.0.0 + */ +export const isSizeBetweenReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isSizeBetween", + Struct({ minimum: CanonicalLength, maximum: CanonicalLength }), + ({ annotations, payload }) => isSizeBetween(payload.minimum, payload.maximum, annotations) +) + /** * Validates that an object contains at least the specified number of * properties. This includes both string and symbol keys when counting @@ -8286,10 +9358,12 @@ export function isMinProperties(minProperties: number, annotations?: Annotations (input) => Reflect.ownKeys(input).length >= minProperties, { expected: `a value with at least ${minProperties === 1 ? "1 entry" : `${minProperties} entries`}`, - meta: { - _tag: "isMinProperties", - minProperties + representation: { + id: "effect/schema/isMinProperties", + payload: { minProperties } }, + toJsonSchema: () => ({ minProperties }), + toCode: () => ({ runtime: `Schema.isMinProperties(${minProperties})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8301,6 +9375,26 @@ export function isMinProperties(minProperties: number, annotations?: Annotations ) } +/** + * Reviver for persisted `isMinProperties` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMinProperties}. + * + * @see {@link isMinProperties} for creating the corresponding check + * + * @category Object checks + * @since 4.0.0 + */ +export const isMinPropertiesReviver: SchemaRepresentation.FilterReviver<{ + readonly minProperties: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMinProperties", + Struct({ minProperties: CanonicalLength }), + ({ annotations, payload }) => isMinProperties(payload.minProperties, annotations) +) + /** * Validates that an object contains at most the specified number of properties. * This includes both string and symbol keys when counting properties. @@ -8326,10 +9420,12 @@ export function isMaxProperties(maxProperties: number, annotations?: Annotations (input) => Reflect.ownKeys(input).length <= maxProperties, { expected: `a value with at most ${maxProperties === 1 ? "1 entry" : `${maxProperties} entries`}`, - meta: { - _tag: "isMaxProperties", - maxProperties + representation: { + id: "effect/schema/isMaxProperties", + payload: { maxProperties } }, + toJsonSchema: () => ({ maxProperties }), + toCode: () => ({ runtime: `Schema.isMaxProperties(${maxProperties})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8341,6 +9437,26 @@ export function isMaxProperties(maxProperties: number, annotations?: Annotations ) } +/** + * Reviver for persisted `isMaxProperties` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isMaxProperties}. + * + * @see {@link isMaxProperties} for creating the corresponding check + * + * @category Object checks + * @since 4.0.0 + */ +export const isMaxPropertiesReviver: SchemaRepresentation.FilterReviver<{ + readonly maxProperties: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isMaxProperties", + Struct({ maxProperties: CanonicalLength }), + ({ annotations, payload }) => isMaxProperties(payload.maxProperties, annotations) +) + /** * Validates that an object contains between `minimum` and `maximum` properties (inclusive). * This includes both string and symbol keys when counting properties. @@ -8370,11 +9486,13 @@ export function isPropertiesLengthBetween(minimum: number, maximum: number, anno expected: minimum === maximum ? `a value with exactly ${minimum === 1 ? "1 entry" : `${minimum} entries`}` : `a value with between ${minimum} and ${maximum} entries`, - meta: { - _tag: "isPropertiesLengthBetween", - minimum, - maximum + + representation: { + id: "effect/schema/isPropertiesLengthBetween", + payload: { minimum, maximum } }, + toJsonSchema: () => ({ minProperties: minimum, maxProperties: maximum }), + toCode: () => ({ runtime: `Schema.isPropertiesLengthBetween(${minimum}, ${maximum})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, arbitrary: { constraint: { @@ -8387,6 +9505,27 @@ export function isPropertiesLengthBetween(minimum: number, maximum: number, anno ) } +/** + * Reviver for persisted `isPropertiesLengthBetween` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isPropertiesLengthBetween}. + * + * @see {@link isPropertiesLengthBetween} for creating the corresponding check + * + * @category Object checks + * @since 4.0.0 + */ +export const isPropertiesLengthBetweenReviver: SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number +}> = InternalSchema.makeFilterReviver( + "effect/schema/isPropertiesLengthBetween", + Struct({ minimum: CanonicalLength, maximum: CanonicalLength }), + ({ annotations, payload }) => isPropertiesLengthBetween(payload.minimum, payload.maximum, annotations) +) + /** * Validates that every own property key of an object satisfies the encoded side * of the provided key schema. @@ -8424,16 +9563,37 @@ export function isPropertyNames(keySchema: Constraint, annotations?: Annotations }, { expected: "an object with property names matching the schema", - meta: { - _tag: "isPropertyNames", - propertyNames: propertyNames.ast + representation: { + id: "effect/schema/isPropertyNames", + payload: null, + schemas: [propertyNames.ast] }, + toJsonSchema: ({ schemas }) => ({ propertyNames: schemas[0] }), + toCode: ({ schemas }) => ({ runtime: `Schema.isPropertyNames(${schemas[0].runtime})` }), [SchemaAST.STRUCTURAL_ANNOTATION_KEY]: true, ...annotations } ) } +/** + * Reviver for persisted `isPropertyNames` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isPropertyNames}. + * + * @see {@link isPropertyNames} for creating the corresponding check + * + * @category Object checks + * @since 4.0.0 + */ +export const isPropertyNamesReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isPropertyNames", + Null, + ({ annotations, schemas }) => isPropertyNames(schemas[0], annotations) +) + /** * Validates that all items in an array are unique according to Effect equality. * @@ -8456,9 +9616,12 @@ export function isUnique(annotations?: Annotations.Filter) { (input) => Arr.dedupeWith(input, equivalence).length === input.length, { expected: "an array with unique items", - meta: { - _tag: "isUnique" + representation: { + id: "effect/schema/isUnique", + payload: null }, + toJsonSchema: () => ({ uniqueItems: true }), + toCode: () => ({ runtime: "Schema.isUnique()" }), arbitrary: { constraint: { unique: true @@ -8469,6 +9632,24 @@ export function isUnique(annotations?: Annotations.Filter) { ) } +/** + * Reviver for persisted `isUnique` checks. + * + * **When to use** + * + * Use when reconstructing documents that may contain checks created by {@link isUnique}. + * + * @see {@link isUnique} for creating the corresponding check + * + * @category Array checks + * @since 4.0.0 + */ +export const isUniqueReviver: SchemaRepresentation.FilterReviver = InternalSchema.makeFilterReviver( + "effect/schema/isUnique", + Null, + ({ annotations }) => isUnique(annotations) +) + // ----------------------------------------------------------------------------- // Built-in Schemas // ----------------------------------------------------------------------------- @@ -8586,14 +9767,15 @@ export function Option(value: A): Option { return Effect.fail(new SchemaIssue.InvalidType(ast, Option_.some(input))) }, { - typeConstructor: { - _tag: "effect/Option" - }, - generation: { - runtime: `Schema.Option(?)`, - Type: `Option.Option`, - importDeclaration: `import * as Option from "effect/Option"` + representation: { + id: "effect/schema/Option", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Option(${typeParameters[0].runtime})`, + Type: `Option.Option<${typeParameters[0].Type}>`, + importDeclarations: [`import * as Option from "effect/Option"`] + }), expected: "Option", toCodec: ([value]) => link>()( @@ -8625,6 +9807,27 @@ export function Option(value: A): Option { return make(schema.ast, { value }) } +/** + * Reviver for persisted `Option` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Option}. + * + * @see {@link Option} for creating the corresponding schema + * + * @category Option + * @since 4.0.0 + */ +export const OptionReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Option", + Null, + ({ annotations, typeParameters }) => { + const schema = Option(typeParameters[0]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link OptionFromNullOr}. * @@ -8892,14 +10095,15 @@ export function Result( } }, { - typeConstructor: { - _tag: "effect/Result" - }, - generation: { - runtime: `Schema.Result(?, ?)`, - Type: `Result.Result`, - importDeclaration: `import * as Result from "effect/Result"` + representation: { + id: "effect/schema/Result", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Result(${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `Result.Result<${typeParameters[0].Type}, ${typeParameters[1].Type}>`, + importDeclarations: [`import * as Result from "effect/Result"`] + }), expected: "Result", toCodec: ([success, failure]) => link>()( @@ -8939,6 +10143,27 @@ export function Result( return make(schema.ast, { success, failure }) } +/** + * Reviver for persisted {@link Result} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Result}. + * + * @see {@link Result} for creating the corresponding schema + * + * @category schemas + * @since 4.0.0 + */ +export const ResultReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Result", + Null, + ({ annotations, typeParameters }) => { + const schema = Result(typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link Redacted}. * @@ -8956,6 +10181,37 @@ export interface Redacted extends readonly value: S } +type RedactedRepresentationOptions = { + readonly label?: string | undefined + readonly disallowJsonEncode?: true | undefined +} + +type NormalizedRedactedOptions = + | { readonly label: string } + | { readonly disallowJsonEncode: true } + | { readonly label: string; readonly disallowJsonEncode: true } + +type RedactedRepresentationPayload = RedactedRepresentationOptions | null + +const RedactedOptionsPayload = declare((input): input is RedactedRepresentationOptions => { + if (!Predicate.isObject(input)) { + return false + } + const keys = globalThis.Object.keys(input) + return keys.length > 0 && keys.every((key) => { + switch (key) { + case "label": + return typeof input[key] === "string" + case "disallowJsonEncode": + return input[key] === true + default: + return false + } + }) +}) + +const RedactedRepresentationPayload: Decoder = Union([Null, RedactedOptionsPayload]) + /** * Schema for values that hide sensitive information from error output and * inspection. @@ -8984,8 +10240,15 @@ export function Redacted(value: S, options?: { readonly label?: string | undefined readonly disallowJsonEncode?: boolean | undefined }): Redacted { - const decodeLabel = typeof options?.label === "string" - ? SchemaParser.decodeUnknownEffect(Literal(options.label)) + const label = typeof options?.label === "string" ? options.label : undefined + const disallowJsonEncode = options?.disallowJsonEncode === true + const normalizedOptions: NormalizedRedactedOptions | undefined = label !== undefined + ? disallowJsonEncode ? { label, disallowJsonEncode: true } : { label } + : disallowJsonEncode + ? { disallowJsonEncode: true } + : undefined + const decodeLabel = label !== undefined + ? SchemaParser.decodeUnknownEffect(Literal(label)) : undefined const schema = declareConstructor, Redacted_.Redacted>()( [value], @@ -9017,22 +10280,24 @@ export function Redacted(value: S, options?: { return Effect.fail(new SchemaIssue.InvalidType(ast, Option_.some(input))) }, { - typeConstructor: { - _tag: "effect/Redacted", - options - }, - generation: { - runtime: options !== undefined ? `Schema.Redacted(?, ${format(options)})` : `Schema.Redacted(?)`, - Type: `Redacted.Redacted`, - importDeclaration: `import * as Redacted from "effect/Redacted"` + representation: { + id: "effect/schema/Redacted", + payload: normalizedOptions ?? null }, + toCode: ({ typeParameters }) => ({ + runtime: normalizedOptions !== undefined + ? `Schema.Redacted(${typeParameters[0].runtime}, ${format(normalizedOptions)})` + : `Schema.Redacted(${typeParameters[0].runtime})`, + Type: `Redacted.Redacted<${typeParameters[0].Type}>`, + importDeclarations: [`import * as Redacted from "effect/Redacted"`] + }), expected: "Redacted", toCodecJson: ([value]) => link>()( redact(value), { - decode: SchemaGetter.transform((e) => Redacted_.make(e, { label: options?.label })), - encode: options?.disallowJsonEncode ? + decode: SchemaGetter.transform((e) => Redacted_.make(e, { label })), + encode: disallowJsonEncode ? SchemaGetter.forbidden((oe) => "Cannot serialize Redacted" + (Option_.isSome(oe) && typeof oe.value.label === "string" ? ` with label: "${oe.value.label}"` : "") @@ -9041,8 +10306,8 @@ export function Redacted(value: S, options?: { } ), toArbitrary: ([value]) => () => ({ - arbitrary: value.arbitrary.map((a) => Redacted_.make(a, { label: options?.label })), - terminal: value.terminal?.map((a) => Redacted_.make(a, { label: options?.label })) + arbitrary: value.arbitrary.map((a) => Redacted_.make(a, { label })), + terminal: value.terminal?.map((a) => Redacted_.make(a, { label })) }), toFormatter: () => globalThis.String, toEquivalence: ([value]) => Redacted_.makeEquivalence(value) @@ -9051,6 +10316,27 @@ export function Redacted(value: S, options?: { return make(schema.ast, { value }) } +/** + * Reviver for persisted {@link Redacted} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Redacted}. + * + * @see {@link Redacted} for creating the corresponding schema + * + * @category Redacted + * @since 4.0.0 + */ +export const RedactedReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Redacted", + RedactedRepresentationPayload, + ({ annotations, payload, typeParameters }) => { + const schema = Redacted(typeParameters[0], payload ?? undefined) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link RedactedFromValue}. * @@ -9198,14 +10484,15 @@ export function CauseReason(error: E } }, { - typeConstructor: { - _tag: "effect/Cause/Failure" - }, - generation: { - runtime: `Schema.CauseReason(?, ?)`, - Type: `Cause.Failure`, - importDeclaration: `import * as Cause from "effect/Cause"` + representation: { + id: "effect/schema/CauseReason", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.CauseReason(${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `Cause.Failure<${typeParameters[0].Type}, ${typeParameters[1].Type}>`, + importDeclarations: [`import * as Cause from "effect/Cause"`] + }), expected: "Cause.Failure", toCodec: ([error, defect]) => link>()( @@ -9236,6 +10523,27 @@ export function CauseReason(error: E return make(schema.ast, { error, defect }) } +/** + * Reviver for persisted `CauseReason` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link CauseReason}. + * + * @see {@link CauseReason} for creating the corresponding schema + * + * @category CauseReason + * @since 4.0.0 + */ +export const CauseReasonReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/CauseReason", + Null, + ({ annotations, typeParameters }) => { + const schema = CauseReason(typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + function causeReasonToArbitrary( error: Annotations.ToArbitrary.TypeParameter, defect: Annotations.ToArbitrary.TypeParameter @@ -9353,14 +10661,15 @@ export function Cause(error: E, defe } }, { - typeConstructor: { - _tag: "effect/Cause" - }, - generation: { - runtime: `Schema.Cause(?, ?)`, - Type: `Cause.Cause`, - importDeclaration: `import * as Cause from "effect/Cause"` + representation: { + id: "effect/schema/Cause", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Cause(${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `Cause.Cause<${typeParameters[0].Type}, ${typeParameters[1].Type}>`, + importDeclarations: [`import * as Cause from "effect/Cause"`] + }), expected: "Cause", toCodec: ([error, defect]) => link>()( @@ -9378,6 +10687,27 @@ export function Cause(error: E, defe return make(schema.ast, { error, defect }) } +/** + * Reviver for persisted `Cause` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Cause}. + * + * @see {@link Cause} for creating the corresponding schema + * + * @category Cause + * @since 4.0.0 + */ +export const CauseReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Cause", + Null, + ({ annotations, typeParameters }) => { + const schema = Cause(typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + function causeToArbitrary( error: Annotations.ToArbitrary.TypeParameter, defect: Annotations.ToArbitrary.TypeParameter @@ -9432,13 +10762,36 @@ export interface ErrorOptions { readonly excludeCause?: boolean | undefined } +type ErrorRepresentationOptions = { + readonly includeStack?: true | undefined + readonly excludeCause?: true | undefined +} + +type NormalizedErrorOptions = + | { readonly includeStack: true } + | { readonly excludeCause: true } + | { readonly includeStack: true; readonly excludeCause: true } + +type ErrorRepresentationPayload = ErrorRepresentationOptions | null + +const ErrorOptionsPayload = declare((input): input is ErrorRepresentationOptions => { + if (!Predicate.isObject(input)) { + return false + } + const keys = globalThis.Object.keys(input) + return keys.length > 0 && + keys.every((key) => (key === "includeStack" || key === "excludeCause") && input[key] === true) +}) + +const ErrorRepresentationPayload: Decoder = Union([Null, ErrorOptionsPayload]) + type ErrorOptionsKey = 0 | 1 | 2 | 3 const getErrorOptionsKey = (options?: ErrorOptions): ErrorOptionsKey => ((options?.includeStack === true ? 1 : 0) | (options?.excludeCause === true ? 2 : 0)) as ErrorOptionsKey -const getErrorOptions = (key: ErrorOptionsKey): ErrorOptions | undefined => { +const getErrorOptions = (key: ErrorOptionsKey): NormalizedErrorOptions | undefined => { switch (key) { case 0: return undefined @@ -9476,14 +10829,14 @@ export function Error(options?: ErrorOptions): Error { } const normalizedOptions = getErrorOptions(key) const schema = instanceOf(globalThis.Error, { - typeConstructor: { - _tag: "Error", - ...(normalizedOptions === undefined ? {} : { options: normalizedOptions }) + representation: { + id: "effect/schema/Error", + payload: normalizedOptions ?? null }, - generation: { + toCode: () => ({ runtime: normalizedOptions !== undefined ? `Schema.Error(${format(normalizedOptions)})` : `Schema.Error()`, Type: `globalThis.Error` - }, + }), expected: "Error", toCodecJson: () => link()(JsonError, SchemaTransformation.errorFromJsonError(normalizedOptions)), toArbitrary: () => (fc) => fc.string().map((message) => new globalThis.Error(message)) @@ -9492,6 +10845,27 @@ export function Error(options?: ErrorOptions): Error { return schema } +/** + * Reviver for persisted {@link Error} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Error}. + * + * @see {@link Error} for creating the corresponding schema + * + * @category Error + * @since 4.0.0 + */ +export const ErrorReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Error", + ErrorRepresentationPayload, + ({ annotations, payload }) => { + const schema = Error(payload ?? undefined) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation of {@link Defect}. * @@ -9647,14 +11021,17 @@ export function Exit`, - importDeclaration: `import * as Exit from "effect/Exit"` + representation: { + id: "effect/schema/Exit", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Exit(${typeParameters[0].runtime}, ${typeParameters[1].runtime}, ${ + typeParameters[2].runtime + })`, + Type: `Exit.Exit<${typeParameters[0].Type}, ${typeParameters[1].Type}, ${typeParameters[2].Type}>`, + importDeclarations: [`import * as Exit from "effect/Exit"`] + }), expected: "Exit", toCodec: ([value, error, defect]) => link>()( @@ -9712,6 +11089,27 @@ export function Exit { + const schema = Exit(typeParameters[0], typeParameters[1], typeParameters[2]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link ReadonlyMap}. * @@ -9866,13 +11264,14 @@ export function ReadonlyMap( } }, { - typeConstructor: { - _tag: "ReadonlyMap" - }, - generation: { - runtime: `Schema.ReadonlyMap(?, ?)`, - Type: `globalThis.ReadonlyMap` + representation: { + id: "effect/schema/ReadonlyMap", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.ReadonlyMap(${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `globalThis.ReadonlyMap<${typeParameters[0].Type}, ${typeParameters[1].Type}>` + }), expected: "ReadonlyMap", toCodec: ([key, value]) => link>()( @@ -9897,6 +11296,27 @@ export function ReadonlyMap( return make(schema.ast, { key, value }) } +/** + * Reviver for persisted {@link ReadonlyMap} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link ReadonlyMap}. + * + * @see {@link ReadonlyMap} for creating the corresponding schema + * + * @category ReadonlyMap + * @since 4.0.0 + */ +export const ReadonlyMapReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/ReadonlyMap", + Null, + ({ annotations, typeParameters }) => { + const schema = ReadonlyMap(typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link HashMap}. * @@ -9957,14 +11377,15 @@ export function HashMap(key: K } }, { - typeConstructor: { - _tag: "effect/HashMap" - }, - generation: { - runtime: `Schema.HashMap(?, ?)`, - Type: `HashMap.HashMap`, - importDeclaration: `import * as HashMap from "effect/HashMap"` + representation: { + id: "effect/schema/HashMap", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.HashMap(${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `HashMap.HashMap<${typeParameters[0].Type}, ${typeParameters[1].Type}>`, + importDeclarations: [`import * as HashMap from "effect/HashMap"`] + }), expected: "HashMap", toCodec: ([key, value]) => link>()( @@ -9989,6 +11410,27 @@ export function HashMap(key: K return make(schema.ast, { key, value }) } +/** + * Reviver for persisted `HashMap` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link HashMap}. + * + * @see {@link HashMap} for creating the corresponding schema + * + * @category HashMap + * @since 4.0.0 + */ +export const HashMapReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/HashMap", + Null, + ({ annotations, typeParameters }) => { + const schema = HashMap(typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link ReadonlySet}. * @@ -10046,13 +11488,14 @@ export function ReadonlySet(value: Value): $ReadonlySe } }, { - typeConstructor: { - _tag: "ReadonlySet" - }, - generation: { - runtime: `Schema.ReadonlySet(?)`, - Type: `globalThis.ReadonlySet` + representation: { + id: "effect/schema/ReadonlySet", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.ReadonlySet(${typeParameters[0].runtime})`, + Type: `globalThis.ReadonlySet<${typeParameters[0].Type}>` + }), expected: "ReadonlySet", toCodec: ([value]) => link>()( @@ -10078,6 +11521,27 @@ export function ReadonlySet(value: Value): $ReadonlySe return make(schema.ast, { value }) } +/** + * Reviver for persisted {@link ReadonlySet} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link ReadonlySet}. + * + * @see {@link ReadonlySet} for creating the corresponding schema + * + * @category ReadonlySet + * @since 4.0.0 + */ +export const ReadonlySetReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/ReadonlySet", + Null, + ({ annotations, typeParameters }) => { + const schema = ReadonlySet(typeParameters[0]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link HashSet}. * @@ -10135,13 +11599,14 @@ export function HashSet(value: Value): HashSet } }, { - typeConstructor: { - _tag: "effect/HashSet" - }, - generation: { - runtime: `Schema.HashSet(?)`, - Type: `HashSet.HashSet` + representation: { + id: "effect/schema/HashSet", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.HashSet(${typeParameters[0].runtime})`, + Type: `HashSet.HashSet<${typeParameters[0].Type}>` + }), expected: "HashSet", toCodec: ([value]) => link>()( @@ -10167,6 +11632,27 @@ export function HashSet(value: Value): HashSet return make(schema.ast, { value }) } +/** + * Reviver for persisted `HashSet` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link HashSet}. + * + * @see {@link HashSet} for creating the corresponding schema + * + * @category HashSet + * @since 4.0.0 + */ +export const HashSetReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/HashSet", + Null, + ({ annotations, typeParameters }) => { + const schema = HashSet(typeParameters[0]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link Chunk}. * @@ -10231,13 +11717,14 @@ export function Chunk(value: Value): Chunk { } }, { - typeConstructor: { - _tag: "effect/Chunk" - }, - generation: { - runtime: `Schema.Chunk(?)`, - Type: `Chunk.Chunk` + representation: { + id: "effect/schema/Chunk", + payload: null }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Chunk(${typeParameters[0].runtime})`, + Type: `Chunk.Chunk<${typeParameters[0].Type}>` + }), expected: "Chunk", toCodec: ([value]) => link>()( @@ -10263,6 +11750,27 @@ export function Chunk(value: Value): Chunk { return make(schema.ast, { value }) } +/** + * Reviver for persisted {@link Chunk} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain schemas created by {@link Chunk}. + * + * @see {@link Chunk} for creating the corresponding schema + * + * @category Chunk + * @since 4.0.0 + */ +export const ChunkReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Chunk", + Null, + ({ annotations, typeParameters }) => { + const schema = Chunk(typeParameters[0]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation of {@link RegExp}. * @@ -10286,13 +11794,14 @@ export interface RegExp extends instanceOf { export const RegExp: RegExp = instanceOf( globalThis.RegExp, { - typeConstructor: { - _tag: "RegExp" + representation: { + id: "effect/schema/RegExp", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.RegExp`, Type: `globalThis.RegExp` - }, + }), expected: "RegExp", toCodecJson: () => link()( @@ -10339,6 +11848,23 @@ export const RegExp: RegExp = instanceOf( } ) +/** + * Reviver for persisted `RegExp` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link RegExp} schema. + * + * @see {@link RegExp} for the corresponding schema + * + * @category RegExp + * @since 4.0.0 + */ +export const RegExpReviver = makeFixedDeclarationReviver( + "effect/schema/RegExp", + RegExp +) + /** * Type-level representation of {@link URL}. * @@ -10366,13 +11892,14 @@ const URLString = String.annotate({ expected: "a string that will be decoded as export const URL: URL = instanceOf( globalThis.URL, { - typeConstructor: { - _tag: "URL" + representation: { + id: "effect/schema/URL", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.URL`, Type: `globalThis.URL` - }, + }), expected: "URL", toCodecJson: () => link()( @@ -10384,6 +11911,23 @@ export const URL: URL = instanceOf( } ) +/** + * Reviver for persisted `URL` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link URL} schema. + * + * @see {@link URL} for the corresponding schema + * + * @category URL + * @since 4.0.0 + */ +export const URLReviver = makeFixedDeclarationReviver( + "effect/schema/URL", + URL +) + /** * Type-level representation of {@link URLFromString}. * @@ -10487,13 +12031,14 @@ const DateString = String.annotate({ expected: "a string in ISO 8601 format that export const Date: Date = instanceOf( globalThis.Date, { - typeConstructor: { - _tag: "Date" + representation: { + id: "effect/schema/Date", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.Date`, Type: `globalThis.Date` - }, + }), expected: "Date", toCodecJson: () => link()( @@ -10508,6 +12053,23 @@ export const Date: Date = instanceOf( } ) +/** + * Reviver for persisted `Date` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link Date} schema. + * + * @see {@link Date} for the corresponding schema + * + * @category Date + * @since 4.0.0 + */ +export const DateReviver = makeFixedDeclarationReviver( + "effect/schema/Date", + Date +) + /** * Type-level representation of {@link DateFromString}. * @@ -10647,14 +12209,15 @@ export interface Duration extends declare { export const Duration: Duration = declare( Duration_.isDuration, { - typeConstructor: { - _tag: "effect/Duration" + representation: { + id: "effect/schema/Duration", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.Duration`, Type: `Duration.Duration`, - importDeclaration: `import * as Duration from "effect/Duration"` - }, + importDeclarations: [`import * as Duration from "effect/Duration"`] + }), expected: "Duration", toCodecJson: () => link()( @@ -10703,6 +12266,23 @@ export const Duration: Duration = declare( } ) +/** + * Reviver for persisted {@link Duration} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link Duration} schema. + * + * @see {@link Duration} for the corresponding schema + * + * @category Duration + * @since 4.0.0 + */ +export const DurationReviver = makeFixedDeclarationReviver( + "effect/schema/Duration", + Duration +) + const DurationString = String.annotate({ expected: "a string that will be decoded as a Duration" }) /** @@ -10908,14 +12488,15 @@ function bigDecimalScaleConstraints( export const BigDecimal: BigDecimal = declare( BigDecimal_.isBigDecimal, { - typeConstructor: { - _tag: "effect/BigDecimal" + representation: { + id: "effect/schema/BigDecimal", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.BigDecimal`, Type: `BigDecimal.BigDecimal`, - importDeclaration: `import * as BigDecimal from "effect/BigDecimal"` - }, + importDeclarations: [`import * as BigDecimal from "effect/BigDecimal"`] + }), expected: "BigDecimal", toCodecJson: () => link()( @@ -10944,6 +12525,23 @@ export const BigDecimal: BigDecimal = declare( } ) +/** + * Reviver for persisted {@link BigDecimal} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link BigDecimal} schema. + * + * @see {@link BigDecimal} for the corresponding schema + * + * @category BigDecimal + * @since 4.0.0 + */ +export const BigDecimalReviver = makeFixedDeclarationReviver( + "effect/schema/BigDecimal", + BigDecimal +) + /** * Type-level representation of {@link BigDecimalFromString}. * @@ -11044,12 +12642,6 @@ export interface fromJsonString extends decodeTo extends decodeTo { a: 1 } * ``` * - * **Example** (Emitting JSON Schema for a JSON string decoder) - * - * ```ts - * import { Schema } from "effect" - * - * const original = Schema.Struct({ a: Schema.String }) - * const schema = Schema.fromJsonString(original) - * - * const document = Schema.toJsonSchemaDocument(schema) - * - * console.log(JSON.stringify(document, null, 2)) - * // { - * // "source": "draft-2020-12", - * // "schema": { - * // "type": "string", - * // "contentMediaType": "application/json", - * // "contentSchema": { - * // "type": "object", - * // "properties": { - * // "a": { - * // "type": "string" - * // } - * // }, - * // "required": [ - * // "a" - * // ], - * // "additionalProperties": false - * // } - * // }, - * // "definitions": {} - * // } - * ``` - * * @category constructors * @since 4.0.0 */ export function fromJsonString(schema: S): fromJsonString { - const identifier = SchemaAST.resolveIdentifier(schema.ast) return String.annotate({ - // Give the transport wrapper its own name so the decoded payload keeps its identifier. - identifier: identifier === undefined ? undefined : `${identifier}JsonString`, expected: "a string that will be decoded as JSON", - contentMediaType: "application/json", - contentSchema: SchemaAST.toEncoded(schema.ast) + contentMediaType: "application/json" }).pipe(decodeTo(schema, SchemaTransformation.fromJsonString)) } @@ -11131,13 +12686,14 @@ export interface File extends instanceOf { * @since 4.0.0 */ export const File: File = instanceOf(globalThis.File, { - typeConstructor: { - _tag: "File" + representation: { + id: "effect/schema/File", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.File`, Type: `globalThis.File` - }, + }), expected: "File", toCodecJson: () => link()( @@ -11183,6 +12739,23 @@ export const File: File = instanceOf(globalThis.File, { ) }) +/** + * Reviver for persisted `File` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link File} schema. + * + * @see {@link File} for the corresponding schema + * + * @category file + * @since 4.0.0 + */ +export const FileReviver = makeFixedDeclarationReviver( + "effect/schema/File", + File +) + /** * Type-level representation of {@link FormData}. * @@ -11205,13 +12778,14 @@ export interface FormData extends instanceOf { * @since 4.0.0 */ export const FormData: FormData = instanceOf(globalThis.FormData, { - typeConstructor: { - _tag: "FormData" + representation: { + id: "effect/schema/FormData", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.FormData`, Type: `globalThis.FormData` - }, + }), expected: "FormData", toCodecJson: () => link()( @@ -11247,6 +12821,23 @@ export const FormData: FormData = instanceOf(globalThis.FormData, { ) }) +/** + * Reviver for persisted `FormData` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link FormData} schema. + * + * @see {@link FormData} for the corresponding schema + * + * @category FormData + * @since 4.0.0 + */ +export const FormDataReviver = makeFixedDeclarationReviver( + "effect/schema/FormData", + FormData +) + /** * Type-level representation returned by {@link fromFormData}. * @@ -11367,13 +12958,14 @@ export interface URLSearchParams extends instanceOf * @since 4.0.0 */ export const URLSearchParams: URLSearchParams = instanceOf(globalThis.URLSearchParams, { - typeConstructor: { - _tag: "URLSearchParams" + representation: { + id: "effect/schema/URLSearchParams", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.URLSearchParams`, Type: `globalThis.URLSearchParams` - }, + }), expected: "URLSearchParams", toCodecJson: () => link()( @@ -11385,6 +12977,23 @@ export const URLSearchParams: URLSearchParams = instanceOf(globalThis.URLSearchP ) }) +/** + * Reviver for persisted `URLSearchParams` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link URLSearchParams} schema. + * + * @see {@link URLSearchParams} for the corresponding schema + * + * @category search params + * @since 4.0.0 + */ +export const URLSearchParamsReviver = makeFixedDeclarationReviver( + "effect/schema/URLSearchParams", + URLSearchParams +) + /** * Type-level representation returned by {@link fromURLSearchParams}. * @@ -11470,32 +13079,14 @@ export interface fromURLSearchParams extends decodeTo(schema: S): fromURLSearchParams { - return URLSearchParams.pipe(decodeTo(schema, SchemaTransformation.fromURLSearchParams)) -} - -/** - * Type-level representation of {@link Finite}. - * - * @category Number - * @since 3.10.0 - */ -export interface Finite extends Number { - readonly "Rebuild": Finite -} - -/** - * Schema for finite numbers, rejecting `NaN`, `Infinity`, and `-Infinity`. + * ``` * - * @category Number - * @since 3.10.0 + * @category decoding + * @since 4.0.0 */ -export const Finite: Finite = Number.check(isFinite()) +export function fromURLSearchParams(schema: S): fromURLSearchParams { + return URLSearchParams.pipe(decodeTo(schema, SchemaTransformation.fromURLSearchParams)) +} /** * Type-level representation of {@link Int}. @@ -11897,13 +13488,14 @@ const Base64String = String.annotate({ * @since 4.0.0 */ export const Uint8Array: Uint8Array = instanceOf(globalThis.Uint8Array, { - typeConstructor: { - _tag: "Uint8Array" + representation: { + id: "effect/schema/Uint8Array", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.Uint8Array`, Type: `globalThis.Uint8Array` - }, + }), expected: "Uint8Array", toCodecJson: () => link>()( @@ -11913,6 +13505,23 @@ export const Uint8Array: Uint8Array = instanceOf(globalThis.Uint8Array (fc) => fc.uint8Array() }) +/** + * Reviver for persisted `Uint8Array` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link Uint8Array} schema. + * + * @see {@link Uint8Array} for the corresponding schema + * + * @category Uint8Array + * @since 4.0.0 + */ +export const Uint8ArrayReviver = makeFixedDeclarationReviver( + "effect/schema/Uint8Array", + Uint8Array +) + /** * Type-level representation of {@link Uint8ArrayFromBase64}. * @@ -12044,14 +13653,15 @@ export interface DateTimeUtc extends declare { export const DateTimeUtc: DateTimeUtc = declare( (u) => DateTime.isDateTime(u) && DateTime.isUtc(u), { - typeConstructor: { - _tag: "effect/DateTime.Utc" + representation: { + id: "effect/schema/DateTimeUtc", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.DateTimeUtc`, Type: `DateTime.Utc`, - importDeclaration: `import * as DateTime from "effect/DateTime"` - }, + importDeclarations: [`import * as DateTime from "effect/DateTime"`] + }), expected: "DateTime.Utc", toCodecJson: () => link()( @@ -12071,6 +13681,23 @@ export const DateTimeUtc: DateTimeUtc = declare( } ) +/** + * Reviver for persisted {@link DateTimeUtc} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link DateTimeUtc} schema. + * + * @see {@link DateTimeUtc} for the corresponding schema + * + * @category DateTime + * @since 4.0.0 + */ +export const DateTimeUtcReviver = makeFixedDeclarationReviver( + "effect/schema/DateTimeUtc", + DateTimeUtc +) + /** * Type-level representation of {@link DateTimeUtcFromDate}. * @@ -12212,14 +13839,15 @@ export interface TimeZoneOffset extends declare { export const TimeZoneOffset: TimeZoneOffset = declare( DateTime.isTimeZoneOffset, { - typeConstructor: { - _tag: "effect/DateTime.TimeZone.Offset" + representation: { + id: "effect/schema/TimeZoneOffset", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.TimeZoneOffset`, Type: `DateTime.TimeZone.Offset`, - importDeclaration: `import * as DateTime from "effect/DateTime"` - }, + importDeclarations: [`import * as DateTime from "effect/DateTime"`] + }), expected: "DateTime.TimeZone.Offset", toCodecJson: () => link()( @@ -12233,6 +13861,23 @@ export const TimeZoneOffset: TimeZoneOffset = declare( } ) +/** + * Reviver for persisted {@link TimeZoneOffset} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link TimeZoneOffset} schema. + * + * @see {@link TimeZoneOffset} for the corresponding schema + * + * @category DateTime + * @since 4.0.0 + */ +export const TimeZoneOffsetReviver = makeFixedDeclarationReviver( + "effect/schema/TimeZoneOffset", + TimeZoneOffset +) + /** * Type-level representation of {@link TimeZoneNamed}. * @@ -12260,14 +13905,15 @@ const TimeZoneNamedString = String.annotate({ expected: "an IANA time zone ident export const TimeZoneNamed: TimeZoneNamed = declare( DateTime.isTimeZoneNamed, { - typeConstructor: { - _tag: "effect/DateTime.TimeZone.Named" + representation: { + id: "effect/schema/TimeZoneNamed", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.TimeZoneNamed`, Type: `DateTime.TimeZone.Named`, - importDeclaration: `import * as DateTime from "effect/DateTime"` - }, + importDeclarations: [`import * as DateTime from "effect/DateTime"`] + }), expected: "DateTime.TimeZone.Named", toCodecJson: () => link()( @@ -12285,6 +13931,23 @@ export const TimeZoneNamed: TimeZoneNamed = declare( } ) +/** + * Reviver for persisted {@link TimeZoneNamed} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link TimeZoneNamed} schema. + * + * @see {@link TimeZoneNamed} for the corresponding schema + * + * @category DateTime + * @since 4.0.0 + */ +export const TimeZoneNamedReviver = makeFixedDeclarationReviver( + "effect/schema/TimeZoneNamed", + TimeZoneNamed +) + /** * Type-level representation of {@link TimeZoneNamedFromString}. * @@ -12343,14 +14006,15 @@ const TimeZoneString = String.annotate({ export const TimeZone: TimeZone = declare( DateTime.isTimeZone, { - typeConstructor: { - _tag: "effect/DateTime.TimeZone" + representation: { + id: "effect/schema/TimeZone", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.TimeZone`, Type: `DateTime.TimeZone`, - importDeclaration: `import * as DateTime from "effect/DateTime"` - }, + importDeclarations: [`import * as DateTime from "effect/DateTime"`] + }), expected: "DateTime.TimeZone", toCodecJson: () => link()( @@ -12371,6 +14035,23 @@ export const TimeZone: TimeZone = declare( } ) +/** + * Reviver for persisted {@link TimeZone} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link TimeZone} schema. + * + * @see {@link TimeZone} for the corresponding schema + * + * @category DateTime + * @since 4.0.0 + */ +export const TimeZoneReviver = makeFixedDeclarationReviver( + "effect/schema/TimeZone", + TimeZone +) + /** * Type-level representation of {@link TimeZoneFromString}. * @@ -12431,14 +14112,15 @@ const DateTimeZonedString = String.annotate({ export const DateTimeZoned: DateTimeZoned = declare( (u) => DateTime.isDateTime(u) && DateTime.isZoned(u), { - typeConstructor: { - _tag: "effect/DateTime.Zoned" + representation: { + id: "effect/schema/DateTimeZoned", + payload: null }, - generation: { + toCode: () => ({ runtime: `Schema.DateTimeZoned`, Type: `DateTime.Zoned`, - importDeclaration: `import * as DateTime from "effect/DateTime"` - }, + importDeclarations: [`import * as DateTime from "effect/DateTime"`] + }), expected: "DateTime.Zoned", toCodecJson: () => link()( @@ -12464,6 +14146,23 @@ export const DateTimeZoned: DateTimeZoned = declare( } ) +/** + * Reviver for persisted {@link DateTimeZoned} declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link DateTimeZoned} schema. + * + * @see {@link DateTimeZoned} for the corresponding schema + * + * @category DateTime + * @since 4.0.0 + */ +export const DateTimeZonedReviver = makeFixedDeclarationReviver( + "effect/schema/DateTimeZoned", + DateTimeZoned +) + /** * Type-level representation of {@link DateTimeZonedFromString}. * @@ -13341,7 +15040,7 @@ export function toEquivalence(schema: Schema): Equivalence.Equivalence * @since 4.0.0 */ export function toRepresentation(schema: Constraint): SchemaRepresentation.Document { - return InternalStandard.fromAST(schema.ast) + return InternalToRepresentation.toRepresentation(schema.ast) } // ----------------------------------------------------------------------------- @@ -13429,14 +15128,16 @@ export interface ToJsonSchemaOptions { * * The `options` parameter controls generation details such as additional * properties and synthesized check descriptions; it does not change the draft - * target. + * target. Declarations are lowered through their `toCodecJson` or `toCodec` + * annotation when available before the representation document is compiled. * * **Gotchas** * * JSON Schema generation is best-effort. Some Effect schema semantics cannot * be represented exactly in JSON Schema, and importing an emitted JSON Schema * may produce an equivalent approximation rather than the original schema - * shape. + * shape. Opaque declarations without a structural codec are represented by an + * unconstrained JSON Schema. * * @category converting * @since 4.0.0 @@ -13445,13 +15146,8 @@ export function toJsonSchemaDocument( schema: Constraint, options?: ToJsonSchemaOptions ): JsonSchema.Document<"draft-2020-12"> { - const sd = toRepresentation(schema) - const jd = InternalStandard.toJsonSchemaDocument(sd, options) - return { - dialect: "draft-2020-12", - schema: jd.schema, - definitions: jd.definitions - } + const document = InternalToRepresentation.toRepresentation(toCodecJsonTop(schema.ast), true) + return InternalToJsonSchemaDocument.toJsonSchemaDocument(document, options) } // ----------------------------------------------------------------------------- @@ -13490,6 +15186,14 @@ export interface toCodecJson extends * Derives a canonical JSON codec from a schema. The encoded form is `Json`, and * decoding produces the schema's `Type`. * + * **Gotchas** + * + * Declarations without a `toCodecJson` or `toCodec` annotation use `Json` as + * their encoded schema. This keeps codec construction total, but encoding or + * decoding can still fail when declaration values are not JSON values. A + * `toCodecJson` callback can return `undefined` when the declaration is already + * in canonical JSON form. + * * @category Canonical Codecs * @since 4.0.0 */ @@ -13497,28 +15201,97 @@ export function toCodecJson(schema: S): toCodecJson { return make(toCodecJsonTop(schema.ast), { schema }) } -const toCodecJsonTop = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => { +const toCodecJsonTopBase = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => { const out = toCodecJsonBase(ast, toCodecJsonTop) - return out !== ast && SchemaAST.isOptional(ast) ? SchemaAST.optionalKeyLastLink(out) : out + const context = ast.context + if (out === ast || context === undefined) return out + return SchemaAST.replaceContextLastLink(out, withoutConstructorDefault(context)) +}) + +const toCodecJsonTop = memoize((ast: SchemaAST.AST): SchemaAST.AST => { + const identifier = InternalAnnotations.resolveIdentifier(ast) + const out = toCodecJsonTopBase(ast) + if (identifier === undefined || out.encoding === undefined) return out + + const encoded = SchemaAST.getLastEncoding(out) + if ( + InternalAnnotations.resolveIdentifier(encoded) !== undefined || + InternalAnnotations.resolveIdentifierFallback(encoded) === identifier + ) { + return out + } + + const annotated = SchemaAST.annotate(encoded, { + [InternalAnnotations.identifierFallbackKey]: identifier + }) + return SchemaAST.applyToSelfOrLastLinkEncoding(() => annotated)(out) +}) + +function withoutConstructorDefault(context: SchemaAST.Context): SchemaAST.Context { + return context.defaultValue === undefined ? + context : + new SchemaAST.Context(context.isOptional, context.isMutable, undefined, context.annotations) +} + +function validateCanonicalObjectPropertyNames(ast: SchemaAST.Objects): void { + if (ast.propertySignatures.some((ps) => typeof ps.name !== "string")) { + throw new globalThis.Error("Objects property names must be strings", { cause: ast }) + } +} + +function makeReorder(getPriority: (ast: SchemaAST.AST) => number) { + return (types: ReadonlyArray): ReadonlyArray => { + // Create a map of original indices for O(1) lookup + const indexMap = new Map() + for (let i = 0; i < types.length; i++) { + indexMap.set(SchemaAST.toEncoded(types[i]), i) + } + + // Create a sorted copy of the types array + const sortedTypes = [...types].sort((a, b) => { + a = SchemaAST.toEncoded(a) + b = SchemaAST.toEncoded(b) + const pa = getPriority(a) + const pb = getPriority(b) + if (pa !== pb) return pa - pb + // If priorities are equal, maintain original order (stable sort) + return indexMap.get(a)! - indexMap.get(b)! + }) + + // Check if order changed by comparing arrays + const orderChanged = sortedTypes.some((ast, index) => ast !== types[index]) + + if (!orderChanged) return types + return sortedTypes + } +} + +const toCodecJsonReorder = makeReorder((ast: SchemaAST.AST) => { + switch (ast._tag) { + case "BigInt": + case "Symbol": + case "UniqueSymbol": + return 0 + default: + return 1 + } }) function toCodecJsonBase(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => SchemaAST.AST): SchemaAST.AST { switch (ast._tag) { case "Declaration": { const getLink = ast.annotations?.toCodecJson ?? ast.annotations?.toCodec - if (Predicate.isFunction(getLink)) { - const tps = SchemaAST.isDeclaration(ast) - ? ast.typeParameters.map((tp) => InternalSchema.make(SchemaAST.toEncoded(tp))) - : [] - const link = getLink(tps) - const to = recur(link.to) - return SchemaAST.replaceEncoding(ast, to === link.to ? [link] : [new SchemaAST.Link(to, link.transformation)]) + if (!Predicate.isFunction(getLink)) { + return SchemaAST.replaceEncoding(ast, [SchemaAST.unknownToJson]) } - return SchemaAST.replaceEncoding(ast, [SchemaAST.unknownToNull]) + const typeParameters = ast.typeParameters.map((tp) => InternalSchema.make(SchemaAST.toEncoded(tp))) + const link = getLink(typeParameters) + return link === undefined ? ast : SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, recur)]) } case "Unknown": - case "ObjectKeyword": return SchemaAST.replaceEncoding(ast, [SchemaAST.unknownToJson]) + case "ObjectKeyword": + return SchemaAST.replaceEncoding(ast, [SchemaAST.objectKeywordToJson]) case "Undefined": case "Void": case "Literal": @@ -13529,13 +15302,11 @@ function toCodecJsonBase(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => Sche case "BigInt": return ast.toCodecStringTree() case "Objects": { - if (ast.propertySignatures.some((ps) => typeof ps.name !== "string")) { - throw new globalThis.Error("Objects property names must be strings", { cause: ast }) - } + validateCanonicalObjectPropertyNames(ast) return ast.recur(recur, SchemaAST.parameterFromString) } case "Union": { - const sortedTypes = InternalSchema.jsonReorder(ast.types) + const sortedTypes = toCodecJsonReorder(ast.types) if (sortedTypes !== ast.types) { return new SchemaAST.Union( sortedTypes, @@ -13570,7 +15341,9 @@ export function toCodecIso(schema: S): Codec { const out = toCodecIsoBase(ast, toCodecIsoTop) - return out !== ast && SchemaAST.isOptional(ast) ? SchemaAST.optionalKeyLastLink(out) : out + return out !== ast && ast.context !== undefined ? + SchemaAST.replaceContextLastLink(out, withoutConstructorDefault(ast.context)) : + out }) function toCodecIsoBase(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => SchemaAST.AST): SchemaAST.AST { @@ -13579,8 +15352,7 @@ function toCodecIsoBase(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => Schem const getLink = ast.annotations?.toCodecIso ?? ast.annotations?.toCodec if (Predicate.isFunction(getLink)) { const link = getLink(ast.typeParameters.map((tp) => InternalSchema.make(tp))) - const to = recur(link.to) - return SchemaAST.replaceEncoding(ast, to === link.to ? [link] : [new SchemaAST.Link(to, link.transformation)]) + return SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, recur)]) } return ast } @@ -13634,10 +15406,11 @@ export interface toCodecStringTree extends * Converts a schema to the StringTree canonical codec, where every leaf value * becomes a string while preserving the original structure. * - * **Details** + * **Gotchas** * - * Declarations are converted to `undefined` (unless they have a - * `toCodecJson` or `toCodec` annotation). + * Declarations must provide a structural `toCodecStringTree`, `toCodecJson`, or + * `toCodec` encoding. A callback can return `undefined` when the declaration is + * already in canonical StringTree form. * * @category Canonical Codecs * @since 4.0.0 @@ -13815,7 +15588,7 @@ const xml = { } } -function getStringTreePriority(ast: SchemaAST.AST): number { +const toStringTreeReorder = makeReorder((ast: SchemaAST.AST) => { switch (ast._tag) { case "Null": case "Boolean": @@ -13827,9 +15600,7 @@ function getStringTreePriority(ast: SchemaAST.AST): number { default: return 1 } -} - -const treeReorder = InternalSchema.makeReorder(getStringTreePriority) +}) function serializerTree( ast: SchemaAST.AST, @@ -13838,16 +15609,20 @@ function serializerTree( ): SchemaAST.AST { switch (ast._tag) { case "Declaration": { - const getLink = ast.annotations?.toCodecJson ?? ast.annotations?.toCodec - if (Predicate.isFunction(getLink)) { - const tps = SchemaAST.isDeclaration(ast) - ? ast.typeParameters.map((tp) => make(recur(SchemaAST.toEncoded(tp)))) - : [] - const link = getLink(tps) - const to = recur(link.to) - return SchemaAST.replaceEncoding(ast, to === link.to ? [link] : [new SchemaAST.Link(to, link.transformation)]) + const typeParameters = ast.typeParameters.map((tp) => make(recur(SchemaAST.toEncoded(tp)))) + const getStringTreeLink = ast.annotations?.toCodecStringTree + if (Predicate.isFunction(getStringTreeLink)) { + const link = getStringTreeLink(typeParameters) + if (link === undefined) return ast + return SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, recur)]) } - return onMissingAnnotation(ast) + const getJsonLink = ast.annotations?.toCodecJson + const jsonLink = Predicate.isFunction(getJsonLink) ? getJsonLink(typeParameters) : undefined + const getLink = jsonLink === undefined ? ast.annotations?.toCodec : undefined + const link = jsonLink ?? (Predicate.isFunction(getLink) ? getLink(typeParameters) : undefined) + return link === undefined + ? onMissingAnnotation(ast) + : SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, recur)]) } case "Null": return SchemaAST.replaceEncoding(ast, [nullToString]) @@ -13864,13 +15639,11 @@ function serializerTree( case "BigInt": return ast.toCodecStringTree() case "Objects": { - if (ast.propertySignatures.some((ps) => typeof ps.name !== "string")) { - throw new globalThis.Error("Objects property names must be strings", { cause: ast }) - } + validateCanonicalObjectPropertyNames(ast) return ast.recur(recur, SchemaAST.parameterFromString) } case "Union": { - const sortedTypes = treeReorder(ast.types) + const sortedTypes = toStringTreeReorder(ast.types) if (sortedTypes !== ast.types) { return new SchemaAST.Union( sortedTypes, @@ -13917,21 +15690,15 @@ const serializerStringTree = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => { if (isSerializerArrayFromSingle(ast)) { return ast } - const out = serializerTree(ast, serializerStringTree, (ast) => SchemaAST.replaceEncoding(ast, [unknownToUndefined])) - if (out !== ast && SchemaAST.isOptional(ast)) { - return SchemaAST.optionalKeyLastLink(out) + const out = serializerTree(ast, serializerStringTree, (ast) => { + throw new globalThis.Error("Missing structural codec for StringTree", { cause: ast }) + }) + if (out !== ast && ast.context !== undefined) { + return SchemaAST.replaceContextLastLink(out, withoutConstructorDefault(ast.context)) } return out }) -const unknownToUndefined = new SchemaAST.Link( - SchemaAST.undefined, - new SchemaTransformation.Transformation( - SchemaGetter.passthrough(), - SchemaGetter.transform(() => undefined) - ) -) - const toArrayFromSingleInputElement = (ast: SchemaAST.AST): SchemaAST.AST => SchemaAST.isOptional(ast) ? SchemaAST.optionalKey(SchemaAST.unknown) : SchemaAST.unknown @@ -14187,7 +15954,29 @@ export interface JsonObject { * @category schemas * @since 4.0.0 */ -export const Json: Codec = make(SchemaAST.Json) +export const Json: Codec = make(SchemaAST.annotate(SchemaAST.Json, { + toCode: () => ({ + runtime: "Schema.Json", + Type: "Schema.Json" + }) +})) + +/** + * Reviver for persisted `Json` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link Json} schema. + * + * @see {@link Json} for the corresponding immutable JSON schema + * + * @category schemas + * @since 4.0.0 + */ +export const JsonReviver = makeFixedDeclarationReviver( + "effect/schema/Json", + Json +) const JsonError = Struct({ message: String, @@ -14230,7 +16019,29 @@ export interface MutableJsonObject { * @category schemas * @since 4.0.0 */ -export const MutableJson: Codec = make(SchemaAST.MutableJson) +export const MutableJson: Codec = make(SchemaAST.annotate(SchemaAST.MutableJson, { + toCode: () => ({ + runtime: "Schema.MutableJson", + Type: "Schema.MutableJson" + }) +})) + +/** + * Reviver for persisted `MutableJson` declarations. + * + * **When to use** + * + * Use when reconstructing documents that may contain the {@link MutableJson} schema. + * + * @see {@link MutableJson} for the corresponding mutable JSON schema + * + * @category schemas + * @since 4.0.0 + */ +export const MutableJsonReviver = makeFixedDeclarationReviver( + "effect/schema/MutableJson", + MutableJson +) // ----------------------------------------------------------------------------- // Annotations @@ -14351,6 +16162,7 @@ export declare namespace Annotations { readonly format?: string | undefined readonly contentEncoding?: string | undefined readonly contentMediaType?: string | undefined + readonly contentSchema?: Json | undefined } /** @@ -14421,10 +16233,6 @@ export declare namespace Annotations { */ readonly identifier?: string | undefined readonly parseOptions?: SchemaAST.ParseOptions | undefined - /** - * Optional metadata used to identify or extend the filter with custom data. - */ - readonly meta?: Meta | undefined /** * Accumulated brands when multiple brands are added with `Schema.brand`. */ @@ -14475,11 +16283,17 @@ export declare namespace Annotations { export interface Declaration = readonly []> extends Bottom { + readonly representation?: + | SchemaRepresentation.RepresentationAnnotation + | undefined readonly toCodec?: | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link) | undefined readonly toCodecJson?: - | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link) + | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link | undefined) + | undefined + readonly toCodecStringTree?: + | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link | undefined) | undefined readonly toCodecIso?: | ((typeParameters: TypeParameters.Type) => SchemaAST.Link) @@ -14487,20 +16301,13 @@ export declare namespace Annotations { readonly toArbitrary?: ToArbitrary.Declaration | undefined readonly toEquivalence?: ToEquivalence.Declaration | undefined readonly toFormatter?: ToFormatter.Declaration | undefined - readonly typeConstructor?: { - readonly _tag: string - readonly [key: string]: unknown - } | undefined - readonly generation?: { - readonly runtime: string - readonly Type: string - readonly Encoded?: string | undefined - readonly importDeclaration?: string | undefined - } | undefined + readonly toCode?: SchemaRepresentation.Generation.Declaration | undefined /** * Used to collect sentinels from a Declaration SchemaAST. * - * @internal + * **Details** + * + * Reserved to internal use only. */ readonly "~sentinels"?: ReadonlyArray | undefined } @@ -14514,6 +16321,11 @@ export declare namespace Annotations { * @since 4.0.0 */ export interface Filter extends Augment { + readonly representation?: + | SchemaRepresentation.CheckRepresentationAnnotation + | undefined + readonly toJsonSchema?: SchemaRepresentation.ToJsonSchema.Check | undefined + readonly toCode?: SchemaRepresentation.Generation.Check | undefined /** * Complete message to use when this filter or refinement fails. * @@ -14534,10 +16346,6 @@ export declare namespace Annotations { * `message`. */ readonly identifier?: string | undefined - /** - * Optional metadata used to identify or extend the filter with custom data. - */ - readonly meta?: Meta | undefined /** * Optional hints used by arbitrary derivation for this filter. * @@ -14557,6 +16365,8 @@ export declare namespace Annotations { * * **Details** * + * Reserved to internal use only. + * * Example: `minLength` on an array is a structural filter. */ readonly "~structural"?: boolean | undefined @@ -14885,254 +16695,4 @@ export declare namespace Annotations { export interface Issue extends Annotations { readonly message?: string | undefined } - - /** - * Registry of metadata payloads emitted by built-in schema filters and checks. - * - * **Details** - * - * Do not augment this interface with custom metadata; extend `MetaDefinitions` - * instead. - * - * @category models - * @since 4.0.0 - */ - export interface BuiltInMetaDefinitions { - // String Meta - readonly isStringFinite: { - readonly _tag: "isStringFinite" - readonly regExp: globalThis.RegExp - } - readonly isStringBigInt: { - readonly _tag: "isStringBigInt" - readonly regExp: globalThis.RegExp - } - readonly isStringSymbol: { - readonly _tag: "isStringSymbol" - readonly regExp: globalThis.RegExp - } - readonly isMinLength: { - readonly _tag: "isMinLength" - readonly minLength: number - } - readonly isMaxLength: { - readonly _tag: "isMaxLength" - readonly maxLength: number - } - readonly isLengthBetween: { - readonly _tag: "isLengthBetween" - readonly minimum: number - readonly maximum: number - } - readonly isPattern: { - readonly _tag: "isPattern" - readonly regExp: globalThis.RegExp - } - readonly isTrimmed: { - readonly _tag: "isTrimmed" - readonly regExp: globalThis.RegExp - } - readonly isUUID: { - readonly _tag: "isUUID" - readonly regExp: globalThis.RegExp - readonly version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | undefined - } - readonly isGUID: { - readonly _tag: "isGUID" - readonly regExp: globalThis.RegExp - } - readonly isULID: { - readonly _tag: "isULID" - readonly regExp: globalThis.RegExp - } - readonly isBase64: { - readonly _tag: "isBase64" - readonly regExp: globalThis.RegExp - } - readonly isBase64Url: { - readonly _tag: "isBase64Url" - readonly regExp: globalThis.RegExp - } - readonly isStartsWith: { - readonly _tag: "isStartsWith" - readonly startsWith: string - readonly regExp: globalThis.RegExp - } - readonly isEndsWith: { - readonly _tag: "isEndsWith" - readonly endsWith: string - readonly regExp: globalThis.RegExp - } - readonly isIncludes: { - readonly _tag: "isIncludes" - readonly includes: string - readonly regExp: globalThis.RegExp - } - readonly isUppercased: { - readonly _tag: "isUppercased" - readonly regExp: globalThis.RegExp - } - readonly isLowercased: { - readonly _tag: "isLowercased" - readonly regExp: globalThis.RegExp - } - readonly isCapitalized: { - readonly _tag: "isCapitalized" - readonly regExp: globalThis.RegExp - } - readonly isUncapitalized: { - readonly _tag: "isUncapitalized" - readonly regExp: globalThis.RegExp - } - // Number Meta - readonly isFinite: { - readonly _tag: "isFinite" - } - readonly isInt: { - readonly _tag: "isInt" - } - readonly isMultipleOf: { - readonly _tag: "isMultipleOf" - readonly divisor: number - } - readonly isGreaterThan: { - readonly _tag: "isGreaterThan" - readonly exclusiveMinimum: number - } - readonly isGreaterThanOrEqualTo: { - readonly _tag: "isGreaterThanOrEqualTo" - readonly minimum: number - } - readonly isLessThan: { - readonly _tag: "isLessThan" - readonly exclusiveMaximum: number - } - readonly isLessThanOrEqualTo: { - readonly _tag: "isLessThanOrEqualTo" - readonly maximum: number - } - readonly isBetween: { - readonly _tag: "isBetween" - readonly minimum: number - readonly maximum: number - readonly exclusiveMinimum?: boolean | undefined - readonly exclusiveMaximum?: boolean | undefined - } - // BigInt Meta - readonly isGreaterThanBigInt: { - readonly _tag: "isGreaterThanBigInt" - readonly exclusiveMinimum: bigint - } - readonly isGreaterThanOrEqualToBigInt: { - readonly _tag: "isGreaterThanOrEqualToBigInt" - readonly minimum: bigint - } - readonly isLessThanBigInt: { - readonly _tag: "isLessThanBigInt" - readonly exclusiveMaximum: bigint - } - readonly isLessThanOrEqualToBigInt: { - readonly _tag: "isLessThanOrEqualToBigInt" - readonly maximum: bigint - } - readonly isBetweenBigInt: { - readonly _tag: "isBetweenBigInt" - readonly minimum: bigint - readonly maximum: bigint - readonly exclusiveMinimum?: boolean | undefined - readonly exclusiveMaximum?: boolean | undefined - } - // Date Meta - readonly isDateValid: { - readonly _tag: "isDateValid" - } - readonly isGreaterThanDate: { - readonly _tag: "isGreaterThanDate" - readonly exclusiveMinimum: globalThis.Date - } - readonly isGreaterThanOrEqualToDate: { - readonly _tag: "isGreaterThanOrEqualToDate" - readonly minimum: globalThis.Date - } - readonly isLessThanDate: { - readonly _tag: "isLessThanDate" - readonly exclusiveMaximum: globalThis.Date - } - readonly isLessThanOrEqualToDate: { - readonly _tag: "isLessThanOrEqualToDate" - readonly maximum: globalThis.Date - } - readonly isBetweenDate: { - readonly _tag: "isBetweenDate" - readonly minimum: globalThis.Date - readonly maximum: globalThis.Date - readonly exclusiveMinimum?: boolean | undefined - readonly exclusiveMaximum?: boolean | undefined - } - // Objects Meta - readonly isMinProperties: { - readonly _tag: "isMinProperties" - readonly minProperties: number - } - readonly isMaxProperties: { - readonly _tag: "isMaxProperties" - readonly maxProperties: number - } - readonly isPropertiesLengthBetween: { - readonly _tag: "isPropertiesLengthBetween" - readonly minimum: number - readonly maximum: number - } - readonly isPropertyNames: { - readonly _tag: "isPropertyNames" - readonly propertyNames: SchemaAST.AST - } - // Arrays Meta - readonly isUnique: { - readonly _tag: "isUnique" - } - // Declaration Meta - readonly isMinSize: { - readonly _tag: "isMinSize" - readonly minSize: number - } - readonly isMaxSize: { - readonly _tag: "isMaxSize" - readonly maxSize: number - } - readonly isSizeBetween: { - readonly _tag: "isSizeBetween" - readonly minimum: number - readonly maximum: number - } - } - - /** - * Union of all metadata payloads defined by `BuiltInMetaDefinitions`. - * - * @category utility types - * @since 4.0.0 - */ - export type BuiltInMeta = BuiltInMetaDefinitions[keyof BuiltInMetaDefinitions] - - /** - * Augmentable registry of schema filter metadata payloads. - * - * **Details** - * - * Extend this interface to add custom values accepted by annotation `meta` - * fields. - * - * @category models - * @since 4.0.0 - */ - export interface MetaDefinitions extends BuiltInMetaDefinitions {} - - /** - * Union of built-in and user-augmented schema filter metadata payloads. - * - * @category utility types - * @since 4.0.0 - */ - export type Meta = MetaDefinitions[keyof MetaDefinitions] } diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index fb4913ea3d2..a41ad971b0d 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -1372,14 +1372,17 @@ export class Number extends Base { } /** @internal */ toCodecJson(): AST { - if (this.checks && (hasCheck(this.checks, "isFinite") || hasCheck(this.checks, "isInt"))) { + if ( + this.checks && + (hasCheck(this.checks, "effect/schema/isFinite") || hasCheck(this.checks, "effect/schema/isInt")) + ) { return this } return replaceEncoding(this, [numberToJson]) } /** @internal */ toCodecStringTree(): AST { - if (this.checks && (hasCheck(this.checks, "isFinite") || hasCheck(this.checks, "isInt"))) { + if (this.toCodecJson() === this) { return replaceEncoding(this, [finiteToString]) } return replaceEncoding(this, [numberToString]) @@ -1390,16 +1393,11 @@ export class Number extends Base { } } -// oxlint-disable-next-line only-used-in-recursion - @gcanti what's this? :-) -function hasCheck(checks: ReadonlyArray>, tag: string): boolean { - return checks.some((c) => { - switch (c._tag) { - case "Filter": - return c.annotations?.meta?._tag === tag - case "FilterGroup": - return hasCheck(c.checks, tag) - } - }) +function hasCheck(checks: ReadonlyArray>, id: string): boolean { + return checks.some((check) => + check.annotations?.representation?.id === id || + (check._tag === "FilterGroup" && hasCheck(check.checks, id)) + ) } /** @@ -2774,20 +2772,6 @@ const parseUnion = iterateEager<{ } }) -const nonFiniteLiterals = new Union([ - new Literal("Infinity"), - new Literal("-Infinity"), - new Literal("NaN") -], "anyOf") - -const numberToJson = new Link( - new Union([number, nonFiniteLiterals], "anyOf"), - new SchemaTransformation.Transformation( - SchemaGetter.Number(), - SchemaGetter.transform((n) => globalThis.Number.isFinite(n) ? n : globalThis.String(n)) - ) -) - function formatIsMutable(isMutable: boolean | undefined): string { return isMutable ? "" : "readonly " } @@ -2889,8 +2873,8 @@ export class Suspend extends Base { * * - `run` — the validation function. Returns `undefined` on success, or an * `Issue` on failure. - * - `annotations` — optional filter-level metadata (expected message, meta - * tags, arbitrary constraint hints). + * - `annotations` — optional filter-level annotations (expected message, + * representation, arbitrary constraint hints). * - `aborted` — when `true`, parsing stops immediately after this filter * fails (no further checks run). * @@ -3013,6 +2997,46 @@ export function makeFilterByGuard( ) } +/** @internal */ +export function isFinite(annotations?: Schema.Annotations.Filter) { + return makeFilter( + (n: number) => globalThis.Number.isFinite(n), + { + expected: "a finite number", + representation: { + id: "effect/schema/isFinite", + payload: null + }, + toJsonSchema: () => ({ type: "number" }), + toCode: () => ({ runtime: "Schema.isFinite()" }), + arbitrary: { + constraint: { + noInfinity: true, + noNaN: true + } + }, + ...annotations + } + ) +} + +const nonFiniteLiterals = new Union([ + new Literal("Infinity"), + new Literal("-Infinity"), + new Literal("NaN") +], "anyOf") + +/** @internal */ +export const finite = appendChecks(number, [isFinite()]) + +const numberToJson = new Link( + new Union([finite, nonFiniteLiterals], "anyOf"), + new SchemaTransformation.Transformation( + SchemaGetter.Number(), + SchemaGetter.transform((n) => globalThis.Number.isFinite(n) ? n : globalThis.String(n)) + ) +) + /** * Creates a {@link Filter} that validates strings by running `RegExp.test`. * @@ -3051,10 +3075,11 @@ export function isPattern(regExp: globalThis.RegExp, annotations?: Schema.Annota (s: string) => regExp.test(s), { expected: `a string matching the RegExp ${source}`, - meta: { - _tag: "isPattern", - regExp + representation: { + id: "effect/schema/isPattern", + payload: { source, flags: regExp.flags } }, + toJsonSchema: () => ({ pattern: source }), arbitrary: { constraint: { patterns: [regExp.source] @@ -3130,14 +3155,17 @@ export function appendChecks(ast: A, checks: Checks | undefined): return replaceChecks(ast, combineChecks(ast.checks, checks)) } +/** @internal */ +export function mapLink(link: Link, f: (ast: AST) => AST): Link { + const to = f(link.to) + return to === link.to ? link : new Link(to, link.transformation) +} + function updateLastLink(encoding: Encoding, f: (ast: AST) => AST): Encoding { const links = encoding const last = links[links.length - 1] - const to = f(last.to) - if (to !== last.to) { - return Arr.append(encoding.slice(0, encoding.length - 1), new Link(to, last.transformation)) - } - return encoding + const out = mapLink(last, f) + return out === last ? encoding : Arr.append(encoding.slice(0, encoding.length - 1), out) } /** @internal */ @@ -3145,6 +3173,11 @@ export function applyToLastLink(f: (ast: AST) => AST) { return (ast: A): A => ast.encoding ? replaceEncoding(ast, updateLastLink(ast.encoding, f)) : ast } +/** @internal */ +export function replaceContextLastLink(ast: A, context: Context): A { + return applyToLastLink((ast) => replaceContext(ast, context))(ast) +} + /** @internal */ export function applyToSelfOrLastLinkEncoding(f: (ast: AST) => AST) { function out(ast: AST): AST { @@ -3220,8 +3253,7 @@ export function annotateKey(ast: A, annotations: Schema.Annotatio return replaceContext(ast, context) } -/** @internal */ -export const optionalKeyLastLink = applyToLastLink(optionalKey) +const optionalKeyLastLink = applyToLastLink(optionalKey) /** * Marks an AST node's property key as optional by setting @@ -3635,10 +3667,11 @@ export function isStringFinite(annotations?: Schema.Annotations.Filter) { isStringFiniteRegExp, { expected: "a string representing a finite number", - meta: { - _tag: "isStringFinite", - regExp: isStringFiniteRegExp + representation: { + id: "effect/schema/isStringFinite", + payload: null }, + toJsonSchema: () => ({ pattern: isStringFiniteRegExp.source }), ...annotations } ) @@ -3669,10 +3702,11 @@ export function isStringBigInt(annotations?: Schema.Annotations.Filter) { isStringBigIntRegExp, { expected: "a string representing a bigint", - meta: { - _tag: "isStringBigInt", - regExp: isStringBigIntRegExp + representation: { + id: "effect/schema/isStringBigInt", + payload: null }, + toJsonSchema: () => ({ pattern: isStringBigIntRegExp.source }), ...annotations } ) @@ -3720,10 +3754,11 @@ export function isStringSymbol(annotations?: Schema.Annotations.Filter) { isStringSymbolRegExp, { expected: "a string representing a symbol", - meta: { - _tag: "isStringSymbol", - regExp: isStringSymbolRegExp + representation: { + id: "effect/schema/isStringSymbol", + payload: null }, + toJsonSchema: () => ({ pattern: isStringSymbolRegExp.source }), ...annotations } ) @@ -3896,9 +3931,17 @@ export function isJson(u: unknown): u is Schema.Json { } } onPath.add(u) - const ok = isArray - ? u.every(recur) - : Object.keys(u).every((key) => recur((u as Record)[key])) + let ok = true + if (isArray) { + for (let index = 0; index < u.length; index++) { + if (!Object.hasOwn(u, index) || !recur(u[index])) { + ok = false + break + } + } + } else { + ok = Object.keys(u).every((key) => recur((u as Record)[key])) + } // Pop on exit so siblings reaching the same node via a different path // don't see it as an ancestor (that would reject valid DAGs). onPath.delete(u) @@ -3917,42 +3960,37 @@ export const Json = new Declaration( Effect.succeed(input) : Effect.fail(new SchemaIssue.InvalidType(ast, Option.some(input))), { - typeConstructor: { - _tag: "effect/Json" - }, - generation: { - runtime: `Schema.Json`, - Type: `Schema.Json` + representation: { + id: "effect/schema/Json", + payload: null }, expected: "JSON value", - toCodecJson: () => new Link(unknown, SchemaTransformation.passthrough()), + toCodecJson: () => undefined, + toCodecStringTree: () => unknownToStringTree, toArbitrary: () => (fc: typeof FastCheck) => fc.jsonValue() } ) /** @internal */ export const MutableJson = annotate(Json, { - typeConstructor: { - _tag: "effect/MutableJson" - }, - generation: { - runtime: `Schema.MutableJson`, - Type: `Schema.MutableJson` + representation: { + id: "effect/schema/MutableJson", + payload: null } }) /** @internal */ -export const unknownToNull = new Link( - null_, - new SchemaTransformation.Transformation( - SchemaGetter.passthrough(), - SchemaGetter.transform(() => null) - ) +export const unknownToJson = new Link( + Json, + SchemaTransformation.passthrough() ) /** @internal */ -export const unknownToJson = new Link( - Json, +export const objectKeywordToJson = new Link( + new Union([ + new Arrays(false, [], [Json]), + new Objects([], [new IndexSignature(string, Json, undefined)]) + ], "anyOf"), SchemaTransformation.passthrough() ) @@ -3991,7 +4029,7 @@ const StringTree = new Declaration( isStringTree(input) ? Effect.succeed(input) : Effect.fail(new SchemaIssue.InvalidType(ast, Option.some(input))), - { expected: "StringTree" } + { expected: "StringTree", toCodecStringTree: () => undefined } ) /** @internal */ diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 017471fcdc2..89abaee4bc0 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -31,8 +31,7 @@ const toConstructorAST = memoize((ast: SchemaAST.AST): SchemaAST.AST => { const getLink = ast.annotations?.[SchemaAST.ClassTypeId] if (Predicate.isFunction(getLink)) { const link = getLink(ast.typeParameters) - const to = toConstructorAST(link.to) - return SchemaAST.replaceEncoding(ast, to === link.to ? [link] : [new SchemaAST.Link(to, link.transformation)]) + return SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, toConstructorAST)]) } return ast } diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 547ea6e3fcd..cad9d022a44 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -1,72 +1,148 @@ /** - * Plain data structures for describing schemas in a serializable form. A - * `Representation` is not the original `Schema` object; it is a JSON-friendly - * description of the schema's types, fields, unions, checks, annotations, and - * references. - * - * This module defines the representation node types, document types, and - * codecs used to validate those documents. It can build representation - * documents from schema ASTs, turn representation documents back into schemas, - * convert them to and from JSON Schema documents, and generate TypeScript code - * artifacts for schema definitions. + * Open, compiler-extensible representation of Effect schemas. * * @since 4.0.0 */ -import * as Arr from "./Array.ts" -import { format, formatPropertyKey } from "./Formatter.ts" -import { collectBrands } from "./internal/schema/annotations.ts" -import * as InternalRepresentation from "./internal/schema/representation.ts" -import { unescapeToken } from "./JsonPointer.ts" +import * as InternalFromJsonSchemaDocument from "./internal/schema/fromJsonSchemaDocument.ts" +import * as InternalFromRepresentation from "./internal/schema/fromRepresentation.ts" +import * as InternalRepresentationJson from "./internal/schema/representationJson.ts" +import * as InternalSchema from "./internal/schema/schema.ts" +import * as InternalToCodeDocument from "./internal/schema/toCodeDocument.ts" +import * as InternalToJsonSchemaDocument from "./internal/schema/toJsonSchemaDocument.ts" +import * as InternalToRepresentation from "./internal/schema/toRepresentation.ts" import type * as JsonSchema from "./JsonSchema.ts" -import { remainder } from "./Number.ts" -import * as Option from "./Option.ts" -import * as Predicate from "./Predicate.ts" -import * as Rec from "./Record.ts" -import * as Schema from "./Schema.ts" -import * as SchemaAST from "./SchemaAST.ts" -import * as SchemaGetter from "./SchemaGetter.ts" - -// ----------------------------------------------------------------------------- -// specification -// ----------------------------------------------------------------------------- +import type * as Schema from "./Schema.ts" +import type * as SchemaAST from "./SchemaAST.ts" /** - * A custom type declaration, such as `Date`, `Option`, or `ReadonlySet`. - * - * **When to use** + * Open persistence identity carried by declarations and opaque checks. * - * Use when inspecting or transforming non-primitive schema types. + * @category annotations + * @since 4.0.0 + */ +export interface RepresentationAnnotation { + readonly id: string + readonly payload: Schema.Json +} + +/** + * Open persistence identity and schema dependencies carried by opaque checks. * - * **Details** + * @category annotations + * @since 4.0.0 + */ +export interface CheckRepresentationAnnotation extends RepresentationAnnotation { + readonly schemas?: ReadonlyArray | undefined +} + +/** + * Input passed to JSON Schema compiler annotations. * - * `typeParameters` holds the inner type arguments, such as the `A` in - * `Option`. `encodedSchema` is the fallback representation when no - * {@link Reviver} recognizes this declaration. `annotations.typeConstructor` - * identifies the declaration kind, such as `{ _tag: "effect/Option" }`. + * @since 4.0.0 + */ +export declare namespace ToJsonSchema { + /** + * Input for a check compiler. + * + * @category models + * @since 4.0.0 + */ + export interface CheckInput { + readonly type: JsonSchema.Type | undefined + readonly schemas: ReadonlyArray + } + + /** + * JSON Schema compiler for a check. + * + * @category models + * @since 4.0.0 + */ + export type Check = (input: CheckInput) => JsonSchema.JsonSchema +} + +/** + * Input and output contracts for code generation annotations. * - * @see {@link Reviver} - * @see {@link toSchemaDefaultReviver} + * @since 4.0.0 + */ +export declare namespace Generation { + /** + * Input for declaration code generation. + * + * @category models + * @since 4.0.0 + */ + export interface DeclarationInput { + readonly typeParameters: ReadonlyArray + } + + /** + * Output of declaration code generation. + * + * @category models + * @since 4.0.0 + */ + export interface DeclarationOutput { + readonly runtime: string + readonly Type: string + readonly importDeclarations?: ReadonlyArray | undefined + } + + /** + * Declaration code generator. + * + * @category models + * @since 4.0.0 + */ + export type Declaration = (input: DeclarationInput) => DeclarationOutput + + /** + * Input for check code generation. + * + * @category models + * @since 4.0.0 + */ + export interface CheckInput { + readonly schemas: ReadonlyArray + } + + /** + * Output of check code generation. + * + * @category models + * @since 4.0.0 + */ + export interface CheckOutput { + readonly runtime: string + readonly importDeclarations?: ReadonlyArray | undefined + } + + /** + * Check code generator. + * + * @category models + * @since 4.0.0 + */ + export type Check = (input: CheckInput) => CheckOutput +} + +/** + * A custom opaque declaration. * * @category models * @since 4.0.0 */ export interface Declaration { readonly _tag: "Declaration" + readonly representation?: RepresentationAnnotation | undefined readonly annotations?: Schema.Annotations.Annotations | undefined readonly typeParameters: ReadonlyArray - readonly checks: ReadonlyArray> - readonly encodedSchema: Representation + readonly checks: ReadonlyArray } /** - * A lazily resolved representation used for recursive schemas. - * - * **Details** - * - * `thunk` points to the actual representation, possibly via a - * {@link Reference}. `checks` is always empty on `Suspend` nodes. - * - * @see {@link Reference} + * A lazily resolved representation. * * @category models * @since 4.0.0 @@ -79,25 +155,7 @@ export interface Suspend { } /** - * A named reference to a definition in the {@link References} map. - * - * **When to use** - * - * Use when a representation should point to a named definition instead of - * embedding the definition inline. - * - * **Details** - * - * `$ref` is the key into `Document.references` or `MultiDocument.references`. - * References are resolved lazily by {@link toSchema} and - * {@link toCodeDocument}. - * - * **Gotchas** - * - * Resolution throws at runtime if the key is not found in the references map. - * - * @see {@link References} - * @see {@link Document} + * A named reference. * * @category models * @since 4.0.0 @@ -107,244 +165,142 @@ export interface Reference { readonly $ref: string } +interface Keyword { + readonly _tag: Tag + readonly annotations?: Schema.Annotations.Annotations | undefined + readonly checks: ReadonlyArray +} + /** - * The `null` type. + * The null keyword representation. * * @category models * @since 4.0.0 */ -export interface Null { - readonly _tag: "Null" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Null extends Keyword<"Null"> {} /** - * The `undefined` type. + * The undefined keyword representation. * * @category models * @since 4.0.0 */ -export interface Undefined { - readonly _tag: "Undefined" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Undefined extends Keyword<"Undefined"> {} /** - * The `void` type. + * The void keyword representation. * * @category models * @since 4.0.0 */ -export interface Void { - readonly _tag: "Void" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Void extends Keyword<"Void"> {} /** - * The `never` type (no valid values). + * The never keyword representation. * * @category models * @since 4.0.0 */ -export interface Never { - readonly _tag: "Never" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Never extends Keyword<"Never"> {} /** - * The `unknown` type (any value accepted). + * The unknown keyword representation. * * @category models * @since 4.0.0 */ -export interface Unknown { - readonly _tag: "Unknown" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Unknown extends Keyword<"Unknown"> {} /** - * The `any` type. + * The any keyword representation. * * @category models * @since 4.0.0 */ -export interface Any { - readonly _tag: "Any" - readonly annotations?: Schema.Annotations.Annotations | undefined -} +export interface Any extends Keyword<"Any"> {} /** - * The `string` type with optional validation checks. - * - * **Details** - * - * `checks` holds string-specific constraints, such as min/max length, pattern, - * and UUID checks. `contentMediaType` and `contentSchema` indicate that the - * string contains encoded data, such as `"application/json"` with a nested - * schema. - * - * @see {@link StringMeta} - * @see {@link Check} + * A string representation. * * @category models * @since 4.0.0 */ -export interface String { - readonly _tag: "String" - readonly annotations?: Schema.Annotations.Annotations | undefined - readonly checks: ReadonlyArray> - readonly contentMediaType?: string | undefined - readonly contentSchema?: Representation | undefined -} +export interface String extends Keyword<"String"> {} /** - * The `number` type with optional validation checks. - * - * **Details** - * - * `checks` holds number-specific constraints, such as int, finite, min, max, - * multipleOf, and between checks. - * - * @see {@link NumberMeta} + * A number representation. * * @category models * @since 4.0.0 */ -export interface Number { - readonly _tag: "Number" - readonly annotations?: Schema.Annotations.Annotations | undefined - readonly checks: ReadonlyArray> -} - +export interface Number extends Keyword<"Number"> {} /** - * The `boolean` type. + * A boolean representation. * * @category models * @since 4.0.0 */ -export interface Boolean { - readonly _tag: "Boolean" - readonly annotations?: Schema.Annotations.Annotations | undefined -} - +export interface Boolean extends Keyword<"Boolean"> {} /** - * The `bigint` type with optional validation checks. - * - * @see {@link BigIntMeta} + * A bigint representation. * * @category models * @since 4.0.0 */ -export interface BigInt { - readonly _tag: "BigInt" - readonly annotations?: Schema.Annotations.Annotations | undefined - readonly checks: ReadonlyArray> -} - +export interface BigInt extends Keyword<"BigInt"> {} /** - * The `symbol` type. + * A symbol representation. * * @category models * @since 4.0.0 */ -export interface Symbol { - readonly _tag: "Symbol" - readonly annotations?: Schema.Annotations.Annotations | undefined -} +export interface Symbol extends Keyword<"Symbol"> {} /** - * A specific literal value (`string`, `number`, `boolean`, or `bigint`). + * A literal representation. * * @category models * @since 4.0.0 */ -export interface Literal { - readonly _tag: "Literal" - readonly annotations?: Schema.Annotations.Annotations | undefined +export interface Literal extends Keyword<"Literal"> { readonly literal: string | number | boolean | bigint } /** - * A specific unique `symbol` value. + * A unique global symbol representation. * * @category models * @since 4.0.0 */ -export interface UniqueSymbol { - readonly _tag: "UniqueSymbol" - readonly annotations?: Schema.Annotations.Annotations | undefined +export interface UniqueSymbol extends Keyword<"UniqueSymbol"> { readonly symbol: symbol } /** - * The `object` keyword type (matches any non-primitive). + * The object keyword representation. * * @category models * @since 4.0.0 */ -export interface ObjectKeyword { - readonly _tag: "ObjectKeyword" - readonly annotations?: Schema.Annotations.Annotations | undefined -} +export interface ObjectKeyword extends Keyword<"ObjectKeyword"> {} /** - * A TypeScript-style enum. Each entry is a `[name, value]` pair. + * An enum representation. * * @category models * @since 4.0.0 */ -export interface Enum { - readonly _tag: "Enum" - readonly annotations?: Schema.Annotations.Annotations | undefined +export interface Enum extends Keyword<"Enum"> { readonly enums: ReadonlyArray } /** - * A template literal type composed of a sequence of parts (literals, strings, - * numbers, etc.). + * A template literal representation. * * @category models * @since 4.0.0 */ -export interface TemplateLiteral { - readonly _tag: "TemplateLiteral" - readonly annotations?: Schema.Annotations.Annotations | undefined +export interface TemplateLiteral extends Keyword<"TemplateLiteral"> { readonly parts: ReadonlyArray } /** - * An array or tuple type. - * - * **Details** - * - * `elements` are the fixed positional elements, or tuple prefix, and each may - * be optional. `rest` contains the variadic tail types; a single-element - * `rest` with no `elements` produces a plain `Array`. `checks` holds - * array-specific constraints, such as minLength, maxLength, and unique checks. - * - * @see {@link Element} - * @see {@link ArraysMeta} - * - * @category models - * @since 4.0.0 - */ -export interface Arrays { - readonly _tag: "Arrays" - readonly annotations?: Schema.Annotations.Annotations | undefined - readonly elements: ReadonlyArray - readonly rest: ReadonlyArray - readonly checks: ReadonlyArray> -} - -/** - * A positional element within an {@link Arrays} tuple. - * - * **Details** - * - * `isOptional` indicates whether this element can be absent. `type` is the - * schema representation for this element's value. - * - * @see {@link Arrays} + * A tuple element. * * @category models * @since 4.0.0 @@ -356,45 +312,24 @@ export interface Element { } /** - * An object/struct type with named properties and optional index signatures. - * - * **Details** - * - * `propertySignatures` are the explicitly named fields. `indexSignatures` - * define catch-all key/value types, such as `Record`. `checks` - * holds object-specific constraints, such as minProperties and maxProperties. - * - * @see {@link PropertySignature} - * @see {@link IndexSignature} - * @see {@link ObjectsMeta} + * An array or tuple representation. * * @category models * @since 4.0.0 */ -export interface Objects { - readonly _tag: "Objects" - readonly annotations?: Schema.Annotations.Annotations | undefined - readonly propertySignatures: ReadonlyArray - readonly indexSignatures: ReadonlyArray - readonly checks: ReadonlyArray> +export interface Arrays extends Keyword<"Arrays"> { + readonly elements: ReadonlyArray + readonly rest: ReadonlyArray } /** - * A named property within an {@link Objects} representation. - * - * **Details** - * - * `name` is the property key, which can be a string, number, or symbol. - * `isOptional` indicates whether the key can be absent. `isMutable` indicates - * whether the property is mutable rather than readonly. - * - * @see {@link Objects} + * A property signature. * * @category models * @since 4.0.0 */ export interface PropertySignature { - readonly name: PropertyKey + readonly name: PropertyKey | number readonly type: Representation readonly isOptional: boolean readonly isMutable: boolean @@ -402,15 +337,7 @@ export interface PropertySignature { } /** - * An index signature, such as `[key: string]: number`, within an - * {@link Objects}. - * - * **Details** - * - * `parameter` is the key type representation. `type` is the value type - * representation. - * - * @see {@link Objects} + * An index signature. * * @category models * @since 4.0.0 @@ -421,34 +348,29 @@ export interface IndexSignature { } /** - * A union of multiple representations. - * - * **Details** + * An object representation. * - * `types` are the union members. `mode` controls JSON Schema output as either - * `"anyOf"` (the default) or mutually exclusive `"oneOf"`. + * @category models + * @since 4.0.0 + */ +export interface Objects extends Keyword<"Objects"> { + readonly propertySignatures: ReadonlyArray + readonly indexSignatures: ReadonlyArray +} + +/** + * A union representation. * * @category models * @since 4.0.0 */ -export interface Union { - readonly _tag: "Union" - readonly annotations?: Schema.Annotations.Annotations | undefined +export interface Union extends Keyword<"Union"> { readonly types: ReadonlyArray readonly mode: "anyOf" | "oneOf" } /** - * The core tagged union of all supported schema shapes. - * - * **Details** - * - * Each variant has a `_tag` discriminator. Switch on `_tag` to handle each - * shape. Most variants carry optional `annotations` and some carry `checks` - * for validation constraints. - * - * @see {@link Document} - * @see {@link fromAST} + * The structural schema representation. * * @category models * @since 4.0.0 @@ -478,3430 +400,569 @@ export type Representation = | Union /** - * A validation constraint attached to a type. Either a single {@link Filter} - * or a {@link FilterGroup} combining multiple checks. - * - * @see {@link Filter} - * @see {@link FilterGroup} + * A structural check. * * @category models * @since 4.0.0 */ -export type Check = Filter | FilterGroup +export type Check = Filter | FilterGroup /** - * A single validation constraint with typed metadata describing the check - * (e.g. `{ _tag: "isMinLength", minLength: 3 }`). - * - * @see {@link Check} + * An opaque leaf check. * * @category models * @since 4.0.0 */ -export interface Filter { +export interface Filter { readonly _tag: "Filter" - readonly annotations?: Schema.Annotations.Filter | undefined - readonly meta: M + readonly representation?: CheckRepresentationAnnotation | undefined + readonly annotations?: Schema.Annotations.Annotations | undefined + readonly aborted: boolean } /** - * A group of validation constraints that are logically combined. Contains - * at least one {@link Check}. - * - * @see {@link Check} + * A non-empty group of checks. * * @category models * @since 4.0.0 */ -export interface FilterGroup { +export interface FilterGroup { readonly _tag: "FilterGroup" - readonly annotations?: Schema.Annotations.Filter | undefined - readonly checks: readonly [Check, ...Array>] + readonly representation?: CheckRepresentationAnnotation | undefined + readonly annotations?: Schema.Annotations.Annotations | undefined + readonly checks: readonly [Check, ...Array] } /** - * Metadata union for string-specific validation checks (minLength, maxLength, - * pattern, UUID, trimmed, etc.). - * - * @see {@link String} - * @see {@link Check} + * Named representation definitions. * * @category models * @since 4.0.0 */ -export type StringMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isStringFinite" - | "isStringBigInt" - | "isStringSymbol" - | "isMinLength" - | "isMaxLength" - | "isPattern" - | "isLengthBetween" - | "isTrimmed" - | "isUUID" - | "isGUID" - | "isULID" - | "isBase64" - | "isBase64Url" - | "isStartsWith" - | "isEndsWith" - | "isIncludes" - | "isUppercased" - | "isLowercased" - | "isCapitalized" - | "isUncapitalized" -] - -/** - * Metadata union for number-specific validation checks (int, finite, - * min, max, multipleOf, between). - * - * @see {@link Number} - * @see {@link Check} +export interface References { + readonly [$ref: string]: Representation +} + +/** + * A single representation and its definitions. * * @category models * @since 4.0.0 */ -export type NumberMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isInt" - | "isFinite" - | "isMultipleOf" - | "isGreaterThanOrEqualTo" - | "isLessThanOrEqualTo" - | "isGreaterThan" - | "isLessThan" - | "isBetween" -] +export interface Document { + readonly representation: Representation + readonly references: References +} /** - * Metadata union for bigint-specific validation checks (min, max, between). - * - * @see {@link BigInt} - * @see {@link Check} + * Multiple representations sharing definitions. * * @category models * @since 4.0.0 */ -export type BigIntMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isGreaterThanOrEqualToBigInt" - | "isLessThanOrEqualToBigInt" - | "isGreaterThanBigInt" - | "isLessThanBigInt" - | "isBetweenBigInt" -] +export interface MultiDocument { + readonly representations: readonly [Representation, ...Array] + readonly references: References +} /** - * Metadata union for array-specific validation checks (minLength, maxLength, - * length, unique). - * - * @see {@link Arrays} - * @see {@link Check} + * Live schemas reconstructed from a multi-document. * * @category models * @since 4.0.0 */ -export type ArraysMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isMinLength" - | "isMaxLength" - | "isLengthBetween" - | "isUnique" -] +export interface SchemaMultiDocument { + readonly schemas: readonly [Schema.Top, ...Array] + readonly definitions: Readonly> +} /** - * Metadata union for object-specific validation checks (minProperties, - * maxProperties, propertiesLength, propertyNames). - * - * @see {@link Objects} - * @see {@link Check} + * Reviver for a declaration. * * @category models * @since 4.0.0 */ -export type ObjectsMeta = - | Schema.Annotations.BuiltInMetaDefinitions[ - | "isMinProperties" - | "isMaxProperties" - | "isPropertiesLengthBetween" - ] - | { readonly _tag: "isPropertyNames"; readonly propertyNames: Representation } +export interface DeclarationReviver

{ + readonly id: string + readonly payloadSchema: Schema.Decoder

+ readonly revive: (input: { + readonly payload: P + readonly typeParameters: ReadonlyArray + readonly annotations: Schema.Annotations.Annotations | undefined + }) => Schema.Top +} /** - * Metadata union for Date-specific validation checks (valid, min, max, between). - * - * @see {@link Declaration} - * @see {@link DeclarationMeta} + * Reviver for a leaf check. * * @category models * @since 4.0.0 */ -export type DateMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isDateValid" - | "isGreaterThanDate" - | "isGreaterThanOrEqualToDate" - | "isLessThanDate" - | "isLessThanOrEqualToDate" - | "isBetweenDate" -] +export interface FilterReviver

{ + readonly id: string + readonly payloadSchema: Schema.Decoder

+ readonly revive: (input: { + readonly payload: P + readonly schemas: ReadonlyArray + readonly annotations: Schema.Annotations.Filter | undefined + }) => SchemaAST.Filter +} /** - * Metadata union for size-based validation checks (minSize, maxSize, size). - * Used for collection types like `Set`, `Map`. - * - * @see {@link Declaration} - * @see {@link DeclarationMeta} + * Reviver for a check group. * * @category models * @since 4.0.0 */ -export type SizeMeta = Schema.Annotations.BuiltInMetaDefinitions[ - | "isMinSize" - | "isMaxSize" - | "isSizeBetween" -] +export interface FilterGroupReviver

{ + readonly id: string + readonly payloadSchema: Schema.Decoder

+ readonly revive: (input: { + readonly payload: P + readonly schemas: ReadonlyArray + readonly annotations: Schema.Annotations.Filter | undefined + }) => SchemaAST.FilterGroup +} /** - * Metadata union for {@link Declaration} checks — either {@link DateMeta} - * or {@link SizeMeta}. + * A check reviver. * * @category models * @since 4.0.0 */ -export type DeclarationMeta = DateMeta | SizeMeta - -/** @internal */ -export type Meta = StringMeta | NumberMeta | BigIntMeta | ArraysMeta | ObjectsMeta | DeclarationMeta +export type CheckReviver

= FilterReviver

| FilterGroupReviver

/** - * A string-keyed map of named {@link Representation} definitions. Used by - * {@link Document} and {@link MultiDocument} for `$ref` resolution (analogous - * to JSON Schema `$defs`). - * - * @see {@link Reference} - * @see {@link Document} + * A typed reviver. * * @category models * @since 4.0.0 */ -export interface References { - readonly [$ref: string]: Representation -} +export type Reviver

= DeclarationReviver

| CheckReviver

/** - * A single {@link Representation} together with its named {@link References}. - * - * **When to use** - * - * Use when representing a single Schema AST together with its named references - * before reconstructing a runtime Schema, converting to JSON Schema, or - * wrapping it as a {@link MultiDocument}. - * - * @see {@link MultiDocument} - * @see {@link fromAST} + * A reviver erased only at collection boundaries. * * @category models * @since 4.0.0 */ -export type Document = { - readonly representation: Representation - readonly references: References -} +export type AnyReviver = Reviver /** - * One or more {@link Representation}s sharing a common {@link References} map. - * - * **When to use** + * Creates a declaration reviver while inferring its payload type from `payloadSchema`. * - * Use when you use {@link fromASTs} to create this from multiple Schema ASTs, - * {@link toCodeDocument} to generate TypeScript code, and - * {@link toJsonSchemaMultiDocument} to convert to JSON Schema. - * - * @see {@link Document} - * @see {@link fromASTs} - * - * @category models + * @category constructors * @since 4.0.0 */ -export type MultiDocument = { - readonly representations: readonly [Representation, ...Array] - readonly references: References -} - -// ----------------------------------------------------------------------------- -// schemas -// ----------------------------------------------------------------------------- - -const Representation$ref = Schema.suspend(() => $Representation) - -const toJsonAnnotationsBlacklist: Set = new Set([ - ...InternalRepresentation.fromASTBlacklist, - "expected", - "contentMediaType", - "contentSchema" -]) +export const makeDeclarationReviver:

( + id: string, + payloadSchema: Schema.Decoder

, + revive: DeclarationReviver

["revive"] +) => DeclarationReviver

= InternalSchema.makeDeclarationReviver /** - * A tree of primitive values used to serialize annotations to JSON. + * Creates a filter reviver while inferring its payload type from `payloadSchema`. * - * @category Tree + * @category constructors * @since 4.0.0 */ -export type PrimitiveTree = Schema.Tree +export const makeFilterReviver:

( + id: string, + payloadSchema: Schema.Decoder

, + revive: FilterReviver

["revive"] +) => FilterReviver

= InternalSchema.makeFilterReviver /** - * Schema for {@link PrimitiveTree}. - * - * **When to use** - * - * Use to validate recursive annotation metadata trees whose leaves are `null`, - * `number`, `boolean`, `bigint`, `symbol`, or `string`. + * Creates a filter group reviver while inferring its payload type from `payloadSchema`. * - * @see {@link PrimitiveTree} for the recursive tree type accepted by this codec - * @see {@link $Annotations} for the annotation codec that filters values through this codec - * - * @category schemas + * @category constructors * @since 4.0.0 */ -export const $PrimitiveTree: Schema.Codec = Schema.Tree( - Schema.Union([ - Schema.Null, - Schema.Number, // allows NaN, Infinity, -Infinity - Schema.Boolean, - Schema.BigInt, - Schema.Symbol, - Schema.String - ]) -) - -const isPrimitiveTree = Schema.is($PrimitiveTree) +export const makeFilterGroupReviver:

( + id: string, + payloadSchema: Schema.Decoder

, + revive: FilterGroupReviver

["revive"] +) => FilterGroupReviver

= InternalSchema.makeFilterGroupReviver /** - * Schema for serializing public `Schema.Annotations.Annotations` values. It - * filters out internal annotation keys and non-primitive values during - * encoding. + * Options for importing JSON Schema Draft 2020-12 documents. * * **When to use** * - * Use to serialize schema annotations in representation schemas while retaining - * only primitive-tree metadata. - * - * **Details** + * Use when each JSON Schema node must be transformed before it is translated. * - * Decoding is passthrough. Encoding removes internal annotation keys and values - * that are not accepted by `$PrimitiveTree`. + * **Gotchas** * - * @see {@link $PrimitiveTree} for the codec used to filter annotation values + * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass through unchanged. * - * @category schemas + * @category models * @since 4.0.0 */ -export const $Annotations = Schema.Record(Schema.String, Schema.Unknown).pipe( - Schema.encodeTo(Schema.Record(Schema.String, $PrimitiveTree), { - decode: SchemaGetter.passthrough(), - encode: SchemaGetter.transformOptional(Option.flatMap((r) => { - const out: Record = {} - for (const [k, v] of Object.entries(r)) { - if (!toJsonAnnotationsBlacklist.has(k) && isPrimitiveTree(v)) { - out[k] = v - } - } - return Rec.isEmptyRecord(out) ? Option.none() : Option.some(out) - })) - }) -).annotate({ identifier: "Annotations" }) +export interface FromJsonSchemaOptions { + readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined +} /** - * Schema for the {@link Null} representation node. + * Runtime and TypeScript source generated for one schema. * - * @category schemas + * @category models * @since 4.0.0 */ -export const $Null = Schema.Struct({ - _tag: Schema.tag("Null"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Null" }) +export interface Code { + readonly runtime: string + readonly Type: string +} /** - * Schema for the {@link Undefined} representation node. + * Creates generated runtime and TypeScript source strings for a schema. * - * @category schemas + * @category constructors * @since 4.0.0 */ -export const $Undefined = Schema.Struct({ - _tag: Schema.tag("Undefined"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Undefined" }) +export const makeCode: (runtime: string, Type: string) => Code = InternalToCodeDocument.makeCode /** - * Schema for the {@link Void} representation node. + * Auxiliary source artifact emitted while generating schema code. * - * @category schemas + * @category models * @since 4.0.0 */ -export const $Void = Schema.Struct({ - _tag: Schema.tag("Void"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Void" }) +export type Artifact = + | { + readonly _tag: "Symbol" + readonly identifier: string + readonly code: Code + } + | { + readonly _tag: "Enum" + readonly identifier: string + readonly code: Code + } + | { + readonly _tag: "Import" + readonly importDeclaration: string + } /** - * Schema for the {@link Never} representation node. + * Generated schema code together with named references and auxiliary artifacts. * - * @category schemas + * @category models * @since 4.0.0 */ -export const $Never = Schema.Struct({ - _tag: Schema.tag("Never"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Never" }) +export interface CodeDocument { + readonly codes: ReadonlyArray + readonly references: { + readonly nonRecursives: ReadonlyArray<{ + readonly $ref: string + readonly code: Code + }> + readonly recursives: Readonly> + } + readonly artifacts: ReadonlyArray +} /** - * Schema for the {@link Unknown} representation node. + * Lowers the type side of an AST to a live representation document. * - * @category schemas + * @category constructors * @since 4.0.0 */ -export const $Unknown = Schema.Struct({ - _tag: Schema.tag("Unknown"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Unknown" }) +export function toRepresentation(ast: SchemaAST.AST): Document { + return InternalToRepresentation.toRepresentation(ast) +} /** - * Schema for the {@link Any} representation node. + * Lowers one or more AST type sides in a shared reference environment. * - * @category schemas + * @category constructors * @since 4.0.0 */ -export const $Any = Schema.Struct({ - _tag: Schema.tag("Any"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Any" }) - -const $IsStringFinite = Schema.Struct({ - _tag: Schema.tag("isStringFinite"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsStringFinite" }) - -const $IsStringBigInt = Schema.Struct({ - _tag: Schema.tag("isStringBigInt"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsStringBigInt" }) - -const $IsStringSymbol = Schema.Struct({ - _tag: Schema.tag("isStringSymbol"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsStringSymbol" }) - -const $IsTrimmed = Schema.Struct({ - _tag: Schema.tag("isTrimmed"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsTrimmed" }) - -const $IsUUID = Schema.Struct({ - _tag: Schema.tag("isUUID"), - regExp: Schema.RegExp, - version: Schema.UndefinedOr(Schema.Literals([1, 2, 3, 4, 5, 6, 7, 8])) -}).annotate({ identifier: "IsUUID" }) - -const $IsGUID = Schema.Struct({ - _tag: Schema.tag("isGUID"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsGUID" }) - -const $IsULID = Schema.Struct({ - _tag: Schema.tag("isULID"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsULID" }) - -const $IsBase64 = Schema.Struct({ - _tag: Schema.tag("isBase64"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsBase64" }) - -const $IsBase64Url = Schema.Struct({ - _tag: Schema.tag("isBase64Url"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsBase64Url" }) - -const $IsStartsWith = Schema.Struct({ - _tag: Schema.tag("isStartsWith"), - startsWith: Schema.String, - regExp: Schema.RegExp -}).annotate({ identifier: "IsStartsWith" }) - -const $IsEndsWith = Schema.Struct({ - _tag: Schema.tag("isEndsWith"), - endsWith: Schema.String, - regExp: Schema.RegExp -}).annotate({ identifier: "IsEndsWith" }) - -const $IsIncludes = Schema.Struct({ - _tag: Schema.tag("isIncludes"), - includes: Schema.String, - regExp: Schema.RegExp -}).annotate({ identifier: "IsIncludes" }) - -const $IsUppercased = Schema.Struct({ - _tag: Schema.tag("isUppercased"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsUppercased" }) - -const $IsLowercased = Schema.Struct({ - _tag: Schema.tag("isLowercased"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsLowercased" }) - -const $IsCapitalized = Schema.Struct({ - _tag: Schema.tag("isCapitalized"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsCapitalized" }) - -const $IsUncapitalized = Schema.Struct({ - _tag: Schema.tag("isUncapitalized"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsUncapitalized" }) - -const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - -const $IsMinLength = Schema.Struct({ - _tag: Schema.tag("isMinLength"), - minLength: NonNegativeInt -}).annotate({ identifier: "IsMinLength" }) - -const $IsMaxLength = Schema.Struct({ - _tag: Schema.tag("isMaxLength"), - maxLength: NonNegativeInt -}).annotate({ identifier: "IsMaxLength" }) - -const $IsLengthBetween = Schema.Struct({ - _tag: Schema.tag("isLengthBetween"), - minimum: NonNegativeInt, - maximum: NonNegativeInt -}).annotate({ identifier: "IsLengthBetween" }) - -const $IsPattern = Schema.Struct({ - _tag: Schema.tag("isPattern"), - regExp: Schema.RegExp -}).annotate({ identifier: "IsPattern" }) - -/** - * Schema for {@link StringMeta}. - * - * @category schemas - * @since 4.0.0 - */ -export const $StringMeta = Schema.Union([ - $IsStringFinite, - $IsStringBigInt, - $IsStringSymbol, - $IsTrimmed, - $IsUUID, - $IsGUID, - $IsULID, - $IsBase64, - $IsBase64Url, - $IsStartsWith, - $IsEndsWith, - $IsIncludes, - $IsUppercased, - $IsLowercased, - $IsCapitalized, - $IsUncapitalized, - $IsMinLength, - $IsMaxLength, - $IsPattern, - $IsLengthBetween -]).annotate({ identifier: "StringMeta" }) - -function makeCheck(meta: Schema.Codec, identifier: string) { - const Check$ref = Schema.suspend(() => Check) - const Check: Schema.Codec> = Schema.Union([ - Schema.Struct({ - _tag: Schema.tag("Filter"), - annotations: Schema.optional($Annotations), - meta - }).annotate({ identifier: `${identifier}Filter` }), - Schema.Struct({ - _tag: Schema.tag("FilterGroup"), - annotations: Schema.optional($Annotations), - checks: Schema.NonEmptyArray(Check$ref) - }).annotate({ identifier: `${identifier}FilterGroup` }) - ]).annotate({ identifier: `${identifier}Check` }) - return Check +export function toRepresentations( + asts: readonly [SchemaAST.AST, ...Array] +): MultiDocument { + return InternalToRepresentation.toRepresentations(asts) } /** - * Schema for the {@link String} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $String = Schema.Struct({ - _tag: Schema.tag("String"), - annotations: Schema.optional($Annotations), - checks: Schema.Array(makeCheck($StringMeta, "String")), - contentMediaType: Schema.optional(Schema.String), - contentSchema: Schema.optional(Representation$ref) -}).annotate({ identifier: "String" }) - -const $IsInt = Schema.Struct({ - _tag: Schema.tag("isInt") -}).annotate({ identifier: "IsInt" }) - -const $IsMultipleOf = Schema.Struct({ - _tag: Schema.tag("isMultipleOf"), - divisor: Schema.Finite -}).annotate({ identifier: "IsMultipleOf" }) - -const $IsFinite = Schema.Struct({ - _tag: Schema.tag("isFinite") -}).annotate({ identifier: "IsFinite" }) - -const $IsGreaterThan = Schema.Struct({ - _tag: Schema.tag("isGreaterThan"), - exclusiveMinimum: Schema.Finite -}).annotate({ identifier: "IsGreaterThan" }) - -const $IsGreaterThanOrEqualTo = Schema.Struct({ - _tag: Schema.tag("isGreaterThanOrEqualTo"), - minimum: Schema.Finite -}).annotate({ identifier: "IsGreaterThanOrEqualTo" }) - -const $IsLessThan = Schema.Struct({ - _tag: Schema.tag("isLessThan"), - exclusiveMaximum: Schema.Finite -}).annotate({ identifier: "IsLessThan" }) - -const $IsLessThanOrEqualTo = Schema.Struct({ - _tag: Schema.tag("isLessThanOrEqualTo"), - maximum: Schema.Finite -}).annotate({ identifier: "IsLessThanOrEqualTo" }) - -const $IsBetween = Schema.Struct({ - _tag: Schema.tag("isBetween"), - minimum: Schema.Finite, - maximum: Schema.Finite, - exclusiveMinimum: Schema.optional(Schema.Boolean), - exclusiveMaximum: Schema.optional(Schema.Boolean) -}).annotate({ identifier: "IsBetween" }) - -/** - * Schema for {@link NumberMeta}. - * - * @category schemas - * @since 4.0.0 - */ -export const $NumberMeta = Schema.Union([ - $IsInt, - $IsMultipleOf, - $IsFinite, - $IsGreaterThan, - $IsGreaterThanOrEqualTo, - $IsLessThan, - $IsLessThanOrEqualTo, - $IsBetween -]).annotate({ identifier: "NumberMeta" }) - -/** - * Schema for the {@link Number} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Number = Schema.Struct({ - _tag: Schema.tag("Number"), - annotations: Schema.optional($Annotations), - checks: Schema.Array(makeCheck($NumberMeta, "Number")) -}).annotate({ identifier: "Number" }) - -/** - * Schema for the {@link Boolean} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Boolean = Schema.Struct({ - _tag: Schema.tag("Boolean"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Boolean" }) - -const $IsGreaterThanBigInt = Schema.Struct({ - _tag: Schema.tag("isGreaterThanBigInt"), - exclusiveMinimum: Schema.BigInt -}).annotate({ identifier: "IsGreaterThanBigInt" }) - -const $IsGreaterThanOrEqualToBigInt = Schema.Struct({ - _tag: Schema.tag("isGreaterThanOrEqualToBigInt"), - minimum: Schema.BigInt -}).annotate({ identifier: "IsGreaterThanOrEqualToBigInt" }) - -const $IsLessThanBigInt = Schema.Struct({ - _tag: Schema.tag("isLessThanBigInt"), - exclusiveMaximum: Schema.BigInt -}).annotate({ identifier: "IsLessThanBigInt" }) - -const $IsLessThanOrEqualToBigInt = Schema.Struct({ - _tag: Schema.tag("isLessThanOrEqualToBigInt"), - maximum: Schema.BigInt -}).annotate({ identifier: "IsLessThanOrEqualToBigInt" }) - -const $IsBetweenBigInt = Schema.Struct({ - _tag: Schema.tag("isBetweenBigInt"), - minimum: Schema.BigInt, - maximum: Schema.BigInt, - exclusiveMinimum: Schema.optional(Schema.Boolean), - exclusiveMaximum: Schema.optional(Schema.Boolean) -}).annotate({ identifier: "IsBetweenBigInt" }) - -const $BigIntMeta = Schema.Union([ - $IsGreaterThanBigInt, - $IsGreaterThanOrEqualToBigInt, - $IsLessThanBigInt, - $IsLessThanOrEqualToBigInt, - $IsBetweenBigInt -]).annotate({ identifier: "BigIntMeta" }) - -/** - * Schema for the {@link BigInt} representation node. + * Converts live schemas and their named definitions to a shared representation document. * * **When to use** * - * Use to encode, decode, or validate serialized `BigInt` representation nodes, - * not application `bigint` values. - * - * **Details** - * - * Accepts representation nodes with `_tag: "BigInt"`, optional annotations, - * and bigint-specific validation metadata in `checks`. - * - * @see {@link BigIntMeta} for the metadata accepted by the `checks` array - * - * @category schemas - * @since 4.0.0 - */ -export const $BigInt = Schema.Struct({ - _tag: Schema.tag("BigInt"), - annotations: Schema.optional($Annotations), - checks: Schema.Array(makeCheck($BigIntMeta, "BigInt")) -}).annotate({ identifier: "BigInt" }) - -/** - * Schema for the {@link Symbol} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Symbol = Schema.Struct({ - _tag: Schema.tag("Symbol"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Symbol" }) - -/** - * Schema for the literal value types allowed in a {@link Literal} node - * (string, finite number, boolean, or bigint). - * - * @category schemas - * @since 4.0.0 - */ -export const $LiteralValue = Schema.Union([ - Schema.String, - Schema.Finite, - Schema.Boolean, - Schema.BigInt -]).annotate({ identifier: "LiteralValue" }) - -/** - * Schema for the {@link Literal} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Literal = Schema.Struct({ - _tag: Schema.tag("Literal"), - annotations: Schema.optional($Annotations), - literal: $LiteralValue -}).annotate({ identifier: "Literal" }) - -/** - * Schema for the {@link UniqueSymbol} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $UniqueSymbol = Schema.Struct({ - _tag: Schema.tag("UniqueSymbol"), - annotations: Schema.optional($Annotations), - symbol: Schema.Symbol -}).annotate({ identifier: "UniqueSymbol" }) - -/** - * Schema for the {@link ObjectKeyword} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $ObjectKeyword = Schema.Struct({ - _tag: Schema.tag("ObjectKeyword"), - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "ObjectKeyword" }) - -/** - * Schema for the {@link Enum} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Enum = Schema.Struct({ - _tag: Schema.tag("Enum"), - annotations: Schema.optional($Annotations), - enums: Schema.Array( - Schema.Tuple([ - Schema.String, - Schema.Union([ - Schema.String, - Schema.Number // NaN, Infinity, -Infinity are allowed enum values - ]) - ]) - ) -}).annotate({ identifier: "Enum" }) - -/** - * Schema for the {@link TemplateLiteral} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $TemplateLiteral = Schema.Struct({ - _tag: Schema.tag("TemplateLiteral"), - annotations: Schema.optional($Annotations), - parts: Schema.Array(Representation$ref) -}).annotate({ identifier: "TemplateLiteral" }) - -/** - * Schema for the {@link Element} type (positional tuple element). - * - * @category schemas - * @since 4.0.0 - */ -export const $Element = Schema.Struct({ - isOptional: Schema.Boolean, - type: Representation$ref, - annotations: Schema.optional($Annotations) -}).annotate({ identifier: "Element" }) - -const $IsUnique = Schema.Struct({ - _tag: Schema.tag("isUnique") -}).annotate({ identifier: "IsUnique" }) - -const $ArraysMeta = Schema.Union([ - $IsMinLength, - $IsMaxLength, - $IsLengthBetween, - $IsUnique -]).annotate({ identifier: "ArraysMeta" }) - -/** - * Schema for the {@link Arrays} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Arrays = Schema.Struct({ - _tag: Schema.tag("Arrays"), - annotations: Schema.optional($Annotations), - elements: Schema.Array($Element), - rest: Schema.Array(Representation$ref), - checks: Schema.Array(makeCheck($ArraysMeta, "Arrays")) -}).annotate({ identifier: "Arrays" }) - -/** - * Schema for the {@link PropertySignature} type. - * - * @category schemas - * @since 4.0.0 - */ -export const $PropertySignature = Schema.Struct({ - annotations: Schema.optional($Annotations), - name: Schema.PropertyKey, - type: Representation$ref, - isOptional: Schema.Boolean, - isMutable: Schema.Boolean -}).annotate({ identifier: "PropertySignature" }) - -/** - * Schema for the {@link IndexSignature} type. - * - * @category schemas - * @since 4.0.0 - */ -export const $IndexSignature = Schema.Struct({ - parameter: Representation$ref, - type: Representation$ref -}).annotate({ identifier: "IndexSignature" }) - -const $IsMinProperties = Schema.Struct({ - _tag: Schema.tag("isMinProperties"), - minProperties: NonNegativeInt -}).annotate({ identifier: "IsMinProperties" }) - -const $IsMaxProperties = Schema.Struct({ - _tag: Schema.tag("isMaxProperties"), - maxProperties: NonNegativeInt -}).annotate({ identifier: "IsMaxProperties" }) - -const $IsPropertiesLengthBetween = Schema.Struct({ - _tag: Schema.tag("isPropertiesLengthBetween"), - minimum: NonNegativeInt, - maximum: NonNegativeInt -}).annotate({ identifier: "IsPropertiesLengthBetween" }) - -const $IsPropertyNames = Schema.Struct({ - _tag: Schema.tag("isPropertyNames"), - propertyNames: Representation$ref -}).annotate({ identifier: "IsPropertyNames" }) - -/** - * Schema for {@link ObjectsMeta}. - * - * @category schemas - * @since 4.0.0 - */ -export const $ObjectsMeta = Schema.Union([ - $IsMinProperties, - $IsMaxProperties, - $IsPropertiesLengthBetween, - $IsPropertyNames -]).annotate({ identifier: "ObjectsMeta" }) - -/** - * Schema for the {@link Objects} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Objects = Schema.Struct({ - _tag: Schema.tag("Objects"), - annotations: Schema.optional($Annotations), - propertySignatures: Schema.Array($PropertySignature), - indexSignatures: Schema.Array($IndexSignature), - checks: Schema.Array(makeCheck($ObjectsMeta, "Objects")) -}).annotate({ identifier: "Objects" }) - -/** - * Schema for the {@link Union} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Union = Schema.Struct({ - _tag: Schema.tag("Union"), - annotations: Schema.optional($Annotations), - types: Schema.Array(Representation$ref), - mode: Schema.Literals(["anyOf", "oneOf"]) -}).annotate({ identifier: "Union" }) - -/** - * Schema for the {@link Reference} representation node. - * - * @category schemas - * @since 4.0.0 - */ -export const $Reference = Schema.Struct({ - _tag: Schema.tag("Reference"), - $ref: Schema.String -}).annotate({ identifier: "Reference" }) - -const $IsDateValid = Schema.Struct({ - _tag: Schema.tag("isDateValid") -}).annotate({ identifier: "IsDateValid" }) - -const $IsGreaterThanDate = Schema.Struct({ - _tag: Schema.tag("isGreaterThanDate"), - exclusiveMinimum: Schema.Date -}).annotate({ identifier: "IsGreaterThanDate" }) - -const $IsGreaterThanOrEqualToDate = Schema.Struct({ - _tag: Schema.tag("isGreaterThanOrEqualToDate"), - minimum: Schema.Date -}).annotate({ identifier: "IsGreaterThanOrEqualToDate" }) - -const $IsLessThanDate = Schema.Struct({ - _tag: Schema.tag("isLessThanDate"), - exclusiveMaximum: Schema.Date -}).annotate({ identifier: "IsLessThanDate" }) - -const $IsLessThanOrEqualToDate = Schema.Struct({ - _tag: Schema.tag("isLessThanOrEqualToDate"), - maximum: Schema.Date -}).annotate({ identifier: "IsLessThanOrEqualToDate" }) - -const $IsBetweenDate = Schema.Struct({ - _tag: Schema.tag("isBetweenDate"), - minimum: Schema.Date, - maximum: Schema.Date, - exclusiveMinimum: Schema.optional(Schema.Boolean), - exclusiveMaximum: Schema.optional(Schema.Boolean) -}).annotate({ identifier: "IsBetweenDate" }) - -/** - * Schema for {@link DateMeta}. + * Use when schemas with shared or unreachable definitions must be passed to representation compilers such as `toCodeDocument`. * - * @category schemas - * @since 4.0.0 - */ -export const $DateMeta = Schema.Union([ - $IsDateValid, - $IsGreaterThanDate, - $IsGreaterThanOrEqualToDate, - $IsLessThanDate, - $IsLessThanOrEqualToDate, - $IsBetweenDate -]).annotate({ identifier: "DateMeta" }) - -const $IsMinSize = Schema.Struct({ - _tag: Schema.tag("isMinSize"), - minSize: NonNegativeInt -}).annotate({ identifier: "IsMinSize" }) - -const $IsMaxSize = Schema.Struct({ - _tag: Schema.tag("isMaxSize"), - maxSize: NonNegativeInt -}).annotate({ identifier: "IsMaxSize" }) - -const $IsSizeBetween = Schema.Struct({ - _tag: Schema.tag("isSizeBetween"), - minimum: NonNegativeInt, - maximum: NonNegativeInt -}).annotate({ identifier: "IsSizeBetween" }) - -/** - * Schema for {@link SizeMeta}. + * **Gotchas** * - * @category schemas - * @since 4.0.0 - */ -export const $SizeMeta = Schema.Union([ - $IsMinSize, - $IsMaxSize, - $IsSizeBetween -]).annotate({ identifier: "SizeMeta" }) - -/** - * Schema for {@link DeclarationMeta}. + * Every schema is projected to its encoded side. Definitions are preserved even when no root reaches them. * - * @category schemas - * @since 4.0.0 - */ -export const $DeclarationMeta = Schema.Union([ - $DateMeta, - $SizeMeta -]).annotate({ identifier: "DeclarationMeta" }) - -/** - * Schema for the {@link Declaration} representation node. + * @see {@link toRepresentations} for converting AST roots without an explicit definition map + * @see {@link toCodeDocument} for generating code from the result * - * @category schemas + * @category constructors * @since 4.0.0 */ -export const $Declaration = Schema.Struct({ - _tag: Schema.tag("Declaration"), - annotations: Schema.optional($Annotations), - typeParameters: Schema.Array(Representation$ref), - checks: Schema.Array(makeCheck($DeclarationMeta, "Declaration")), - encodedSchema: Representation$ref -}).annotate({ identifier: "Declaration" }) +export function fromSchemaMultiDocument(document: SchemaMultiDocument): MultiDocument { + return InternalToRepresentation.fromSchemaMultiDocument(document) +} /** - * Schema for the {@link Suspend} representation node. + * Wraps a single representation document as a multi-document with one root. * - * @category schemas - * @since 4.0.0 - */ -export const $Suspend = Schema.Struct({ - _tag: Schema.tag("Suspend"), - annotations: Schema.optional($Annotations), - checks: Schema.Tuple([]), - thunk: Representation$ref -}).annotate({ identifier: "Suspend" }) - -/** - * Type-level helper for the recursive {@link $Representation} codec. + * **When to use** * - * @category schemas - * @since 4.0.0 - */ -export interface $Representation extends Schema.Codec {} - -/** - * Schema for the full {@link Representation} union. It recursively validates - * and encodes any representation node. + * Use when an API such as `toCodeDocument` requires a `MultiDocument`. * - * @category schemas + * @category transforming * @since 4.0.0 */ -export const $Representation: $Representation = Schema.Union([ - $Null, - $Undefined, - $Void, - $Never, - $Unknown, - $Any, - $String, - $Number, - $Boolean, - $BigInt, - $Symbol, - $Literal, - $UniqueSymbol, - $ObjectKeyword, - $Enum, - $TemplateLiteral, - $Arrays, - $Objects, - $Union, - $Reference, - $Declaration, - $Suspend -]).annotate({ identifier: "Schema" }) +export function toMultiDocument(document: Document): MultiDocument { + return { + representations: [document.representation], + references: document.references + } +} /** - * Schema for {@link Document}. + * Compiles a live representation document to JSON Schema Draft 2020-12. * * **When to use** * - * Use to validate or serialize a single schema representation document with - * `Schema.decodeUnknownSync` or `Schema.encodeSync`. + * Use when you need JSON Schema output from a representation whose checks carry compiler annotations. * * **Gotchas** * - * This codec validates document structure but does not resolve `$ref` keys - * against `references`. - * - * @see {@link DocumentFromJson} for the JSON-string codec wrapper - * @see {@link $MultiDocument} for validating documents with multiple root representations + * Opaque declarations are represented by an unconstrained JSON Schema. Check callback results are used directly, and exceptions raised by a callback pass through unchanged. * - * @category schemas - * @since 4.0.0 - */ -export const $Document = Schema.Struct({ - representation: $Representation, - references: Schema.Record(Schema.String, $Representation) -}).annotate({ identifier: "Document" }) - -/** - * Schema for {@link MultiDocument}. + * @see {@link toJsonSchemaMultiDocument} for multiple roots sharing definitions * - * @category schemas + * @category transforming * @since 4.0.0 */ -export const $MultiDocument = Schema.Struct({ - representations: Schema.NonEmptyArray($Representation), - references: Schema.Record(Schema.String, $Representation) -}).annotate({ identifier: "MultiDocument" }) - -// ----------------------------------------------------------------------------- -// APIs -// ----------------------------------------------------------------------------- +export function toJsonSchemaDocument( + document: Document, + options?: Schema.ToJsonSchemaOptions +): JsonSchema.Document<"draft-2020-12"> { + return InternalToJsonSchemaDocument.toJsonSchemaDocument(document, options) +} /** - * Converts a Schema AST into a {@link Document}. + * Compiles multiple live representations to a shared JSON Schema Draft 2020-12 document. * * **When to use** * - * Use when you have a single Schema AST and need a schema representation - * document. - * - * **Details** + * Use when several representation roots must share the same JSON Schema definitions. * - * Shared/recursive sub-schemas are extracted into the `references` map. - * - * **Example** (Converting a Schema to a Document) - * - * ```ts - * import { Schema, SchemaRepresentation } from "effect" - * - * const Person = Schema.Struct({ - * name: Schema.String, - * age: Schema.Number - * }) + * **Gotchas** * - * const doc = SchemaRepresentation.fromAST(Person.ast) - * console.log(doc.representation._tag) - * // "Objects" - * ``` + * Every definition is compiled, including definitions that are not reachable from a root. * - * @see {@link Document} - * @see {@link fromASTs} + * @see {@link toJsonSchemaDocument} for a single root * - * @category constructors + * @category transforming * @since 4.0.0 */ -export const fromAST: (ast: SchemaAST.AST) => Document = InternalRepresentation.fromAST +export function toJsonSchemaMultiDocument( + document: MultiDocument, + options?: Schema.ToJsonSchemaOptions +): JsonSchema.MultiDocument<"draft-2020-12"> { + return InternalToJsonSchemaDocument.toJsonSchemaMultiDocument(document, options) +} /** - * Converts one or more Schema ASTs into a {@link MultiDocument}. + * Generates TypeScript source for live schema representations and their definitions. * * **When to use** * - * Use when you have multiple Schema ASTs and need one schema representation - * `MultiDocument` with shared references. - * - * **Details** + * Use when custom declarations and checks provide `toCode` callbacks and must be emitted without a central handler registry. * - * All schemas share a single `references` map. + * **Gotchas** * - * @see {@link MultiDocument} - * @see {@link fromAST} + * Opaque declarations and leaf checks require `toCode` callbacks. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * - * @category constructors + * @category transforming * @since 4.0.0 */ -export const fromASTs: (asts: readonly [SchemaAST.AST, ...Array]) => MultiDocument = - InternalRepresentation.fromASTs +export function toCodeDocument(document: MultiDocument): CodeDocument { + return InternalToCodeDocument.toCodeDocument(document) +} /** - * Schema that decodes a {@link Document} from JSON and encodes it back. + * Projects a live single-root representation document and encodes it as JSON. * * **When to use** * - * Use when you need a JSON codec for schema representation documents with - * `Schema.decodeUnknownSync` or `Schema.encodeSync`. - * - * **Example** (Round-tripping a Document through JSON) + * Use when you need a stable JSON value for storage or transport after calling `toRepresentation`. * - * ```ts - * import { Schema, SchemaRepresentation } from "effect" - * - * const doc = SchemaRepresentation.fromAST(Schema.String.ast) - * const json = Schema.encodeSync(SchemaRepresentation.DocumentFromJson)(doc) - * const back = Schema.decodeUnknownSync(SchemaRepresentation.DocumentFromJson)(json) - * ``` - * - * @see {@link $Document} - * @see {@link MultiDocumentFromJson} + * **Gotchas** * - * @category schemas - * @since 4.0.0 - */ -export const DocumentFromJson: Schema.Codec = Schema.toCodecJson($Document) - -/** - * Schema for `MultiDocument` values encoded as JSON. + * Generic annotations that are not JSON are omitted. Invalid persistence identities and unsupported structural values throw an `Error` containing their representation path. * - * @see {@link $MultiDocument} - * @see {@link DocumentFromJson} + * @see {@link toRepresentation} for constructing the live document + * @see {@link toJsonMultiDocument} for documents with multiple roots * - * @category schemas + * @category encoding * @since 4.0.0 */ -export const MultiDocumentFromJson: Schema.Codec = Schema.toCodecJson($MultiDocument) +export function toJson(document: Document): Schema.Json { + return InternalRepresentationJson.toJson(document) +} /** - * Wraps a single {@link Document} as a {@link MultiDocument} with one - * representation. + * Projects a live multi-root representation document and encodes it as JSON. * * **When to use** * - * Use when you need to pass a single schema representation `Document` where an - * API expects a `MultiDocument`. + * Use when you need one JSON value for multiple live roots that share a reference environment. * - * @see {@link Document} - * @see {@link MultiDocument} - * - * @category transforming - * @since 4.0.0 - */ -export function toMultiDocument(document: Document): MultiDocument { - return { - representations: [document.representation], - references: document.references - } -} - -/** - * A callback that handles {@link Declaration} nodes during reconstruction - * ({@link toSchema}) or code generation ({@link toCodeDocument}). - * - * **Details** + * **Gotchas** * - * Return a value to handle the declaration. Return `undefined` to fall back to - * default behavior, which uses `encodedSchema` for `toSchema` or the - * `generation` annotation for `toCodeDocument`. `recur` processes child - * representations recursively. + * The root order and shared reference keys are preserved, while non-JSON generic annotations are omitted. * - * @see {@link toSchema} - * @see {@link toSchemaDefaultReviver} - * @see {@link toCodeDocument} + * @see {@link toRepresentations} for constructing the live multi-document + * @see {@link toJson} for a single-root document * - * @category models + * @category encoding * @since 4.0.0 */ -export type Reviver = (declaration: Declaration, recur: (representation: Representation) => T) => T | undefined +export function toJsonMultiDocument(document: MultiDocument): Schema.Json { + return InternalRepresentationJson.toJsonMultiDocument(document) +} /** - * Default {@link Reviver} for {@link toSchema} that handles built-in Effect - * types, including Option, Result, Redacted, Cause, Exit, ReadonlyMap, HashMap, - * ReadonlySet, Date, Duration, URL, and RegExp. + * Decodes a persisted single-root representation document from JSON. * * **When to use** * - * Use when you need the default `options.reviver` for {@link toSchema} to - * reconstruct runtime schemas for built-in Effect declarations. + * Use when reading a representation document from storage or transport before inspecting it or passing it to `fromRepresentation`. * - * **Details** + * **Gotchas** * - * The reviver returns `undefined` for unrecognized declarations, causing - * fallback to `encodedSchema`. + * Invalid documents throw a schema decoding error. Decoding does not reconstruct runtime callbacks. * - * @see {@link toSchema} - * @see {@link Reviver} + * @see {@link toJson} for encoding a document + * @see {@link fromRepresentation} for reconstructing a runtime schema + * @see {@link fromJsonMultiDocument} for multiple roots sharing references * - * @category transforming + * @category decoding * @since 4.0.0 */ -export const toSchemaDefaultReviver: Reviver = (s, recur) => { - const typeConstructor = s.annotations?.typeConstructor - if (Predicate.isObject(typeConstructor) && typeof typeConstructor._tag === "string") { - const typeParameters = s.typeParameters.map(recur) - switch (typeConstructor._tag) { - // built-in types - case "Date": - return Schema.Date - case "Error": - return Schema.Error(typeConstructor.options as Schema.ErrorOptions | undefined) - case "File": - return Schema.File - case "FormData": - return Schema.FormData - case "ReadonlyMap": - return Schema.ReadonlyMap(typeParameters[0], typeParameters[1]) - case "ReadonlySet": - return Schema.ReadonlySet(typeParameters[0]) - case "RegExp": - return Schema.RegExp - case "Uint8Array": - return Schema.Uint8Array - case "URL": - return Schema.URL - case "URLSearchParams": - return Schema.URLSearchParams - // effect types - case "effect/Option": - return Schema.Option(typeParameters[0]) - case "effect/Result": - return Schema.Result(typeParameters[0], typeParameters[1]) - case "effect/Redacted": - return Schema.Redacted(typeParameters[0], typeConstructor.options as any) - case "effect/DateTime.TimeZone": - return Schema.TimeZone - case "effect/DateTime.TimeZone.Named": - return Schema.TimeZoneNamed - case "effect/DateTime.TimeZone.Offset": - return Schema.TimeZoneOffset - case "effect/DateTime.Utc": - return Schema.DateTimeUtc - case "effect/DateTime.Zoned": - return Schema.DateTimeZoned - case "effect/BigDecimal": - return Schema.BigDecimal - case "effect/Chunk": - return Schema.Chunk(typeParameters[0]) - case "effect/Cause": - return Schema.Cause(typeParameters[0], typeParameters[1]) - case "effect/Cause/Failure": - return Schema.CauseReason(typeParameters[0], typeParameters[1]) - case "effect/Duration": - return Schema.Duration - case "effect/Exit": - return Schema.Exit(typeParameters[0], typeParameters[1], typeParameters[2]) - case "effect/Json": - return Schema.Json - case "effect/MutableJson": - return Schema.MutableJson - case "effect/HashMap": - return Schema.HashMap(typeParameters[0], typeParameters[1]) - case "effect/HashSet": - return Schema.HashSet(typeParameters[0]) - } - } +export function fromJson(input: Schema.Json): Document { + return InternalRepresentationJson.fromJson(input) } /** - * Creates a runtime Schema from a {@link Document}. + * Decodes a persisted multi-root representation document from JSON. * * **When to use** * - * Use when you have a serialized or computed schema representation document and - * need a runtime Schema for decoding/encoding. - * - * **Details** - * - * Pass `options.reviver`, such as {@link toSchemaDefaultReviver}, to handle - * {@link Declaration} nodes for types like `Date` and `Option`. Without a - * reviver, declarations fall back to their `encodedSchema`. Circular references - * are handled via lazy `Schema.suspend`. + * Use when reading multiple representation roots that share references before inspecting them or passing them to `fromRepresentations`. * * **Gotchas** * - * This throws if a `$ref` is not found in `document.references`. - * - * **Example** (Reconstructing a Schema) - * - * ```ts - * import { Schema, SchemaRepresentation } from "effect" + * Invalid documents throw a schema decoding error. Decoding does not reconstruct runtime callbacks. * - * const doc = SchemaRepresentation.fromAST( - * Schema.Struct({ name: Schema.String }).ast - * ) + * @see {@link toJsonMultiDocument} for encoding a multi-document + * @see {@link fromRepresentations} for reconstructing runtime schemas + * @see {@link fromJson} for a single root * - * const schema = SchemaRepresentation.toSchema(doc) - * console.log(JSON.stringify(Schema.toJsonSchemaDocument(schema), null, 2)) - * ``` - * - * @see {@link Document} - * @see {@link Reviver} - * @see {@link toSchemaDefaultReviver} - * - * @category Runtime Generation + * @category decoding * @since 4.0.0 */ -export function toSchema(document: Document, options?: { - readonly reviver?: Reviver | undefined -}): S { - type Slot = { - // 0 = not started, 1 = building, 2 = done - state: 0 | 1 | 2 - value: Schema.Top | undefined - ref: Schema.Top - } - - const slots = new Map() - - return recur(document.representation) as S - - function recur(r: Representation): Schema.Top { - let out = on(r) - if ("annotations" in r && r.annotations) out = out.annotate(r.annotations) - out = toSchemaChecks(out, r) - return out - } - - function getSlot(identifier: string): Slot { - const existing = slots.get(identifier) - if (existing) return existing - - // Create the slot *before* resolving, so self-references can see it. - const slot: Slot = { - state: 0, - value: undefined, - ref: Schema.suspend(() => { - if (slot.value === undefined) { - return Schema.Unknown - } - return slot.value - }) - } - slots.set(identifier, slot) - return slot - } - - function resolveReference($ref: string): Schema.Top { - const definition = document.references[$ref] - if (definition === undefined) { - throw new Error(`Reference ${$ref} not found`) - } - - const slot = getSlot($ref) - - if (slot.state === 2) { - // Already built: return the built schema directly - return slot.value! - } - - if (slot.state === 1) { - // Circular: we're currently building this identifier. - return slot.ref - } - - // First time: build it. - slot.state = 1 - try { - slot.value = recur(definition) - slot.state = 2 - return slot.value - } catch (e) { - // Leave the slot in a safe state so future thunks don't silently succeed. - slot.state = 0 - slot.value = undefined - throw e - } - } - - function on(r: Representation): Schema.Top { - switch (r._tag) { - case "Declaration": - return options?.reviver?.(r, recur) ?? recur(r.encodedSchema) - case "Reference": - return resolveReference(r.$ref) - case "Suspend": - return recur(r.thunk) - case "Null": - return Schema.Null - case "Undefined": - return Schema.Undefined - case "Void": - return Schema.Void - case "Never": - return Schema.Never - case "Unknown": - return Schema.Unknown - case "Any": - return Schema.Any - case "String": { - const contentMediaType = r.contentMediaType - const contentSchema = r.contentSchema - if (contentMediaType === "application/json" && contentSchema !== undefined) { - return Schema.fromJsonString(recur(contentSchema)) - } - return Schema.String - } - case "Number": - return Schema.Number - case "Boolean": - return Schema.Boolean - case "BigInt": - return Schema.BigInt - case "Symbol": - return Schema.Symbol - case "Literal": - return Schema.Literal(r.literal) - case "UniqueSymbol": - return Schema.UniqueSymbol(r.symbol) - case "ObjectKeyword": - return Schema.ObjectKeyword - case "Enum": - return Schema.Enum(Object.fromEntries(r.enums)) - case "TemplateLiteral": { - const parts = r.parts.map(recur) as Schema.TemplateLiteral.Parts - return Schema.TemplateLiteral(parts) - } - case "Arrays": { - const elements = r.elements.map((e) => { - const s = recur(e.type) - return e.isOptional ? Schema.optionalKey(s) : s - }) - const rest = r.rest.map(recur) - if (Arr.isArrayNonEmpty(rest)) { - if (r.elements.length === 0 && r.rest.length === 1) { - return Schema.Array(rest[0]) - } - return Schema.TupleWithRest(Schema.Tuple(elements), rest) - } - return Schema.Tuple(elements) - } - case "Objects": { - const fields: Record = {} - - for (const ps of r.propertySignatures) { - const s = recur(ps.type) - const withOptional = ps.isOptional ? Schema.optionalKey(s) : s - fields[ps.name] = ps.isMutable ? Schema.mutableKey(withOptional) : withOptional - } - - const indexSignatures = r.indexSignatures.map((is) => - Schema.Record(recur(is.parameter) as Schema.Record.Key, recur(is.type)) - ) - - if (Arr.isArrayNonEmpty(indexSignatures)) { - if (r.propertySignatures.length === 0 && indexSignatures.length === 1) { - return indexSignatures[0] - } - return Schema.StructWithRest(Schema.Struct(fields), indexSignatures) - } - - return Schema.Struct(fields) - } - case "Union": { - if (r.types.length === 0) return Schema.Never - if (r.types.every((t) => t._tag === "Literal")) { - if (r.types.length === 1) { - return Schema.Literal(r.types[0].literal) - } - return Schema.Literals(r.types.map((t) => t.literal)) - } - return Schema.Union(r.types.map(recur), { mode: r.mode }) - } - } - } - - function toSchemaChecks(top: Schema.Top, schema: Representation): Schema.Top { - switch (schema._tag) { - default: - return top - case "String": - case "Number": - case "BigInt": - case "Arrays": - case "Objects": - case "Declaration": { - const checks = schema.checks.map(toSchemaCheck) - return Arr.isArrayNonEmpty(checks) ? top.check(...checks) : top - } - } - } - - function toSchemaCheck(check: Check): SchemaAST.Check { - switch (check._tag) { - case "Filter": - return toSchemaFilter(check) - case "FilterGroup": { - return Schema.makeFilterGroup(Arr.map(check.checks, toSchemaCheck), check.annotations) - } - } - } - - function toSchemaFilter(filter: Filter): SchemaAST.Check { - const a = filter.annotations - switch (filter.meta._tag) { - // String Meta - case "isStringFinite": - return Schema.isStringFinite(a) - case "isStringBigInt": - return Schema.isStringBigInt(a) - case "isStringSymbol": - return Schema.isStringSymbol(a) - case "isMinLength": - return Schema.isMinLength(filter.meta.minLength, a) - case "isMaxLength": - return Schema.isMaxLength(filter.meta.maxLength, a) - case "isLengthBetween": - return Schema.isLengthBetween(filter.meta.minimum, filter.meta.maximum, a) - case "isPattern": - return Schema.isPattern(filter.meta.regExp, a) - case "isTrimmed": - return Schema.isTrimmed(a) - case "isUUID": - return Schema.isUUID(filter.meta.version, a) - case "isGUID": - return Schema.isGUID(a) - case "isULID": - return Schema.isULID(a) - case "isBase64": - return Schema.isBase64(a) - case "isBase64Url": - return Schema.isBase64Url(a) - case "isStartsWith": - return Schema.isStartsWith(filter.meta.startsWith, a) - case "isEndsWith": - return Schema.isEndsWith(filter.meta.endsWith, a) - case "isIncludes": - return Schema.isIncludes(filter.meta.includes, a) - case "isUppercased": - return Schema.isUppercased(a) - case "isLowercased": - return Schema.isLowercased(a) - case "isCapitalized": - return Schema.isCapitalized(a) - case "isUncapitalized": - return Schema.isUncapitalized(a) - - // Number Meta - case "isFinite": - return Schema.isFinite(a) - case "isInt": - return Schema.isInt(a) - case "isMultipleOf": - return Schema.isMultipleOf(filter.meta.divisor, a) - case "isGreaterThan": - return Schema.isGreaterThan(filter.meta.exclusiveMinimum, a) - case "isGreaterThanOrEqualTo": - return Schema.isGreaterThanOrEqualTo(filter.meta.minimum, a) - case "isLessThan": - return Schema.isLessThan(filter.meta.exclusiveMaximum, a) - case "isLessThanOrEqualTo": - return Schema.isLessThanOrEqualTo(filter.meta.maximum, a) - case "isBetween": - return Schema.isBetween(filter.meta, a) - - // BigInt Meta - case "isGreaterThanBigInt": - return Schema.isGreaterThanBigInt(filter.meta.exclusiveMinimum, a) - case "isGreaterThanOrEqualToBigInt": - return Schema.isGreaterThanOrEqualToBigInt(filter.meta.minimum, a) - case "isLessThanBigInt": - return Schema.isLessThanBigInt(filter.meta.exclusiveMaximum, a) - case "isLessThanOrEqualToBigInt": - return Schema.isLessThanOrEqualToBigInt(filter.meta.maximum, a) - case "isBetweenBigInt": - return Schema.isBetweenBigInt(filter.meta, a) - - // Object Meta - case "isMinProperties": - return Schema.isMinProperties(filter.meta.minProperties, a) - case "isMaxProperties": - return Schema.isMaxProperties(filter.meta.maxProperties, a) - case "isPropertiesLengthBetween": - return Schema.isPropertiesLengthBetween(filter.meta.minimum, filter.meta.maximum, a) - case "isPropertyNames": - return Schema.isPropertyNames(recur(filter.meta.propertyNames) as Schema.Record.Key, a) - - // Arrays Meta - case "isUnique": - return Schema.isUnique(a) - - // Date Meta - case "isDateValid": - return Schema.isDateValid(a) - case "isGreaterThanDate": - return Schema.isGreaterThanDate(filter.meta.exclusiveMinimum, a) - case "isGreaterThanOrEqualToDate": - return Schema.isGreaterThanOrEqualToDate(filter.meta.minimum, a) - case "isLessThanDate": - return Schema.isLessThanDate(filter.meta.exclusiveMaximum, a) - case "isLessThanOrEqualToDate": - return Schema.isLessThanOrEqualToDate(filter.meta.maximum, a) - case "isBetweenDate": - return Schema.isBetweenDate(filter.meta, a) - - // Size Meta - case "isMinSize": - return Schema.isMinSize(filter.meta.minSize, a) - case "isMaxSize": - return Schema.isMaxSize(filter.meta.maxSize, a) - case "isSizeBetween": - return Schema.isSizeBetween(filter.meta.minimum, filter.meta.maximum, a) - } - } +export function fromJsonMultiDocument(input: Schema.Json): MultiDocument { + return InternalRepresentationJson.fromJsonMultiDocument(input) } /** - * Converts a {@link Document} to a Draft 2020-12 JSON Schema document. + * Reconstructs a runtime schema from a representation document. * * **When to use** * - * Use when you need to produce a standard JSON Schema document from a schema - * representation `Document`. + * Use when you have decoded or constructed a document whose declaration and check annotations may require revivers. * * **Gotchas** * - * JSON Schema generation is best-effort. Some Effect schema representation - * semantics cannot be represented exactly in JSON Schema, and importing an - * emitted JSON Schema may produce an equivalent approximation rather than the - * original representation shape. - * - * **Example** (Generating JSON Schema) + * Revivers are resolved locally by `id`; none are installed implicitly. Reviver results are used directly, and exceptions raised by a reviver pass through unchanged. * - * ```ts - * import { Schema, SchemaRepresentation } from "effect" - * - * const doc = SchemaRepresentation.fromAST(Schema.String.ast) - * const jsonSchema = SchemaRepresentation.toJsonSchemaDocument(doc) - * console.log(jsonSchema.schema.type) - * // "string" - * ``` - * - * @see {@link Document} - * @see {@link toJsonSchemaMultiDocument} - * @see {@link fromJsonSchemaDocument} + * @see {@link fromJson} for decoding a persisted document + * @see {@link fromRepresentations} for multiple roots sharing references * * @category transforming * @since 4.0.0 */ -export const toJsonSchemaDocument: ( +export function fromRepresentation( document: Document, - options?: Schema.ToJsonSchemaOptions -) => JsonSchema.Document<"draft-2020-12"> = InternalRepresentation.toJsonSchemaDocument + options: { readonly revivers: ReadonlyArray } +): Schema.Top { + return InternalFromRepresentation.fromRepresentation(document, options.revivers) +} /** - * Converts a {@link MultiDocument} to a Draft 2020-12 JSON Schema - * multi-document. + * Reconstructs multiple runtime schemas and their shared definitions from a representation multi-document. * * **When to use** * - * Use when you need to export related schema representation documents together - * so shared definitions stay in multi-document JSON Schema form. + * Use when every root and named definition must be rebuilt in one shared reference environment. * * **Gotchas** * - * JSON Schema generation is best-effort. Some Effect schema representation - * semantics cannot be represented exactly in JSON Schema, and importing an - * emitted JSON Schema may produce equivalent approximations rather than the - * original representation shapes. + * Every definition is revived, including definitions not reachable from a root. Revivers are resolved locally by `id`; none are installed implicitly. * - * @see {@link MultiDocument} - * @see {@link toJsonSchemaDocument} - * @see {@link fromJsonSchemaMultiDocument} + * @see {@link fromJsonMultiDocument} for decoding a persisted multi-document + * @see {@link fromRepresentation} for a single root * * @category transforming * @since 4.0.0 */ -export const toJsonSchemaMultiDocument: ( +export function fromRepresentations( document: MultiDocument, - options?: Schema.ToJsonSchemaOptions -) => JsonSchema.MultiDocument<"draft-2020-12"> = InternalRepresentation.toJsonSchemaMultiDocument - -/** - * A pair of TypeScript source strings for a schema: `runtime` is the - * executable Schema expression, `Type` is the corresponding TypeScript type. - * - * @see {@link makeCode} - * @see {@link CodeDocument} - * - * @category Code Generation - * @since 4.0.0 - */ -export type Code = { - readonly runtime: string - readonly Type: string -} - -/** - * Constructs a {@link Code} value from a runtime expression string and a - * TypeScript type string. - * - * @see {@link Code} - * - * @category Code Generation - * @since 4.0.0 - */ -export function makeCode(runtime: string, Type: string): Code { - return { runtime, Type } -} - -/** - * An auxiliary code artifact produced during code generation — a symbol - * declaration, an enum declaration, or an import statement. - * - * @see {@link CodeDocument} - * @see {@link toCodeDocument} - * - * @category Code Generation - * @since 4.0.0 - */ -export type Artifact = - | { - readonly _tag: "Symbol" - readonly identifier: string - readonly generation: Code - } - | { - readonly _tag: "Enum" - readonly identifier: string - readonly generation: Code - } - | { - readonly _tag: "Import" - readonly importDeclaration: string - } - -/** - * The output of {@link toCodeDocument}: generated TypeScript code for one or - * more schemas plus their shared references and auxiliary artifacts. - * - * **Details** - * - * `codes` contains one {@link Code} per input representation. - * `references.nonRecursives` contains topologically sorted non-recursive - * definitions. `references.recursives` contains definitions involved in cycles. - * `artifacts` contains symbols, enums, and import statements needed by the - * code. - * - * @see {@link toCodeDocument} - * @see {@link Code} - * @see {@link Artifact} - * - * @category Code Generation - * @since 4.0.0 - */ -export type CodeDocument = { - readonly codes: ReadonlyArray - readonly references: { - readonly nonRecursives: ReadonlyArray<{ - readonly $ref: string - readonly code: Code - }> - readonly recursives: { - readonly [$ref: string]: Code - } - } - readonly artifacts: ReadonlyArray -} - -/** - * Generates TypeScript code strings from a {@link MultiDocument}. - * - * **When to use** - * - * Use when you need to produce source code for Effect Schema definitions from a - * schema representation `MultiDocument`. - * - * **Details** - * - * `options.reviver` can customize code generation for {@link Declaration} - * nodes. Return `undefined` to fall back to the default logic, which uses - * `generation` annotations or the encoded schema. References are - * topologically sorted so non-recursive definitions are emitted before their - * dependents. `$ref` keys are converted to sanitized JavaScript identifiers. - * - * **Example** (Generating TypeScript code) - * - * ```ts - * import { Schema, SchemaRepresentation } from "effect" - * - * const Person = Schema.Struct({ - * name: Schema.String, - * age: Schema.Int - * }) - * - * const multi = SchemaRepresentation.toMultiDocument( - * SchemaRepresentation.fromAST(Person.ast) - * ) - * const codeDoc = SchemaRepresentation.toCodeDocument(multi) - * console.log(codeDoc.codes[0].runtime) - * // Schema.Struct({ ... }) - * ``` - * - * @see {@link CodeDocument} - * @see {@link MultiDocument} - * @see {@link Reviver} - * - * @category Code Generation - * @since 4.0.0 - */ -export function toCodeDocument(multiDocument: MultiDocument, options?: { - /** - * The reviver can return `undefined` to indicate that the generation should be generated by the default logic - */ - readonly reviver?: Reviver | undefined -}): CodeDocument { - const artifacts: Array = [] - - const ts = topologicalSort(multiDocument.references) - - // Phase 1: Build sanitization map with collision handling - const sanitizedReferenceMap = new Map() - const uniqueSanitizedReferences = new Set() - const referenceCount = new Map() - - // Process all references first to build the map - const allRefs = [ - ...ts.nonRecursives.map(({ $ref }) => $ref), - ...Object.keys(ts.recursives) - ] - - for (const ref of allRefs) { - ensureUniqueSanitized(ref) - } - - // Phase 2: Use the map when processing references - const nonRecursives = ts.nonRecursives.map(({ $ref, representation }) => ({ - $ref: sanitizedReferenceMap.get($ref)!, - code: recur(representation) - })) - const recursives = Rec.mapEntries(ts.recursives, (representation, $ref) => [ - sanitizedReferenceMap.get($ref)!, - recur(representation) - ]) - - const codes = multiDocument.representations.map(recur) - - return { - codes, - references: { - nonRecursives: nonRecursives.filter(({ $ref }) => (referenceCount.get($ref) ?? 0) > 0), - recursives: Rec.filter(recursives, (_, $ref) => (referenceCount.get($ref) ?? 0) > 0) - }, - artifacts - } - - function ensureUniqueSanitized(originalRef: string): string { - // Check if already mapped (consistency) - const sanitized = sanitizedReferenceMap.get(originalRef) - if (sanitized !== undefined) { - return sanitized - } - - // Find unique sanitized name - const seed = sanitizeJavaScriptIdentifier(originalRef) - let candidate = seed - let suffix = 0 - - while (uniqueSanitizedReferences.has(candidate)) { - candidate = `${seed}${++suffix}` - } - - uniqueSanitizedReferences.add(candidate) - sanitizedReferenceMap.set(originalRef, candidate) - return candidate - } - - function addSymbol(s: symbol): string { - const identifier = ensureUniqueSanitized("_symbol") - const key = globalThis.Symbol.keyFor(s) - const description = s.description - const generation = key === undefined - ? makeCode(`Symbol(${description === undefined ? "" : format(description)})`, `typeof ${identifier}`) - : makeCode(`Symbol.for(${format(key)})`, `typeof ${identifier}`) - artifacts.push({ _tag: "Symbol", identifier, generation }) - return identifier - } - - function addEnum(s: Enum): string { - const identifier = ensureUniqueSanitized("_Enum") - artifacts.push({ - _tag: "Enum", - identifier, - generation: makeCode( - `enum ${identifier} { ${s.enums.map(([name, value]) => `${format(name)}: ${format(value)}`).join(", ")} }`, - `typeof ${identifier}` - ) - }) - return identifier - } - - function addImport(importDeclaration: string) { - if (!artifacts.some((a) => a._tag === "Import" && a.importDeclaration === importDeclaration)) { - artifacts.push({ _tag: "Import", importDeclaration }) - } - } - - function recur(s: Representation): Code { - const g = on(s) - switch (s._tag) { - default: - return makeCode( - g.runtime + toRuntimeAnnotate(s.annotations) + toRuntimeBrand(s.annotations), - g.Type + toTypeBrand(s.annotations) - ) - case "Reference": - return g - case "Declaration": - case "String": - case "Number": - case "BigInt": - case "Arrays": - case "Objects": - case "Suspend": - return makeCode( - g.runtime + toRuntimeAnnotate(s.annotations) + toRuntimeBrand(s.annotations) + toRuntimeChecks(s.checks), - g.Type + toTypeBrand(s.annotations) + toTypeChecks(s.checks) - ) - } - } - - function on(s: Representation): Code { - switch (s._tag) { - case "Declaration": { - // if there is a reviver, use it to generate the generation - if (options?.reviver !== undefined) { - // the reviver can return `undefined` to indicate that the generation should be generated by the default logic - const out = options.reviver(s, recur) - if (out !== undefined) { - return out - } - } - // otherwise, use the generation from the annotations - const generation = s.annotations?.generation - if ( - Predicate.isObject(generation) && typeof generation.runtime === "string" && - typeof generation.Type === "string" - ) { - const typeParameters = s.typeParameters.map(recur) - if (typeof generation.importDeclaration === "string") { - addImport(generation.importDeclaration) - } - return makeCode( - replacePlaceholders(generation.runtime, typeParameters.map((p) => p.runtime)), - replacePlaceholders(generation.Type, typeParameters.map((p) => p.Type)) - ) - } - // otherwise, use the generation from the encoded schema - return recur(s.encodedSchema) - } - case "Reference": { - const sanitized = ensureUniqueSanitized(s.$ref) - referenceCount.set(sanitized, (referenceCount.get(sanitized) ?? 0) + 1) - return makeCode(sanitized, sanitized) - } - case "Suspend": { - const thunk = recur(s.thunk) - return makeCode( - `Schema.suspend((): Schema.Codec<${thunk.Type}> => ${thunk.runtime})`, - thunk.Type - ) - } - case "Null": - return makeCode(`Schema.Null`, "null") - case "Undefined": - return makeCode(`Schema.Undefined`, "undefined") - case "Void": - return makeCode(`Schema.Void`, "void") - case "Never": - return makeCode(`Schema.Never`, "never") - case "Unknown": - return makeCode(`Schema.Unknown`, "unknown") - case "Any": - return makeCode(`Schema.Any`, "any") - case "Number": - return makeCode(`Schema.Number`, "number") - case "Boolean": - return makeCode(`Schema.Boolean`, "boolean") - case "BigInt": - return makeCode(`Schema.BigInt`, "bigint") - case "Symbol": - return makeCode(`Schema.Symbol`, "symbol") - case "String": { - const contentMediaType = s.contentMediaType - const contentSchema = s.contentSchema - if (contentMediaType === "application/json" && contentSchema !== undefined) { - return makeCode(`Schema.fromJsonString(${recur(contentSchema)})`, "string") - } else { - return makeCode(`Schema.String`, "string") - } - } - case "Literal": { - const literal = format(s.literal) - return makeCode(`Schema.Literal(${literal})`, literal) - } - case "UniqueSymbol": { - const identifier = addSymbol(s.symbol) - return makeCode(`Schema.UniqueSymbol(${identifier})`, `typeof ${identifier}`) - } - case "ObjectKeyword": - return makeCode(`Schema.ObjectKeyword`, "object") - case "Enum": { - const identifier = addEnum(s) - return makeCode(`Schema.Enum(${identifier})`, `typeof ${identifier}`) - } - case "TemplateLiteral": { - const parts = s.parts.map(recur) - const type = toTypeParts(s.parts).map((p) => "`" + p + "`").join(" | ") - return makeCode(`Schema.TemplateLiteral([${parts.map((p) => p.runtime).join(", ")}])`, type) - } - case "Arrays": { - const elements = s.elements.map((e) => { - return { - isOptional: e.isOptional, - type: recur(e.type), - annotations: e.annotations - } - }) - - const rest = s.rest.map(recur) - - if (Arr.isArrayNonEmpty(rest)) { - const item = rest[0] - if (elements.length === 0 && rest.length === 1) { - return makeCode( - `Schema.Array(${item.runtime})`, - `ReadonlyArray<${item.Type}>` - ) - } - const post = rest.slice(1) - return makeCode( - `Schema.TupleWithRest(Schema.Tuple([${ - elements.map((e) => - toRuntimeIsOptional(e.isOptional, e.type.runtime) + toRuntimeAnnotateKey(e.annotations) - ).join(", ") - }]), [${rest.map((r) => r.runtime).join(", ")}])`, - `readonly [${ - elements.map((e) => toTypeIsOptional(e.isOptional, e.type.Type)).join(", ") - }, ...Array<${item.Type}>${post.length > 0 ? `, ${post.map((p) => p.Type).join(", ")}` : ""}]` - ) - } - return makeCode( - `Schema.Tuple([${ - elements.map((e) => toRuntimeIsOptional(e.isOptional, e.type.runtime) + toRuntimeAnnotateKey(e.annotations)) - .join(", ") - }])`, - `readonly [${elements.map((e) => toTypeIsOptional(e.isOptional, e.type.Type)).join(", ")}]` - ) - } - case "Objects": { - const pss = s.propertySignatures.map((p) => { - const isSymbol = typeof p.name === "symbol" - const name = isSymbol ? addSymbol(p.name) : formatPropertyKey(p.name) - const nameType = toTypeIsOptional( - p.isOptional, - toTypeIsMutable(p.isMutable, isSymbol ? `[typeof ${name}]` : name) - ) - const type = recur(p.type) - return makeCode( - `${isSymbol ? `[${name}]` : name}: ${ - toRuntimeIsOptional(p.isOptional, toRuntimeIsMutable(p.isMutable, type.runtime)) - }` + - toRuntimeAnnotateKey(p.annotations), - `${nameType}: ${type.Type}` - ) - }) - - const iss = s.indexSignatures.map((is) => { - return { - parameter: recur(is.parameter), - type: recur(is.type) - } - }) - - if (iss.length === 0) { - // 1) Only properties -> Struct - return makeCode( - `Schema.Struct({ ${pss.map((p) => p.runtime).join(", ")} })`, - `{ ${pss.map((p) => p.Type).join(", ")} }` - ) - } else if (pss.length === 0 && iss.length === 1) { - // 2) Only one index signature and no properties -> Record - return makeCode( - `Schema.Record(${iss[0].parameter.runtime}, ${iss[0].type.runtime})`, - `{ readonly [x: ${iss[0].parameter.Type}]: ${iss[0].type.Type} }` - ) - } else { - // 3) Properties + index signatures -> StructWithRest - return makeCode( - `Schema.StructWithRest(Schema.Struct({ ${pss.map((p) => p.runtime).join(", ")} }), [${ - iss.map((is) => `Schema.Record(${is.parameter.runtime}, ${is.type.runtime})`).join(", ") - }])`, - `{ ${pss.map((p) => p.Type).join(", ")}, ${ - iss.map((is) => `readonly [x: ${is.parameter.Type}]: ${is.type.Type}`).join(", ") - } }` - ) - } - } - case "Union": { - if (s.types.length === 0) { - return makeCode("Schema.Never", "never") - } - if (s.types.every((t) => t._tag === "Literal")) { - const literals = s.types.map((l) => format(l.literal)) - if (literals.length === 1) { - return makeCode(`Schema.Literal(${literals[0]})`, literals[0]) - } - return makeCode(`Schema.Literals([${literals.join(", ")}])`, literals.join(" | ")) - } - const mode = s.mode === "anyOf" ? "" : `, { mode: "oneOf" }` - const types = s.types.map((t) => recur(t)) - return makeCode( - `Schema.Union([${types.map((t) => t.runtime).join(", ")}]${mode})`, - types.map((t) => t.Type).join(" | ") - ) - } - } - } - - function toTypeBrand(annotations: Schema.Annotations.Annotations | undefined): string { - const brands = collectBrands(annotations) - if (brands.length === 0) return "" - addImport(`import type * as Brand from "effect/Brand"`) - return brands.map((b) => ` & Brand.Brand<${format(b)}>`).join("") - } - - function toTypeChecks(checks: ReadonlyArray>): string { - return checks.map((c) => toTypeCheck(c)).join("") - } - - function toTypeCheck(check: Check): string { - switch (check._tag) { - case "Filter": - return toTypeBrand(check.annotations) - case "FilterGroup": { - return toTypeChecks(check.checks) - } - } - } - - function toRuntimeChecks(checks: ReadonlyArray>): string { - return checks.map((c) => `.check(${toRuntimeCheck(c)})` + toRuntimeBrand(c.annotations)).join("") - } - - function toRuntimeCheck(check: Check): string { - switch (check._tag) { - case "Filter": - return toRuntimeFilter(check) - case "FilterGroup": { - const a = toRuntimeAnnotations(check.annotations) - const ca = a === "" ? "" : `, ${a}` - return `Schema.makeFilterGroup([${check.checks.map((c) => toRuntimeCheck(c)).join(", ")}]${ca})` - } - } - } - - function toRuntimeFilter(filter: Filter): string { - const a = toRuntimeAnnotations(filter.annotations) - const ca = a === "" ? "" : `, ${a}` - switch (filter.meta._tag) { - case "isTrimmed": - case "isGUID": - case "isULID": - case "isBase64": - case "isBase64Url": - case "isUppercased": - case "isLowercased": - case "isCapitalized": - case "isUncapitalized": - case "isFinite": - case "isInt": - case "isUnique": - case "isDateValid": - return `Schema.${filter.meta._tag}(${a})` - - case "isStringFinite": - case "isStringBigInt": - case "isStringSymbol": - case "isPattern": - return `Schema.${filter.meta._tag}(${toRuntimeRegExp(filter.meta.regExp)}${ca})` - - case "isMinLength": - return `Schema.isMinLength(${filter.meta.minLength}${ca})` - case "isMaxLength": - return `Schema.isMaxLength(${filter.meta.maxLength}${ca})` - case "isLengthBetween": - return `Schema.isLengthBetween(${filter.meta.minimum}, ${filter.meta.maximum}${ca})` - case "isUUID": - return `Schema.isUUID(${filter.meta.version}${ca})` - case "isStartsWith": - return `Schema.isStartsWith(${format(filter.meta.startsWith)}${ca})` - case "isEndsWith": - return `Schema.isEndsWith(${format(filter.meta.endsWith)}${ca})` - case "isIncludes": - return `Schema.isIncludes(${format(filter.meta.includes)}${ca})` - - case "isGreaterThan": - case "isGreaterThanBigInt": - case "isGreaterThanDate": - return `Schema.${filter.meta._tag}(${toRuntimeValue(filter.meta.exclusiveMinimum)}${ca})` - case "isGreaterThanOrEqualTo": - case "isGreaterThanOrEqualToBigInt": - case "isGreaterThanOrEqualToDate": - return `Schema.${filter.meta._tag}(${toRuntimeValue(filter.meta.minimum)}${ca})` - case "isLessThan": - case "isLessThanBigInt": - case "isLessThanDate": - return `Schema.${filter.meta._tag}(${toRuntimeValue(filter.meta.exclusiveMaximum)}${ca})` - case "isLessThanOrEqualTo": - case "isLessThanOrEqualToBigInt": - case "isLessThanOrEqualToDate": - return `Schema.${filter.meta._tag}(${toRuntimeValue(filter.meta.maximum)}${ca})` - case "isBetween": - case "isBetweenBigInt": - case "isBetweenDate": - return `Schema.${filter.meta._tag}({ minimum: ${toRuntimeValue(filter.meta.minimum)}, maximum: ${ - toRuntimeValue(filter.meta.maximum) - }, exclusiveMinimum: ${toRuntimeValue(filter.meta.exclusiveMinimum)}, exclusiveMaximum: ${ - toRuntimeValue(filter.meta.exclusiveMaximum) - }${ca})` - - case "isMultipleOf": - return `Schema.isMultipleOf(${filter.meta.divisor}${ca})` - - case "isMinProperties": - return `Schema.isMinProperties(${filter.meta.minProperties}${ca})` - case "isMaxProperties": - return `Schema.isMaxProperties(${filter.meta.maxProperties}${ca})` - case "isPropertiesLengthBetween": - return `Schema.isPropertiesLengthBetween(${filter.meta.minimum}, ${filter.meta.maximum}${ca})` - case "isPropertyNames": - return `Schema.isPropertyNames(${recur(filter.meta.propertyNames).runtime}${ca})` - - case "isMinSize": - return `Schema.isMinSize(${filter.meta.minSize}${ca})` - case "isMaxSize": - return `Schema.isMaxSize(${filter.meta.maxSize}${ca})` - case "isSizeBetween": - return `Schema.isSizeBetween(${filter.meta.minimum}, ${filter.meta.maximum}${ca})` - } - } -} - -const VALID_ASCII_UPPER_JAVASCRIPT_IDENTIFIER_REGEXP = /^[A-Z_$][A-Za-z0-9_$]*$/ - -/** - * Converts an arbitrary string into a valid (ASCII) JavaScript identifier - * starting with an uppercase letter, `$`, or `_`. - * - * - Replaces invalid identifier characters with `_` - * - Uppercases a leading ASCII letter - * - If the first character is a digit, prefixes `_` - * - Empty input becomes `_` - * - * @internal - */ -export function sanitizeJavaScriptIdentifier(s: string): string { - if (s.length === 0) return "_" - if (VALID_ASCII_UPPER_JAVASCRIPT_IDENTIFIER_REGEXP.test(s)) return s - - const out: Array = [] - let needsPrefix = false - let i = 0 - - for (const ch of s) { - if (i === 0) { - if (ch === "_" || ch === "$" || (ch >= "A" && ch <= "Z")) { - out.push(ch) - } else if (ch >= "a" && ch <= "z") { - out.push(ch.toUpperCase()) - } else if (ch >= "0" && ch <= "9") { - out.push(ch) - needsPrefix = true - } else { - out.push("_") - } - } else { - out.push(isAsciiIdPart(ch) ? ch : "_") - } - i++ - } - - return needsPrefix ? "_" + out.join("") : out.join("") -} - -function isAsciiIdStart(ch: string): boolean { - return ( - ch === "_" || - ch === "$" || - (ch >= "A" && ch <= "Z") || - (ch >= "a" && ch <= "z") - ) -} - -function isAsciiIdPart(ch: string): boolean { - return isAsciiIdStart(ch) || (ch >= "0" && ch <= "9") -} - -function replacePlaceholders(template: string, items: ReadonlyArray) { - let i = 0 - return template.replace(/\?/g, () => items[i++]) -} - -function toTypeParts(parts: ReadonlyArray): ReadonlyArray { - if (parts.length === 0) { - return [""] - } - const [first, ...rest] = parts - const restPatterns = toTypeParts(rest) - return toTypePart(first).flatMap((f) => restPatterns.map((r) => f + r)) -} - -function toTypePart(r: Representation): ReadonlyArray { - switch (r._tag) { - case "Literal": - return [globalThis.String(r.literal)] - case "String": - return ["${string}"] - case "Number": - return ["${number}"] - case "BigInt": - return ["${bigint}"] - case "TemplateLiteral": - return toTypeParts(r.parts) - case "Union": - return r.types.flatMap(toTypePart) - default: - return [] - } -} - -const toCodeAnnotationsBlacklist: Set = new Set([ - ...toJsonAnnotationsBlacklist, - "typeConstructor", - "generation", - "brands" -]) - -function toRuntimeAnnotations(annotations: Schema.Annotations.Annotations | undefined): string { - if (!annotations) return "" - const entries: Array = [] - for (const [key, value] of Object.entries(annotations)) { - if (toCodeAnnotationsBlacklist.has(key)) continue - entries.push(`${formatPropertyKey(key)}: ${format(value)}`) - } - if (entries.length === 0) return "" - return `{ ${entries.join(", ")} }` -} - -function toRuntimeBrand(annotations: Schema.Annotations.Annotations | undefined): string { - const brands = collectBrands(annotations) - return brands.length > 0 ? `.pipe(${brands.map((b) => `Schema.brand(${format(b)})`).join(", ")})` : "" -} - -function toRuntimeAnnotate(annotations: Schema.Annotations.Annotations | undefined): string { - const s = toRuntimeAnnotations(annotations) - return s === "" ? "" : `.annotate(${s})` -} - -function toRuntimeAnnotateKey(annotations: Schema.Annotations.Annotations | undefined): string { - const s = toRuntimeAnnotations(annotations) - return s === "" ? "" : `.annotateKey(${s})` -} - -function toRuntimeIsOptional(isOptional: boolean, runtime: string): string { - return isOptional ? `Schema.optionalKey(${runtime})` : runtime -} - -function toTypeIsOptional(isOptional: boolean, type: string): string { - return isOptional ? `${type}?` : type -} - -function toRuntimeIsMutable(isMutable: boolean, runtime: string): string { - return isMutable ? `Schema.mutableKey(${runtime})` : runtime -} - -function toTypeIsMutable(isMutable: boolean, type: string): string { - return isMutable ? type : `readonly ${type}` -} - -function toRuntimeValue(value: undefined | number | boolean | bigint | Date): string { - if (value instanceof Date) { - return `new Date(${value.getTime()})` - } - return format(value) -} - -function toRuntimeRegExp(regExp: RegExp): string { - const args = [format(regExp.source)] - const flags = regExp.flags.trim() - if (flags !== "") { - args.push(format(flags)) - } - return `new RegExp(${args.join(", ")})` + options: { readonly revivers: ReadonlyArray } +): SchemaMultiDocument { + return InternalFromRepresentation.fromRepresentations(document, options.revivers) } /** - * Parses a Draft 2020-12 JSON Schema document into a {@link Document}. + * Imports a JSON Schema Draft 2020-12 document as a runtime schema. * * **When to use** * - * Use when you need to import a Draft 2020-12 JSON Schema document into the - * Effect schema representation system. - * - * **Details** - * - * `options.onEnter` is an optional hook called on each JSON Schema node before - * processing, allowing pre-transformation. + * Use when you need to validate or transform values described by an external JSON Schema document. * * **Gotchas** * - * JSON Schema import is best-effort. Some JSON Schema constructs do not map - * exactly to Effect schema representations, and importing a schema previously - * emitted by `toJsonSchemaDocument` may produce an equivalent approximation - * rather than the original representation shape. - * - * This throws if a `$ref` cannot be resolved within the document's definitions. - * Circular `$ref`s are detected and cause an error. + * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * - * @see {@link Document} - * @see {@link toJsonSchemaDocument} - * @see {@link fromJsonSchemaMultiDocument} + * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions + * @see {@link toRepresentation} for converting the result to a representation document * * @category constructors * @since 4.0.0 */ -export function fromJsonSchemaDocument(document: JsonSchema.Document<"draft-2020-12">, options?: { - readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined -}): Document { - const { references, representations: schemas } = fromJsonSchemaMultiDocument({ - dialect: document.dialect, - schemas: [document.schema], - definitions: document.definitions - }, options) - return { - representation: schemas[0], - references - } +export function fromJsonSchemaDocument( + document: JsonSchema.Document<"draft-2020-12">, + options?: FromJsonSchemaOptions +): Schema.Top { + return InternalFromJsonSchemaDocument.fromJsonSchemaDocument(document, options) } /** - * Parses a Draft 2020-12 JSON Schema multi-document into a - * {@link MultiDocument}. + * Imports multiple JSON Schema Draft 2020-12 roots as runtime schemas with shared definitions. * * **When to use** * - * Use when you need to import a Draft 2020-12 JSON Schema multi-document whose - * schemas share definitions. - * - * **Details** - * - * `options.onEnter` is an optional hook called on each JSON Schema node before - * processing. + * Use when multiple imported roots must preserve shared definitions, aliases, and recursion. * * **Gotchas** * - * JSON Schema import is best-effort. Some JSON Schema constructs do not map - * exactly to Effect schema representations, and importing schemas previously - * emitted by `toJsonSchemaMultiDocument` may produce equivalent approximations - * rather than the original representation shapes. + * Every definition is translated, including definitions that no root references. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * - * This throws if a `$ref` cannot be resolved. - * - * @see {@link MultiDocument} - * @see {@link toJsonSchemaMultiDocument} - * @see {@link fromJsonSchemaDocument} + * @see {@link fromJsonSchemaDocument} for a single root + * @see {@link fromSchemaMultiDocument} for converting the result to a representation document * * @category constructors * @since 4.0.0 */ -export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<"draft-2020-12">, options?: { - readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined -}): MultiDocument { - let definitionIdentifier: string | undefined - const references: Record = {} - - type ResolvedReference = Exclude - const resolvedReferences = new Map() - - function resolveReference($ref: string): ResolvedReference { - const definition = document.definitions[$ref] - if (definition === undefined) { - throw new Error(`Reference ${$ref} not found`) - } - - const resolved = resolvedReferences.get($ref) - if (resolved === null) { - throw new Error(`Circular reference detected: ${$ref}`) - } - if (resolved !== undefined) return resolved - - resolvedReferences.set($ref, null) - const value = recur(definition) - const out = value._tag === "Reference" ? resolveReference(value.$ref) : value - resolvedReferences.set($ref, out) - return out - } - - for (const [identifier, definition] of Object.entries(document.definitions)) { - definitionIdentifier = identifier - references[identifier] = unknownToJson(recur(definition)) - } - - definitionIdentifier = undefined - const representations = Arr.map(document.schemas, (schema) => unknownToJson(recur(schema))) - return { - representations, - references - } - - function recur(u: unknown): Representation { - if (u === false) return never - if (!Predicate.isObject(u)) return unknown - - let js: JsonSchema.JsonSchema = options?.onEnter?.(u) ?? u - if (Array.isArray(js.type)) { - if (js.type.every(isType)) { - const { type, ...rest } = js - js = { - anyOf: type.map((type) => ({ type })), - ...rest - } - } else { - js = {} - } - } - - let out = on(js) - - const annotations = collectAnnotations(js) - if (annotations !== undefined) { - out = combine(out, { _tag: "Unknown", annotations }) - } - - if (Array.isArray(js.allOf)) { - out = js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out) - } - if (Array.isArray(js.anyOf)) { - out = combine({ _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" }, out) - } - if (Array.isArray(js.oneOf)) { - out = combine({ _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" }, out) - } - - return out - } - - function on(js: JsonSchema.JsonSchema): Representation { - if (typeof js.$ref === "string") { - const $ref = js.$ref.slice(2).split("/").at(-1) - if ($ref !== undefined) { - const reference: Reference = { _tag: "Reference", $ref: unescapeToken($ref) } - if (definitionIdentifier === $ref) { - return { _tag: "Suspend", thunk: reference, checks: [] } - } else { - return reference - } - } - } else if ("const" in js) { - if (isLiteralValue(js.const)) { - return { _tag: "Literal", literal: js.const } - } else if (js.const === null) { - return null_ - } - } else if (Array.isArray(js.enum)) { - const types: Array = [] - for (const e of js.enum) { - if (isLiteralValue(e)) { - types.push({ _tag: "Literal", literal: e }) - } else if (e === null) { - types.push(null_) - } else { - types.push(recur(e)) - } - } - if (types.length === 1) { - return types[0] - } else { - return { _tag: "Union", types, mode: "anyOf" } - } - } - - const type = isType(js.type) ? js.type : getType(js) - if (type !== undefined) { - switch (type) { - case "null": - return null_ - case "string": { - const checks = collectStringChecks(js) - if (checks.length > 0) { - return { ...string, checks } - } - return string - } - case "number": - return { - _tag: "Number", - checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }, ...collectNumberChecks(js)] - } - case "integer": - return { - _tag: "Number", - checks: [{ _tag: "Filter", meta: { _tag: "isInt" } }, ...collectNumberChecks(js)] - } - case "boolean": - return boolean - case "array": { - const minItems = typeof js.minItems === "number" ? js.minItems : 0 - - const elements: Array = (Array.isArray(js.prefixItems) ? js.prefixItems : []).map((e, i) => ({ - isOptional: i + 1 > minItems, - type: recur(e) - })) - - const rest: Array = js.items !== undefined ? - [recur(js.items)] - : js.prefixItems !== undefined && typeof js.maxItems === "number" - ? [] - : [unknown] - - return { _tag: "Arrays", elements, rest, checks: collectArraysChecks(js) } - } - case "object": { - return { - _tag: "Objects", - propertySignatures: collectProperties(js), - indexSignatures: collectIndexSignatures(js), - checks: collectObjectsChecks(js) - } - } - } - } - - return { _tag: "Unknown" } - } - - function collectObjectsChecks(js: JsonSchema.JsonSchema): Array> { - const checks: Array> = [] - if (typeof js.minProperties === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMinProperties", minProperties: js.minProperties } }) - } - if (typeof js.maxProperties === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMaxProperties", maxProperties: js.maxProperties } }) - } - if (js.propertyNames !== undefined) { - const propertyNames = recur(js.propertyNames) - checks.push({ _tag: "Filter", meta: { _tag: "isPropertyNames", propertyNames } }) - } - return checks - } - - function combine(a: Representation, b: Representation): Representation { - switch (a._tag) { - default: - return never - case "Reference": - return combine(resolveReference(a.$ref), b) - case "Never": - return a - case "Unknown": { - const resolved = b._tag === "Reference" ? resolveReference(b.$ref) : b - return { ...resolved, ...combineAnnotations(a.annotations, resolved.annotations) } - } - case "Null": - case "String": - case "Number": - case "Boolean": - case "Literal": - case "Arrays": - case "Objects": - case "Union": - break - } - - if (b._tag === "Reference") { - return combine(a, resolveReference(b.$ref)) - } - if (b._tag === "Unknown") { - return { ...a, ...combineAnnotations(a.annotations, b.annotations) } - } - if (a._tag === "Union") { - const types = a.types.map((s) => combine(s, b)).filter((s) => s !== never) - if (types.length === 0) return never - return { - _tag: "Union", - types, - mode: a.mode, - ...makeAnnotations(a.annotations) - } - } - if (b._tag === "Union") { - return combine(b, a) - } - - switch (a._tag) { - case "Null": - return b._tag === "Null" ? { ...a, ...combineAnnotations(a.annotations, b.annotations) } : never - case "String": { - if (b._tag === "Literal") { - return satisfiesLiteral(a, b) ? { ...b, ...combineAnnotations(a.annotations, b.annotations) } : never - } - if (b._tag !== "String") return never - const checks = combineChecks(a.checks, b.checks, b.annotations) - return { - _tag: "String", - checks: checks ?? a.checks, - ...combineAnnotations(a.annotations, checks ? undefined : b.annotations) - } - } - case "Number": { - if (b._tag === "Literal") { - return satisfiesLiteral(a, b) ? { ...b, ...combineAnnotations(a.annotations, b.annotations) } : never - } - if (b._tag !== "Number") return never - const checks = combineNumberChecks(a.checks, b.checks, b.annotations) - return { - _tag: "Number", - checks: checks ?? a.checks, - ...combineAnnotations(a.annotations, checks ? undefined : b.annotations) - } - } - case "Boolean": - if (b._tag === "Boolean") { - return { _tag: "Boolean", ...combineAnnotations(a.annotations, b.annotations) } - } - return b._tag === "Literal" && typeof b.literal === "boolean" - ? { ...b, ...combineAnnotations(a.annotations, b.annotations) } - : never - case "Literal": - switch (b._tag) { - case "Literal": - return a.literal === b.literal - ? { ...a, ...combineAnnotations(a.annotations, b.annotations) } - : never - case "String": - case "Number": - return satisfiesLiteral(b, a) ? { ...a, ...combineAnnotations(a.annotations, b.annotations) } : never - case "Boolean": - return typeof a.literal === "boolean" - ? { ...a, ...combineAnnotations(a.annotations, b.annotations) } - : never - default: - return never - } - case "Arrays": { - if (b._tag !== "Arrays") return never - const arrays = combineArrays(a, b) - if (arrays === undefined) return never - const checks = combineArraysChecks(a.checks, b.checks, b.annotations) - return { - _tag: "Arrays", - elements: arrays.elements, - rest: arrays.rest, - checks: checks ?? a.checks, - ...combineAnnotations(a.annotations, checks ? undefined : b.annotations) - } - } - case "Objects": { - if (b._tag !== "Objects") return never - const checks = combineChecks(a.checks, b.checks, b.annotations) - return { - _tag: "Objects", - propertySignatures: combinePropertySignatures(a.propertySignatures, b.propertySignatures), - indexSignatures: combineIndexSignatures(a.indexSignatures, b.indexSignatures), - checks: checks ?? a.checks, - ...combineAnnotations(a.annotations, checks ? undefined : b.annotations) - } - } - default: - return never - } - } - - function satisfiesPrimitiveCheck(check: Check, value: unknown): boolean { - if (check._tag === "FilterGroup") { - return check.checks.every((check) => satisfiesPrimitiveCheck(check, value)) - } - const meta = check.meta - switch (meta._tag) { - case "isMinLength": - return typeof value === "string" && value.length >= meta.minLength - case "isMaxLength": - return typeof value === "string" && value.length <= meta.maxLength - case "isPattern": - return typeof value === "string" && meta.regExp.test(value) - case "isFinite": - return typeof value === "number" && globalThis.Number.isFinite(value) - case "isInt": - return typeof value === "number" && globalThis.Number.isSafeInteger(value) - case "isMultipleOf": - return typeof value === "number" && remainder(value, meta.divisor) === 0 - case "isGreaterThan": - return typeof value === "number" && value > meta.exclusiveMinimum - case "isGreaterThanOrEqualTo": - return typeof value === "number" && value >= meta.minimum - case "isLessThan": - return typeof value === "number" && value < meta.exclusiveMaximum - case "isLessThanOrEqualTo": - return typeof value === "number" && value <= meta.maximum - default: - return false - } - } - - function satisfiesLiteral(type: String | Number, literal: Literal): boolean { - const value = literal.literal - if (type._tag === "String" ? typeof value !== "string" : typeof value !== "number") { - return false - } - return type.checks.every((check) => satisfiesPrimitiveCheck(check, value)) - } - - function collectProperties(js: JsonSchema.JsonSchema): Array { - const properties: Record = Predicate.isObject(js.properties) ? js.properties : {} - const required = Array.isArray(js.required) ? js.required : [] - required.forEach((key) => { - if (!Object.hasOwn(properties, key)) { - properties[key] = {} - } - }) - return Object.entries(properties).map(([key, v]) => ({ - name: key, - type: recur(v), - isOptional: !required.includes(key), - isMutable: false - })) - } - - function collectIndexSignatures(js: JsonSchema.JsonSchema): Array { - const out: Array = [] - - if (Predicate.isObject(js.patternProperties)) { - for (const [pattern, value] of Object.entries(js.patternProperties)) { - out.push({ parameter: recur({ pattern }), type: recur(value) }) - } - } - - if (js.additionalProperties === undefined || js.additionalProperties === true) { - out.push({ parameter: string, type: unknown }) - } else if (Predicate.isObject(js.additionalProperties)) { - out.push({ parameter: string, type: recur(js.additionalProperties) }) - } - - return out - } - - function combineArrays(a: Arrays, b: Arrays): Pick | undefined { - const elements: Array = [] - const len = Math.max(a.elements.length, b.elements.length) - for (let i = 0; i < len; i++) { - const ae = a.elements[i] - const be = b.elements[i] - const isOptional = ae?.isOptional !== false && be?.isOptional !== false - const at = ae?.type ?? a.rest[0] - const bt = be?.type ?? b.rest[0] - if (at === undefined || bt === undefined) { - return isOptional ? { elements, rest: [] } : undefined - } - const type = combine(at, bt) - if (type === never) { - return isOptional ? { elements, rest: [] } : undefined - } - elements.push({ isOptional, type }) - } - - const ar = a.rest[0] - const br = b.rest[0] - if (ar === undefined || br === undefined) { - return { elements, rest: [] } - } - const rest = combine(ar, br) - return { elements, rest: rest === never ? [] : [rest] } - } - - function combinePropertySignatures( - a: ReadonlyArray, - b: ReadonlyArray - ): Array { - const propertySignatures: Array = [] - const thatPropertiesMap: Record = {} - for (const p of b) { - thatPropertiesMap[p.name] = p - } - const keys = new Set() - for (const p of a) { - keys.add(p.name) - const thatp = thatPropertiesMap[p.name] - if (thatp) { - propertySignatures.push( - { - name: p.name, - type: combine(p.type, thatp.type), - isOptional: p.isOptional && thatp.isOptional, - isMutable: p.isMutable - } - ) - } else { - propertySignatures.push(p) - } - } - for (const p of b) { - if (!keys.has(p.name)) propertySignatures.push(p) - } - return propertySignatures - } - - function combineIndexSignatures( - a: ReadonlyArray, - b: ReadonlyArray - ): Array { - if (a.length === 0 || b.length === 0) return [] - const out: Array = [...a] - for (const is of b) { - if (is.parameter === string) { - const i = a.findIndex((is) => is.parameter === string) - if (i !== -1) { - out[i] = { parameter: string, type: combine(a[i].type, is.type) } - } else { - out.push(is) - } - } else { - out.push(is) - } - } - return out - } - - function unknownToJson(representation: Representation): Representation { - switch (representation._tag) { - case "Unknown": - return representation.annotations === undefined ? - json : - { - ...json, - annotations: { - ...json.annotations, - ...representation.annotations - } - } - case "Suspend": { - const thunk = unknownToJson(representation.thunk) - return thunk === representation.thunk ? representation : { ...representation, thunk } - } - case "String": { - if (representation.contentSchema === undefined) return representation - const contentSchema = unknownToJson(representation.contentSchema) - return contentSchema === representation.contentSchema ? representation : { ...representation, contentSchema } - } - case "Arrays": { - const elements = SchemaAST.mapOrSame(representation.elements, (element) => { - const type = unknownToJson(element.type) - return type === element.type ? element : { ...element, type } - }) - const rest = SchemaAST.mapOrSame(representation.rest, unknownToJson) - return elements === representation.elements && rest === representation.rest ? - representation : - { ...representation, elements, rest } - } - case "Objects": { - const propertySignatures = SchemaAST.mapOrSame(representation.propertySignatures, (propertySignature) => { - const type = unknownToJson(propertySignature.type) - return type === propertySignature.type ? propertySignature : { ...propertySignature, type } - }) - const indexSignatures = SchemaAST.mapOrSame(representation.indexSignatures, (indexSignature) => { - const type = unknownToJson(indexSignature.type) - return type === indexSignature.type ? indexSignature : { ...indexSignature, type } - }) - return propertySignatures === representation.propertySignatures && - indexSignatures === representation.indexSignatures ? - representation : - { ...representation, propertySignatures, indexSignatures } - } - case "Union": { - const types = SchemaAST.mapOrSame(representation.types, unknownToJson) - return types === representation.types ? representation : { ...representation, types } - } - default: - return representation - } - } -} - -function asChecks( - checks: ReadonlyArray>, - annotations: Schema.Annotations.Annotations | undefined -): ReadonlyArray> | undefined { - if (Arr.isReadonlyArrayNonEmpty(checks)) { - if (annotations !== undefined) { - if (checks.length === 1) { - const check = checks[0] - if (check.annotations === undefined) { - return [{ ...check, annotations }] - } else { - return [{ _tag: "FilterGroup", checks, annotations }] - } - } else { - return [{ _tag: "FilterGroup", checks, annotations }] - } - } - return checks - } -} - -function combineChecks( - a: ReadonlyArray>, - b: ReadonlyArray>, - annotations: Schema.Annotations.Annotations | undefined -): Array> | undefined { - const checks = asChecks(b, annotations) - if (checks) { - return [...a, ...checks] - } -} - -function combineNumberChecks( - a: ReadonlyArray>, - b: ReadonlyArray>, - annotations: Schema.Annotations.Annotations | undefined -): Array> | undefined { - if (a.some((c) => c._tag === "Filter" && c.meta._tag === "isFinite")) { - b = b.filter((c) => c._tag !== "Filter" || c.meta._tag !== "isFinite") - } - if (a.some((c) => c._tag === "Filter" && c.meta._tag === "isInt")) { - b = b.filter((c) => c._tag !== "Filter" || c.meta._tag !== "isInt") - } - return combineChecks(a, b, annotations) -} - -function combineArraysChecks( - a: ReadonlyArray>, - b: ReadonlyArray>, - annotations: Schema.Annotations.Annotations | undefined -): Array> | undefined { - if (a.some((c) => c._tag === "Filter" && c.meta._tag === "isUnique")) { - b = b.filter((c) => c._tag !== "Filter" || c.meta._tag !== "isUnique") - } - return combineChecks(a, b, annotations) -} - -function makeAnnotations( - annotations: Schema.Annotations.Annotations | undefined -): { annotations: Schema.Annotations.Annotations } | undefined { - return annotations ? { annotations } : undefined -} - -function combineAnnotations( - a: Schema.Annotations.Annotations | undefined, - b: Schema.Annotations.Annotations | undefined -): { annotations: Schema.Annotations.Annotations } | undefined { - if (a === undefined) return makeAnnotations(b) - if (b === undefined) return makeAnnotations(a) - return { annotations: { ...a, ...b } } // TODO: better merge -} - -function collectStringChecks(js: JsonSchema.JsonSchema): Array> { - const checks: Array> = [] - if (typeof js.minLength === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMinLength", minLength: js.minLength } }) - } - if (typeof js.maxLength === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: js.maxLength } }) - } - if (typeof js.pattern === "string") { - checks.push({ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp(js.pattern) } }) - } - return checks -} - -function collectNumberChecks(js: JsonSchema.JsonSchema): Array> { - const checks: Array> = [] - if (typeof js.minimum === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isGreaterThanOrEqualTo", minimum: js.minimum } }) - } - if (typeof js.maximum === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isLessThanOrEqualTo", maximum: js.maximum } }) - } - if (typeof js.exclusiveMinimum === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isGreaterThan", exclusiveMinimum: js.exclusiveMinimum } }) - } - if (typeof js.exclusiveMaximum === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isLessThan", exclusiveMaximum: js.exclusiveMaximum } }) - } - if (typeof js.multipleOf === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMultipleOf", divisor: js.multipleOf } }) - } - return checks -} - -function collectArraysChecks(js: JsonSchema.JsonSchema): Array> { - const checks: Array> = [] - if (js.prefixItems === undefined) { - if (typeof js.minItems === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMinLength", minLength: js.minItems } }) - } - if (typeof js.maxItems === "number") { - checks.push({ _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: js.maxItems } }) - } - } - if (typeof js.uniqueItems === "boolean") { - checks.push({ _tag: "Filter", meta: { _tag: "isUnique" } }) - } - return checks -} - -const unknown: Unknown = { _tag: "Unknown" } -const json: Declaration = { - _tag: "Declaration", - annotations: { - expected: "JSON value", - generation: { - Type: "Schema.Json", - runtime: "Schema.Json" - }, - typeConstructor: { - _tag: "effect/Json" - } - }, - checks: [], - encodedSchema: unknown, - typeParameters: [] -} -const never: Never = { _tag: "Never" } -const null_: Null = { _tag: "Null" } -const string: String = { _tag: "String", checks: [] } -const boolean: Boolean = { _tag: "Boolean" } - -function collectAnnotations( - schema: JsonSchema.JsonSchema -): Schema.Annotations.Annotations | undefined { - const as: Record = {} - - if (typeof schema.title === "string") as.title = schema.title - if (typeof schema.description === "string") as.description = schema.description - if (schema.default !== undefined) as.default = schema.default - if (Array.isArray(schema.examples)) as.examples = schema.examples - if (typeof schema.readOnly === "boolean") as.readOnly = schema.readOnly - if (typeof schema.writeOnly === "boolean") as.writeOnly = schema.writeOnly - if (typeof schema.format === "string") as.format = schema.format - if (typeof schema.contentEncoding === "string") as.contentEncoding = schema.contentEncoding - if (typeof schema.contentMediaType === "string") as.contentMediaType = schema.contentMediaType - - return Rec.isEmptyRecord(as) ? undefined : as -} - -function isLiteralValue(value: unknown): value is SchemaAST.LiteralValue { - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" -} - -const stringKeys = ["minLength", "maxLength", "pattern", "format", "contentMediaType", "contentSchema"] -const numberKeys = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] -const objectKeys = [ - "properties", - "required", - "additionalProperties", - "patternProperties", - "propertyNames", - "minProperties", - "maxProperties" -] -const arrayKeys = ["items", "prefixItems", "additionalItems", "minItems", "maxItems", "uniqueItems"] - -function getType(js: JsonSchema.JsonSchema): JsonSchema.Type | undefined { - if (stringKeys.some((key) => js[key] !== undefined)) { - return "string" - } - if (numberKeys.some((key) => js[key] !== undefined)) { - return "number" - } - if (objectKeys.some((key) => js[key] !== undefined)) { - return "object" - } - if (arrayKeys.some((key) => js[key] !== undefined)) { - return "array" - } -} - -const types = ["null", "string", "number", "integer", "boolean", "object", "array"] - -function isType(type: unknown): type is JsonSchema.Type { - return typeof type === "string" && types.includes(type) -} - -/** @internal */ -export type TopologicalSort = { - /** - * The definitions that are not recursive. - * The definitions that depends on other definitions are placed after the definitions they depend on - */ - readonly nonRecursives: ReadonlyArray<{ - readonly $ref: string - readonly representation: Representation - }> - /** - * The recursive definitions (with no particular order). - */ - readonly recursives: { - readonly [$ref: string]: Representation - } -} - -/** @internal */ -export function topologicalSort(references: References): TopologicalSort { - const identifiers = Object.keys(references) - const identifierSet = new Set(identifiers) - - const collectRefs = (root: Representation): Set => { - const refs = new Set() - const visited = new WeakSet() - const stack: Array = [root] - - while (stack.length > 0) { - const r = stack.pop()! - if (visited.has(r)) continue - visited.add(r) - - if (r._tag === "Reference") { - if (identifierSet.has(r.$ref)) { - refs.add(r.$ref) - } - } - - // Push nested Representation schemas onto the stack - switch (r._tag) { - case "Declaration": - for (const typeParam of r.typeParameters) stack.push(typeParam) - stack.push(r.encodedSchema) - break - case "Suspend": - stack.push(r.thunk) - break - case "String": - if (r.contentSchema !== undefined) stack.push(r.contentSchema) - break - case "TemplateLiteral": - for (const part of r.parts) stack.push(part) - break - case "Arrays": - for (const element of r.elements) stack.push(element.type) - for (const rest of r.rest) stack.push(rest) - break - case "Objects": - for (const propertySignature of r.propertySignatures) stack.push(propertySignature.type) - for (const indexSignature of r.indexSignatures) { - stack.push(indexSignature.parameter) - stack.push(indexSignature.type) - } - break - case "Union": - for (const type of r.types) stack.push(type) - break - } - } - - return refs - } - - // identifier -> internal identifiers it depends on - const dependencies = new Map>( - identifiers.map((id) => [id, collectRefs(references[id])]) - ) - - // Mark only nodes that are part of cycles - const recursive = new Set() - const state = new Map() // 0 = new, 1 = visiting, 2 = done - const stack: Array = [] - const indexInStack = new Map() - - const dfs = (id: string): void => { - const s = state.get(id) ?? 0 - if (s === 1) { - const start = indexInStack.get(id) - if (start !== undefined) { - for (let i = start; i < stack.length; i++) { - recursive.add(stack[i]) - } - } - return - } - if (s === 2) return - - state.set(id, 1) - indexInStack.set(id, stack.length) - stack.push(id) - - for (const dep of dependencies.get(id) ?? []) { - dfs(dep) - } - - stack.pop() - indexInStack.delete(id) - state.set(id, 2) - } - - for (const id of identifiers) dfs(id) - - // Topologically sort the non-recursive nodes (ignoring edges to recursive nodes) - const inDegree = new Map() - const dependents = new Map>() // dep -> nodes that depend on it - - for (const id of identifiers) { - if (!recursive.has(id)) { - inDegree.set(id, 0) - dependents.set(id, new Set()) - } - } - - for (const [id, deps] of dependencies) { - if (recursive.has(id)) continue - for (const dep of deps) { - if (recursive.has(dep)) continue - inDegree.set(id, (inDegree.get(id) ?? 0) + 1) - dependents.get(dep)?.add(id) - } - } - - const queue: Array = [] - for (const [id, deg] of inDegree) { - if (deg === 0) queue.push(id) - } - - const nonRecursives: Array<{ readonly $ref: string; readonly representation: Representation }> = [] - for (let i = 0; i < queue.length; i++) { - const $ref = queue[i] - nonRecursives.push({ $ref, representation: references[$ref] }) - - for (const next of dependents.get($ref) ?? []) { - const deg = (inDegree.get(next) ?? 0) - 1 - inDegree.set(next, deg) - if (deg === 0) queue.push(next) - } - } - - const recursives: Record = {} - for (const $ref of recursive) { - recursives[$ref] = references[$ref] - } - - return { nonRecursives, recursives } +export function fromJsonSchemaMultiDocument( + document: JsonSchema.MultiDocument<"draft-2020-12">, + options?: FromJsonSchemaOptions +): SchemaMultiDocument { + return InternalFromJsonSchemaDocument.fromJsonSchemaMultiDocument(document, options) } diff --git a/packages/effect/src/internal/schema/annotations.ts b/packages/effect/src/internal/schema/annotations.ts index 174494ae603..25653b33a63 100644 --- a/packages/effect/src/internal/schema/annotations.ts +++ b/packages/effect/src/internal/schema/annotations.ts @@ -12,9 +12,15 @@ export function resolveAt(key: string) { return (ast: SchemaAST.AST): A | undefined => resolve(ast)?.[key] as A | undefined } +/** @internal */ +export const identifierFallbackKey = "~identifier" + /** @internal */ export const resolveIdentifier = resolveAt("identifier") +/** @internal */ +export const resolveIdentifierFallback = resolveAt(identifierFallbackKey) + /** @internal */ export const resolveTitle = resolveAt("title") @@ -35,3 +41,20 @@ export const getExpected = memoize((ast: SchemaAST.AST): string => { export function collectBrands(annotations: Schema.Annotations.Annotations | undefined): ReadonlyArray { return annotations !== undefined && Array.isArray(annotations.brands) ? annotations.brands : [] } + +/** @internal */ +export const annotationExcludedKeys = new Set([ + "~sentinels", + "~structural", + "representation", + "arbitrary", + "brands", + "toJsonSchema", + "toCode", + "toArbitrary", + "toEquivalence", + "toFormatter", + "toCodec", + "toCodecJson", + "toCodecIso" +]) diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts new file mode 100644 index 00000000000..a75343f9ef2 --- /dev/null +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -0,0 +1,969 @@ +import { unescapeToken } from "../../JsonPointer.ts" +import type * as JsonSchema from "../../JsonSchema.ts" +import { remainder } from "../../Number.ts" +import * as Schema from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import { errorWithPath } from "../errors.ts" +import * as InternalRecord from "../record.ts" +import { fromRepresentation, fromRepresentations } from "./fromRepresentation.ts" + +type Path = ReadonlyArray +type Representation = SchemaRepresentation.Representation +type Check = SchemaRepresentation.Check + +type ImportedJsonSchemaRepresentation = Extract + +const jsonSchemaTypes = new Set([ + "null", + "string", + "number", + "integer", + "boolean", + "object", + "array" +]) + +const jsonSchemaStringKeys = ["minLength", "maxLength", "pattern", "format", "contentMediaType", "contentSchema"] +const jsonSchemaNumberKeys = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] +const jsonSchemaObjectKeys = [ + "properties", + "required", + "additionalProperties", + "patternProperties", + "propertyNames", + "minProperties", + "maxProperties" +] +const jsonSchemaArrayKeys = ["items", "prefixItems", "additionalItems", "minItems", "maxItems", "uniqueItems"] + +function isImportedJsonSchemaType(input: unknown): input is JsonSchema.Type { + return typeof input === "string" && jsonSchemaTypes.has(input) +} + +function inferJsonSchemaType(schema: JsonSchema.JsonSchema): JsonSchema.Type | undefined { + if (jsonSchemaStringKeys.some((key) => schema[key] !== undefined)) return "string" + if (jsonSchemaNumberKeys.some((key) => schema[key] !== undefined)) return "number" + if (jsonSchemaObjectKeys.some((key) => schema[key] !== undefined)) return "object" + if (jsonSchemaArrayKeys.some((key) => schema[key] !== undefined)) return "array" +} + +function jsonSchemaReferenceKey($ref: string): string | undefined { + const token = $ref.slice($ref.lastIndexOf("/") + 1) + return token.length === 0 ? undefined : unescapeToken(token) +} + +function jsonSchemaFilter( + id: string, + payload: Schema.Json, + schemas?: ReadonlyArray +): Check { + return { + _tag: "Filter", + aborted: false, + representation: { + id, + payload, + ...(schemas === undefined ? undefined : { schemas }) + } + } +} + +function jsonSchemaAnnotations( + schema: JsonSchema.JsonSchema +): Schema.Annotations.Annotations | undefined { + const annotations: Record = {} + if (typeof schema.title === "string") annotations.title = schema.title + if (typeof schema.description === "string") annotations.description = schema.description + if (Object.hasOwn(schema, "default")) annotations.default = schema.default as Schema.Json + if (Array.isArray(schema.examples)) annotations.examples = schema.examples as ReadonlyArray + if (typeof schema.readOnly === "boolean") annotations.readOnly = schema.readOnly + if (typeof schema.writeOnly === "boolean") annotations.writeOnly = schema.writeOnly + if (typeof schema.format === "string") annotations.format = schema.format + if (typeof schema.contentEncoding === "string") annotations.contentEncoding = schema.contentEncoding + if (typeof schema.contentMediaType === "string") annotations.contentMediaType = schema.contentMediaType + if (SchemaAST.isJson(schema.contentSchema)) annotations.contentSchema = schema.contentSchema + return Object.keys(annotations).length === 0 ? undefined : annotations +} + +function annotateJsonSchemaRepresentation( + representation: ImportedJsonSchemaRepresentation, + annotations: Schema.Annotations.Annotations | undefined +): ImportedJsonSchemaRepresentation { + if (annotations === undefined) return representation + if (representation._tag === "Reference") { + return { + _tag: "Suspend", + annotations, + checks: [], + thunk: representation + } + } + return { + ...representation, + annotations: { + ...representation.annotations, + ...annotations + } + } +} + +function jsonDeclaration( + annotations: Schema.Annotations.Annotations | undefined +): Representation { + return { + _tag: "Declaration", + representation: { + id: "effect/schema/Json", + payload: null + }, + annotations: { + ...annotations, + expected: "JSON value" + }, + checks: [], + typeParameters: [] + } +} + +function unknownJsonSchemas(representation: Representation): Representation { + switch (representation._tag) { + case "Unknown": + return jsonDeclaration(representation.annotations) + case "Suspend": + return { ...representation, thunk: unknownJsonSchemas(representation.thunk) } + case "Arrays": + return { + ...representation, + elements: representation.elements.map((element) => ({ + ...element, + type: unknownJsonSchemas(element.type) + })), + rest: representation.rest.map(unknownJsonSchemas) + } + case "Objects": + return { + ...representation, + propertySignatures: representation.propertySignatures.map((property) => ({ + ...property, + type: unknownJsonSchemas(property.type) + })), + indexSignatures: representation.indexSignatures.map((indexSignature) => ({ + parameter: unknownJsonSchemas(indexSignature.parameter), + type: unknownJsonSchemas(indexSignature.type) + })), + checks: representation.checks.map(unknownJsonSchemaCheck) + } + case "Union": + return { ...representation, types: representation.types.map(unknownJsonSchemas) } + default: + return representation + } +} + +function unknownJsonSchemaCheck(check: Check): Check { + const representation = check.representation + const schemas = representation?.schemas + if (representation === undefined || schemas === undefined) { + return check + } + return { + ...check, + representation: { + ...representation, + schemas: schemas.map(unknownJsonSchemas) + } + } +} + +function translateJsonSchemaMultiDocument( + document: JsonSchema.MultiDocument<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions, + singleRoot = false +): SchemaRepresentation.MultiDocument { + const definitionCache = new Map() + const definitionsInProgress = new Set() + + function translateDefinition(key: string, path: Path): ImportedJsonSchemaRepresentation { + const cached = definitionCache.get(key) + if (cached !== undefined) return cached + if (!Object.hasOwn(document.definitions, key) || definitionsInProgress.has(key)) { + throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) + } + definitionsInProgress.add(key) + const representation = recur(document.definitions[key], ["definitions", key]) + definitionsInProgress.delete(key) + definitionCache.set(key, representation) + return representation + } + + function resolveReference( + reference: SchemaRepresentation.Reference, + path: Path, + seen: ReadonlySet = new Set() + ): ImportedJsonSchemaRepresentation { + if (seen.has(reference.$ref)) { + throw errorWithPath(`Invalid reference ${reference.$ref}`, [...path, "$ref"]) + } + const nextSeen = new Set(seen).add(reference.$ref) + const representation = translateDefinition(reference.$ref, path) + if (representation._tag === "Reference") { + return resolveReference(representation, path, nextSeen) + } + if (representation._tag === "Suspend" && representation.thunk._tag === "Reference") { + return annotateJsonSchemaRepresentation( + resolveReference(representation.thunk, path, nextSeen), + representation.annotations + ) + } + return representation + } + + function annotationsOf( + representation: ImportedJsonSchemaRepresentation + ): Schema.Annotations.Annotations | undefined { + return representation._tag === "Reference" ? undefined : representation.annotations + } + + function mergeAnnotations( + left: Schema.Annotations.Annotations | undefined, + right: Schema.Annotations.Annotations | undefined + ): Schema.Annotations.Annotations | undefined { + if (left === undefined) return right + if (right === undefined) return left + return { ...left, ...right } + } + + function combinedAnnotations( + representation: ImportedJsonSchemaRepresentation, + left: ImportedJsonSchemaRepresentation, + right: ImportedJsonSchemaRepresentation + ): ImportedJsonSchemaRepresentation { + return annotateJsonSchemaRepresentation( + representation, + mergeAnnotations(annotationsOf(left), annotationsOf(right)) + ) + } + + function asChecks( + checks: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined + ): ReadonlyArray | undefined { + if (checks.length === 0) return undefined + if (annotations === undefined) return checks + if (checks.length === 1 && checks[0].annotations === undefined) { + return [{ + ...checks[0], + annotations + }] + } + return [{ + _tag: "FilterGroup", + checks: checks as [Check, ...Array], + annotations + }] + } + + function combineChecks( + left: ReadonlyArray, + right: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined + ): ReadonlyArray | undefined { + const checks = asChecks(right, annotations) + return checks === undefined ? undefined : [...left, ...checks] + } + + function checkId(check: Check): string | undefined { + return check._tag === "Filter" ? check.representation?.id : undefined + } + + function combineNumberChecks( + left: ReadonlyArray, + right: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined + ): ReadonlyArray | undefined { + if (left.some((check) => checkId(check) === "effect/schema/isFinite")) { + right = right.filter((check) => checkId(check) !== "effect/schema/isFinite") + } + if (left.some((check) => checkId(check) === "effect/schema/isInt")) { + right = right.filter((check) => checkId(check) !== "effect/schema/isInt") + } + return combineChecks(left, right, annotations) + } + + function combineArrayChecks( + left: ReadonlyArray, + right: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined + ): ReadonlyArray | undefined { + if (left.some((check) => checkId(check) === "effect/schema/isUnique")) { + right = right.filter((check) => checkId(check) !== "effect/schema/isUnique") + } + return combineChecks(left, right, annotations) + } + + function satisfiesPrimitiveCheck(check: Check, value: string | number): boolean | undefined { + if (check._tag === "FilterGroup") { + return check.checks.every((check) => satisfiesPrimitiveCheck(check, value)) + } + const representation = check.representation! + const payload = representation.payload as Record + switch (representation.id) { + case "effect/schema/isMinLength": + return (value as string).length >= payload.minLength + case "effect/schema/isMaxLength": + return (value as string).length <= payload.maxLength + case "effect/schema/isPattern": + return new RegExp(payload.source as string, payload.flags as string).test(value as string) + case "effect/schema/isFinite": + return globalThis.Number.isFinite(value as number) + case "effect/schema/isInt": + return globalThis.Number.isSafeInteger(value as number) + case "effect/schema/isMultipleOf": + return remainder(value as number, payload.divisor) === 0 + case "effect/schema/isGreaterThan": + return (value as number) > payload.exclusiveMinimum + case "effect/schema/isGreaterThanOrEqualTo": + return (value as number) >= payload.minimum + case "effect/schema/isLessThan": + return (value as number) < payload.exclusiveMaximum + case "effect/schema/isLessThanOrEqualTo": + return (value as number) <= payload.maximum + } + } + + function satisfiesLiteral( + representation: + | SchemaRepresentation.String + | SchemaRepresentation.Number, + literal: SchemaRepresentation.Literal + ): boolean { + const value = literal.literal + if (representation._tag === "String" ? typeof value !== "string" : typeof value !== "number") { + return false + } + return representation.checks.every((check) => satisfiesPrimitiveCheck(check, value as string | number)) + } + + function combineArrays( + left: SchemaRepresentation.Arrays, + right: SchemaRepresentation.Arrays, + path: Path + ): Pick | undefined { + const elements: Array = [] + const length = Math.max(left.elements.length, right.elements.length) + for (let index = 0; index < length; index++) { + const leftElement = left.elements[index] + const rightElement = right.elements[index] + const isOptional = leftElement?.isOptional !== false && rightElement?.isOptional !== false + const leftType = leftElement?.type ?? left.rest[0] + const rightType = rightElement?.type ?? right.rest[0] + if (leftType === undefined || rightType === undefined) { + return isOptional ? { elements, rest: [] } : undefined + } + const type = combine( + leftType as ImportedJsonSchemaRepresentation, + rightType as ImportedJsonSchemaRepresentation, + [...path, "elements", index, "type"] + ) + if (type._tag === "Never") { + return isOptional ? { elements, rest: [] } : undefined + } + elements.push({ + isOptional, + type + }) + } + + const leftRest = left.rest[0] + const rightRest = right.rest[0] + if (leftRest === undefined || rightRest === undefined) { + return { elements, rest: [] } + } + const rest = combine( + leftRest as ImportedJsonSchemaRepresentation, + rightRest as ImportedJsonSchemaRepresentation, + [...path, "rest", 0] + ) + return { elements, rest: rest._tag === "Never" ? [] : [rest] } + } + + function combineProperties( + left: ReadonlyArray, + right: ReadonlyArray, + path: Path + ): Array { + const rightByName = new Map(right.map((property) => [property.name, property])) + const names = new Set() + const properties = left.map((property) => { + names.add(property.name) + const other = rightByName.get(property.name) + if (other === undefined) return property + return { + name: property.name, + type: combine( + property.type as ImportedJsonSchemaRepresentation, + other.type as ImportedJsonSchemaRepresentation, + [...path, "properties", property.name as string] + ), + isOptional: property.isOptional && other.isOptional, + isMutable: false + } + }) + for (const property of right) { + if (!names.has(property.name)) properties.push(property) + } + return properties + } + + function isUnconstrainedString(representation: Representation): boolean { + return representation._tag === "String" && representation.checks.length === 0 && + representation.annotations === undefined + } + + function combineIndexSignatures( + left: ReadonlyArray, + right: ReadonlyArray, + path: Path + ): Array { + if (left.length === 0 || right.length === 0) return [] + const signatures = [...left] + for (const signature of right) { + if (isUnconstrainedString(signature.parameter)) { + const index = signatures.findIndex((candidate) => isUnconstrainedString(candidate.parameter)) + if (index !== -1) { + signatures[index] = { + parameter: signatures[index].parameter, + type: combine( + signatures[index].type as ImportedJsonSchemaRepresentation, + signature.type as ImportedJsonSchemaRepresentation, + [...path, "indexSignatures", index, "type"] + ) + } + } else { + signatures.push(signature) + } + } else { + signatures.push(signature) + } + } + return signatures + } + + function combine( + left: ImportedJsonSchemaRepresentation, + right: ImportedJsonSchemaRepresentation, + path: Path + ): ImportedJsonSchemaRepresentation { + if (left._tag === "Never") return left + if (right._tag === "Never") return right + if (left._tag === "Unknown") return combinedAnnotations(right, left, right) + if (right._tag === "Unknown") return combinedAnnotations(left, left, right) + if (left._tag === "Reference") return combine(resolveReference(left, path), right, path) + if (right._tag === "Reference") return combine(left, resolveReference(right, path), path) + if (left._tag === "Suspend") { + return annotateJsonSchemaRepresentation( + combine(left.thunk as ImportedJsonSchemaRepresentation, right, path), + left.annotations + ) + } + if (right._tag === "Suspend") { + return annotateJsonSchemaRepresentation( + combine(left, right.thunk as ImportedJsonSchemaRepresentation, path), + right.annotations + ) + } + if (left._tag === "Union") { + const types = left.types + .map((type, index) => combine(type as ImportedJsonSchemaRepresentation, right, [...path, "types", index])) + .filter((type) => type._tag !== "Never") + if (types.length === 0) return { _tag: "Never", checks: [] } + return annotateJsonSchemaRepresentation({ + _tag: "Union", + types, + mode: left.mode, + checks: left.checks + }, left.annotations) + } + if (right._tag === "Union") return combine(right, left, path) + + switch (left._tag) { + case "Null": + return right._tag === "Null" + ? combinedAnnotations({ _tag: "Null", checks: [...left.checks, ...right.checks] }, left, right) + : { _tag: "Never", checks: [] } + case "String": + if (right._tag === "Literal") { + return satisfiesLiteral(left, right) + ? combinedAnnotations( + { + _tag: "Literal", + literal: right.literal, + checks: right.checks + }, + left, + right + ) + : { _tag: "Never", checks: [] } + } + if (right._tag !== "String") return { _tag: "Never", checks: [] } + const stringChecks = combineChecks(left.checks, right.checks, right.annotations) + return annotateJsonSchemaRepresentation( + { + _tag: "String", + checks: stringChecks ?? left.checks + }, + mergeAnnotations(left.annotations, stringChecks === undefined ? right.annotations : undefined) + ) + case "Number": + if (right._tag === "Literal") { + return satisfiesLiteral(left, right) + ? combinedAnnotations( + { + _tag: "Literal", + literal: right.literal, + checks: right.checks + }, + left, + right + ) + : { _tag: "Never", checks: [] } + } + if (right._tag !== "Number") return { _tag: "Never", checks: [] } + const numberChecks = combineNumberChecks(left.checks, right.checks, right.annotations) + return annotateJsonSchemaRepresentation( + { + _tag: "Number", + checks: numberChecks ?? left.checks + }, + mergeAnnotations(left.annotations, numberChecks === undefined ? right.annotations : undefined) + ) + case "Boolean": + if (right._tag === "Literal") { + return typeof right.literal === "boolean" + ? combinedAnnotations( + { + _tag: "Literal", + literal: right.literal, + checks: right.checks + }, + left, + right + ) + : { _tag: "Never", checks: [] } + } + return right._tag === "Boolean" + ? combinedAnnotations( + { + _tag: "Boolean", + checks: [...left.checks, ...right.checks] + }, + left, + right + ) + : { _tag: "Never", checks: [] } + case "Literal": + if (right._tag === "Literal") { + return left.literal === right.literal + ? combinedAnnotations( + { + _tag: "Literal", + literal: left.literal, + checks: [...left.checks, ...right.checks] + }, + left, + right + ) + : { _tag: "Never", checks: [] } + } + if ( + (right._tag === "String" || right._tag === "Number") && satisfiesLiteral(right, left) || + right._tag === "Boolean" && typeof left.literal === "boolean" + ) { + return combinedAnnotations( + { + _tag: "Literal", + literal: left.literal, + checks: left.checks + }, + left, + right + ) + } + return { _tag: "Never", checks: [] } + case "Arrays": { + if (right._tag !== "Arrays") return { _tag: "Never", checks: [] } + const arrays = combineArrays(left, right, path) + if (arrays === undefined) return { _tag: "Never", checks: [] } + const arrayChecks = combineArrayChecks(left.checks, right.checks, right.annotations) + return annotateJsonSchemaRepresentation( + { + _tag: "Arrays", + elements: arrays.elements, + rest: arrays.rest, + checks: arrayChecks ?? left.checks + }, + mergeAnnotations(left.annotations, arrayChecks === undefined ? right.annotations : undefined) + ) + } + case "Objects": { + if (right._tag !== "Objects") return { _tag: "Never", checks: [] } + const objectChecks = combineChecks(left.checks, right.checks, right.annotations) + return annotateJsonSchemaRepresentation( + { + _tag: "Objects", + propertySignatures: combineProperties(left.propertySignatures, right.propertySignatures, path), + indexSignatures: combineIndexSignatures(left.indexSignatures, right.indexSignatures, path), + checks: objectChecks ?? left.checks + }, + mergeAnnotations(left.annotations, objectChecks === undefined ? right.annotations : undefined) + ) + } + } + } + + function enter(input: unknown): JsonSchema.JsonSchema | undefined { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return undefined + } + const schema = input as JsonSchema.JsonSchema + return options?.onEnter === undefined ? schema : options.onEnter(schema) + } + + function recur(input: unknown, path: Path): ImportedJsonSchemaRepresentation { + if (input === false) { + return { _tag: "Never", checks: [] } + } + const schema = enter(input) + if (schema === undefined) { + return { _tag: "Unknown", checks: [] } + } + + let representation = on(schema, path) + const annotations = jsonSchemaAnnotations(schema) + if (annotations !== undefined && representation._tag === "Reference") { + resolveReference(representation, path) + } + representation = annotateJsonSchemaRepresentation(representation, annotations) + + if (Array.isArray(schema.allOf)) { + for (let index = 0; index < schema.allOf.length; index++) { + representation = combine( + representation, + recur(schema.allOf[index], [...path, "allOf", index]), + [...path, "allOf", index] + ) + } + } + + for (const mode of ["anyOf", "oneOf"] as const) { + const members = schema[mode] + if (Array.isArray(members)) { + const union: ImportedJsonSchemaRepresentation = { + _tag: "Union", + types: members.map((member, index) => recur(member, [...path, mode, index])), + mode, + checks: [] + } + representation = combine(union, representation, [...path, mode]) + } + } + return representation + } + + function on(schema: JsonSchema.JsonSchema, path: Path): ImportedJsonSchemaRepresentation { + if (typeof schema.$ref === "string") { + const $ref = jsonSchemaReferenceKey(schema.$ref) + if ($ref !== undefined) { + return { _tag: "Reference", $ref } + } + } + if (Object.hasOwn(schema, "const")) { + if (schema.const === null) { + return { _tag: "Null", checks: [] } + } + if (typeof schema.const === "string" || typeof schema.const === "number" || typeof schema.const === "boolean") { + return { _tag: "Literal", literal: schema.const, checks: [] } + } + } + if (Array.isArray(schema.enum)) { + const types: Array = [] + for (let index = 0; index < schema.enum.length; index++) { + const value = schema.enum[index] + if (value === null) { + types.push({ _tag: "Null", checks: [] }) + } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + types.push({ _tag: "Literal", literal: value, checks: [] }) + } else { + types.push(recur(value, [...path, "enum", index])) + } + } + if (types.length === 1) { + return types[0] + } + return { _tag: "Union", types, mode: "anyOf", checks: [] } + } + + if (Array.isArray(schema.type) && schema.type.every(isImportedJsonSchemaType)) { + return { + _tag: "Union", + types: schema.type.map((type) => on({ ...schema, type }, path)), + mode: "anyOf", + checks: [] + } + } + + const type = isImportedJsonSchemaType(schema.type) ? schema.type : inferJsonSchemaType(schema) + switch (type) { + case "null": + return { _tag: "Null", checks: [] } + case "string": + return { + _tag: "String", + checks: collectStringChecks(schema) + } + case "number": + case "integer": + return { + _tag: "Number", + checks: [ + jsonSchemaFilter(type === "number" ? "effect/schema/isFinite" : "effect/schema/isInt", null), + ...collectNumberChecks(schema) + ] + } + case "boolean": + return { _tag: "Boolean", checks: [] } + case "array": { + const minItems = typeof schema.minItems === "number" ? schema.minItems : 0 + const elements = Array.isArray(schema.prefixItems) + ? schema.prefixItems.map((element, index) => ({ + isOptional: index + 1 > minItems, + type: recur(element, [...path, "prefixItems", index]) + })) + : [] + const rest = schema.items !== undefined + ? [recur(schema.items, [...path, "items"])] + : schema.prefixItems !== undefined && typeof schema.maxItems === "number" + ? [] + : [{ _tag: "Unknown", checks: [] } as ImportedJsonSchemaRepresentation] + return { + _tag: "Arrays", + elements, + rest, + checks: collectArrayChecks(schema) + } + } + case "object": + return { + _tag: "Objects", + propertySignatures: collectProperties(schema, path), + indexSignatures: collectIndexSignatures(schema, path), + checks: collectObjectChecks(schema, path) + } + default: + return { _tag: "Unknown", checks: [] } + } + } + + function collectStringChecks(schema: JsonSchema.JsonSchema): Array { + const checks: Array = [] + if (typeof schema.minLength === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMinLength", { minLength: schema.minLength })) + } + if (typeof schema.maxLength === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMaxLength", { maxLength: schema.maxLength })) + } + if (typeof schema.pattern === "string") { + checks.push(jsonSchemaFilter("effect/schema/isPattern", { source: schema.pattern, flags: "" })) + } + return checks + } + + function collectNumberChecks(schema: JsonSchema.JsonSchema): Array { + const checks: Array = [] + if (typeof schema.minimum === "number") { + checks.push(jsonSchemaFilter("effect/schema/isGreaterThanOrEqualTo", { minimum: schema.minimum })) + } + if (typeof schema.maximum === "number") { + checks.push(jsonSchemaFilter("effect/schema/isLessThanOrEqualTo", { maximum: schema.maximum })) + } + if (typeof schema.exclusiveMinimum === "number") { + checks.push(jsonSchemaFilter("effect/schema/isGreaterThan", { exclusiveMinimum: schema.exclusiveMinimum })) + } + if (typeof schema.exclusiveMaximum === "number") { + checks.push(jsonSchemaFilter("effect/schema/isLessThan", { exclusiveMaximum: schema.exclusiveMaximum })) + } + if (typeof schema.multipleOf === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMultipleOf", { divisor: schema.multipleOf })) + } + return checks + } + + function collectArrayChecks(schema: JsonSchema.JsonSchema): Array { + const checks: Array = [] + if (schema.prefixItems === undefined) { + if (typeof schema.minItems === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMinLength", { minLength: schema.minItems })) + } + if (typeof schema.maxItems === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMaxLength", { maxLength: schema.maxItems })) + } + } + if (typeof schema.uniqueItems === "boolean") { + checks.push(jsonSchemaFilter("effect/schema/isUnique", null)) + } + return checks + } + + function collectProperties( + schema: JsonSchema.JsonSchema, + path: Path + ): Array { + const properties = + typeof schema.properties === "object" && schema.properties !== null && !Array.isArray(schema.properties) + ? schema.properties as Record + : {} + const required = Array.isArray(schema.required) + ? schema.required.filter((key): key is string => typeof key === "string") + : [] + const keys = new Set([...Object.keys(properties), ...required]) + return Array.from(keys, (name) => ({ + name, + type: recur(properties[name], [...path, "properties", name]), + isOptional: !required.includes(name), + isMutable: false + })) + } + + function collectIndexSignatures( + schema: JsonSchema.JsonSchema, + path: Path + ): Array { + const signatures: Array = [] + if ( + typeof schema.patternProperties === "object" && + schema.patternProperties !== null && + !Array.isArray(schema.patternProperties) + ) { + for (const [pattern, value] of Object.entries(schema.patternProperties)) { + signatures.push({ + parameter: { + _tag: "String", + checks: [jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: "" })] + }, + type: recur(value, [...path, "patternProperties", pattern]) + }) + } + } + if (schema.additionalProperties === undefined || schema.additionalProperties === true) { + signatures.push({ + parameter: { _tag: "String", checks: [] }, + type: { _tag: "Unknown", checks: [] } + }) + } else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) { + signatures.push({ + parameter: { _tag: "String", checks: [] }, + type: recur(schema.additionalProperties, [...path, "additionalProperties"]) + }) + } + return signatures + } + + function collectObjectChecks( + schema: JsonSchema.JsonSchema, + path: Path + ): Array { + const checks: Array = [] + if (typeof schema.minProperties === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMinProperties", { minProperties: schema.minProperties })) + } + if (typeof schema.maxProperties === "number") { + checks.push(jsonSchemaFilter("effect/schema/isMaxProperties", { maxProperties: schema.maxProperties })) + } + if (schema.propertyNames !== undefined) { + checks.push(jsonSchemaFilter( + "effect/schema/isPropertyNames", + null, + [recur(schema.propertyNames, [...path, "propertyNames"])] + )) + } + return checks + } + + const references: Record = {} + for (const key of Object.keys(document.definitions)) { + InternalRecord.set(references, key, unknownJsonSchemas(translateDefinition(key, ["definitions", key]))) + } + const representations = document.schemas.map((schema, index) => + unknownJsonSchemas(recur(schema, singleRoot ? ["schema"] : ["schemas", index])) + ) as [Representation, ...Array] + return { representations, references } +} + +/** @internal */ +function toRepresentation( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions +): SchemaRepresentation.Document { + const translated = translateJsonSchemaMultiDocument( + { + dialect: document.dialect, + schemas: [document.schema], + definitions: document.definitions + }, + options, + true + ) + return { + representation: translated.representations[0], + references: translated.references + } +} + +const jsonSchemaRevivers: ReadonlyArray = [ + Schema.JsonReviver, + Schema.isPatternReviver, + Schema.isFiniteReviver, + Schema.isGreaterThanReviver, + Schema.isGreaterThanOrEqualToReviver, + Schema.isLessThanReviver, + Schema.isLessThanOrEqualToReviver, + Schema.isMultipleOfReviver, + Schema.isIntReviver, + Schema.isMinLengthReviver, + Schema.isMaxLengthReviver, + Schema.isMinPropertiesReviver, + Schema.isMaxPropertiesReviver, + Schema.isPropertyNamesReviver, + Schema.isUniqueReviver +] + +/** @internal */ +export function fromJsonSchemaDocument( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions +): Schema.Top { + return fromRepresentation(toRepresentation(document, options), jsonSchemaRevivers) +} + +/** @internal */ +export function fromJsonSchemaMultiDocument( + document: JsonSchema.MultiDocument<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions +): SchemaRepresentation.SchemaMultiDocument { + return fromRepresentations(translateJsonSchemaMultiDocument(document, options), jsonSchemaRevivers) +} diff --git a/packages/effect/src/internal/schema/fromRepresentation.ts b/packages/effect/src/internal/schema/fromRepresentation.ts new file mode 100644 index 00000000000..0568cf6d3b9 --- /dev/null +++ b/packages/effect/src/internal/schema/fromRepresentation.ts @@ -0,0 +1,316 @@ +import * as Arr from "../../Array.ts" +import * as Result from "../../Result.ts" +import * as Schema from "../../Schema.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import { errorWithPath } from "../errors.ts" +import * as InternalRecord from "../record.ts" + +type Path = ReadonlyArray + +/** @internal */ +export function fromRepresentations( + document: SchemaRepresentation.MultiDocument, + revivers: ReadonlyArray +): SchemaRepresentation.SchemaMultiDocument { + return revivePersisted(document.representations, document.references, makeReviverMap(revivers), false) +} + +class ReferenceSlot { + body: Schema.Top | undefined + readonly wrapper: Schema.Top + + constructor(key: string) { + this.wrapper = Schema.suspend((): Schema.Top => { + if (this.body === undefined) { + throw new Error(`Reference ${key} was evaluated before it was resolved`) + } + return this.body + }).annotate({ identifier: key }) + } +} + +function makeReviverMap( + revivers: ReadonlyArray +): Map { + const out = new Map() + + for (let index = 0; index < revivers.length; index++) { + const reviver = revivers[index] + if (out.has(reviver.id)) { + throw errorWithPath(`Duplicate reviver for ${reviver.id}`, ["revivers", index, "id"]) + } + out.set(reviver.id, reviver) + } + + return out +} + +function revivePersisted( + representations: readonly [ + SchemaRepresentation.Representation, + ...Array + ], + references: SchemaRepresentation.References, + reviverMap: ReadonlyMap, + singleRoot: boolean +): SchemaRepresentation.SchemaMultiDocument { + const slots = new Map() + const referenceKeys = Object.keys(references) + + for (const key of referenceKeys) { + slots.set(key, new ReferenceSlot(key)) + } + + function resolveReviver( + representation: SchemaRepresentation.RepresentationAnnotation, + path: Path + ): R { + const reviver = reviverMap.get(representation.id) + if (reviver === undefined) { + throw errorWithPath(`Missing reviver for ${representation.id}`, path) + } + return reviver as R + } + + function decodePayload( + representation: SchemaRepresentation.RepresentationAnnotation, + reviver: SchemaRepresentation.AnyReviver, + path: Path + ): any { + const decoded = Schema.decodeUnknownResult(reviver.payloadSchema)(representation.payload) + if (Result.isFailure(decoded)) { + throw errorWithPath(`Invalid representation payload for ${representation.id}`, path) + } + return decoded.success + } + + function reviveSchemas( + representations: ReadonlyArray, + path: Path + ): ReadonlyArray { + return representations.map((representation, index) => recur(representation, [...path, index])) + } + + function reviveDeclaration( + declaration: SchemaRepresentation.Declaration, + path: Path + ): Schema.Top { + const representationPath = [...path, "representation"] + const representation = declaration.representation + if (representation === undefined) { + throw errorWithPath("Missing representation annotation", representationPath) + } + const reviver = resolveReviver>(representation, representationPath) + const payload = decodePayload(representation, reviver, [...representationPath, "payload"]) + const typeParameters = reviveSchemas(declaration.typeParameters, [...path, "typeParameters"]) + const schema = reviver.revive({ payload, typeParameters, annotations: declaration.annotations }) + return appendChecks(schema, declaration.checks, [...path, "checks"]) + } + + function reviveFilter( + filter: SchemaRepresentation.Filter, + path: Path + ): SchemaAST.Filter { + const representationPath = [...path, "representation"] + const representation = filter.representation + if (representation === undefined) { + throw errorWithPath("Missing representation annotation", representationPath) + } + const reviver = resolveReviver>(representation, representationPath) + const payload = decodePayload(representation, reviver, [...representationPath, "payload"]) + const schemas = reviveSchemas(representation.schemas ?? [], [...representationPath, "schemas"]) + const check = reviver.revive({ payload, schemas, annotations: filter.annotations }) + return filter.aborted ? check.abort() : check + } + + function reviveFilterGroup( + group: SchemaRepresentation.FilterGroup, + path: Path + ): SchemaAST.FilterGroup { + const representationPath = [...path, "representation"] + const representation = group.representation + if (representation === undefined) { + const checks = group.checks.map((check, index) => reviveCheck(check, [...path, "checks", index])) + return Schema.makeFilterGroup( + checks as [SchemaAST.Check, ...Array>], + group.annotations as Schema.Annotations.Filter | undefined + ) + } + + const reviver = resolveReviver>(representation, representationPath) + const payload = decodePayload(representation, reviver, [...representationPath, "payload"]) + const schemas = reviveSchemas(representation.schemas ?? [], [...representationPath, "schemas"]) + return reviver.revive({ payload, schemas, annotations: group.annotations }) + } + + function reviveCheck( + check: SchemaRepresentation.Check, + path: Path + ): SchemaAST.Check { + return check._tag === "Filter" + ? reviveFilter(check, path) + : reviveFilterGroup(check, path) + } + + function appendChecks( + schema: S, + checks: ReadonlyArray, + path: Path + ): S["Rebuild"] { + const revived = checks.map((check, index) => reviveCheck(check, [...path, index])) + return Arr.isArrayNonEmpty(revived) ? schema.check(...revived) : schema as S["Rebuild"] + } + + function annotateNode( + schema: Schema.Top, + annotations: Schema.Annotations.Annotations | undefined + ): Schema.Top { + return annotations === undefined ? schema : schema.annotate(annotations) + } + + function finishStructural( + schema: Schema.Top, + representation: Exclude, + path: Path + ): Schema.Top { + return appendChecks( + annotateNode(schema, representation.annotations), + representation.checks, + [...path, "checks"] + ) + } + + function recur( + representation: SchemaRepresentation.Representation, + path: Path + ): Schema.Top { + switch (representation._tag) { + case "Reference": { + const slot = slots.get(representation.$ref) + if (slot === undefined) { + throw errorWithPath(`Invalid reference ${representation.$ref}`, [...path, "$ref"]) + } + return slot.wrapper + } + case "Declaration": + return reviveDeclaration(representation, path) + case "Suspend": { + const thunk = recur(representation.thunk, [...path, "thunk"]) + return annotateNode(Schema.suspend(() => thunk), representation.annotations) + } + case "Null": + return finishStructural(Schema.Null, representation, path) + case "Undefined": + return finishStructural(Schema.Undefined, representation, path) + case "Void": + return finishStructural(Schema.Void, representation, path) + case "Never": + return finishStructural(Schema.Never, representation, path) + case "Unknown": + return finishStructural(Schema.Unknown, representation, path) + case "Any": + return finishStructural(Schema.Any, representation, path) + case "String": + return finishStructural(Schema.String, representation, path) + case "Number": + return finishStructural(Schema.Number, representation, path) + case "Boolean": + return finishStructural(Schema.Boolean, representation, path) + case "BigInt": + return finishStructural(Schema.BigInt, representation, path) + case "Symbol": + return finishStructural(Schema.Symbol, representation, path) + case "Literal": + return finishStructural(Schema.Literal(representation.literal), representation, path) + case "UniqueSymbol": + return finishStructural(Schema.UniqueSymbol(representation.symbol), representation, path) + case "ObjectKeyword": + return finishStructural(Schema.ObjectKeyword, representation, path) + case "Enum": + return finishStructural(Schema.Enum(Object.fromEntries(representation.enums)), representation, path) + case "TemplateLiteral": { + const parts = representation.parts.map((part, index) => recur(part, [...path, "parts", index])) + return finishStructural( + Schema.TemplateLiteral(parts as unknown as Schema.TemplateLiteral.Parts), + representation, + path + ) + } + case "Arrays": { + const elements = representation.elements.map((element, index) => { + let schema = recur(element.type, [...path, "elements", index, "type"]) + if (element.annotations !== undefined) { + schema = schema.annotateKey(element.annotations as Schema.Annotations.Key) + } + return element.isOptional ? Schema.optionalKey(schema) : schema + }) + const rest = representation.rest.map((item, index) => recur(item, [...path, "rest", index])) + const schema = Arr.isArrayNonEmpty(rest) + ? elements.length === 0 && rest.length === 1 + ? Schema.Array(rest[0]) + : Schema.TupleWithRest(Schema.Tuple(elements), rest) + : Schema.Tuple(elements) + return finishStructural(schema, representation, path) + } + case "Objects": { + const fields: Record = {} + for (let index = 0; index < representation.propertySignatures.length; index++) { + const property = representation.propertySignatures[index] + let schema = recur(property.type, [...path, "propertySignatures", index, "type"]) + if (property.annotations !== undefined) { + schema = schema.annotateKey(property.annotations as Schema.Annotations.Key) + } + if (property.isOptional) { + schema = Schema.optionalKey(schema) + } + if (property.isMutable) { + schema = Schema.mutableKey(schema) + } + InternalRecord.set(fields, property.name, schema) + } + const records = representation.indexSignatures.map((indexSignature, index) => + Schema.Record( + recur(indexSignature.parameter, [...path, "indexSignatures", index, "parameter"]) as Schema.Record.Key, + recur(indexSignature.type, [...path, "indexSignatures", index, "type"]) + ) + ) + const schema = Arr.isArrayNonEmpty(records) + ? representation.propertySignatures.length === 0 && records.length === 1 + ? records[0] + : Schema.StructWithRest(Schema.Struct(fields), records) + : Schema.Struct(fields) + return finishStructural(schema, representation, path) + } + case "Union": { + const members = representation.types.map((member, index) => recur(member, [...path, "types", index])) + return finishStructural(Schema.Union(members, { mode: representation.mode }), representation, path) + } + } + } + + const definitions: Record = {} + for (const key of referenceKeys) { + const slot = slots.get(key)! + slot.body = recur(references[key], ["references", key]) + InternalRecord.set(definitions, key, slot.wrapper) + } + + const schemas = representations.map((representation, index) => + recur(representation, singleRoot ? ["representation"] : ["representations", index]) + ) as [Schema.Top, ...Array] + return { schemas, definitions } +} + +/** @internal */ +export function fromRepresentation( + document: SchemaRepresentation.Document, + revivers: ReadonlyArray +): Schema.Top { + return revivePersisted( + [document.representation], + document.references, + makeReviverMap(revivers), + true + ).schemas[0] +} diff --git a/packages/effect/src/internal/schema/representation.ts b/packages/effect/src/internal/schema/representation.ts deleted file mode 100644 index b63ea21509b..00000000000 --- a/packages/effect/src/internal/schema/representation.ts +++ /dev/null @@ -1,795 +0,0 @@ -import * as Arr from "../../Array.ts" -import * as Equal from "../../Equal.ts" -import { format } from "../../Formatter.ts" -import { escapeToken } from "../../JsonPointer.ts" -import type * as JsonSchema from "../../JsonSchema.ts" -import * as Predicate from "../../Predicate.ts" -import * as Rec from "../../Record.ts" -import * as RegEx from "../../RegExp.ts" -import type * as Schema from "../../Schema.ts" -import * as SchemaAST from "../../SchemaAST.ts" -import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" -import * as InternalAnnotations from "./annotations.ts" -import * as InternalSchema from "./schema.ts" - -/** @internal */ -export function fromAST(ast: SchemaAST.AST): SchemaRepresentation.Document { - const { references, representations: schemas } = fromASTs([ast]) - return { representation: schemas[0], references } -} - -/** @internal */ -export function fromASTs(asts: readonly [SchemaAST.AST, ...Array]): SchemaRepresentation.MultiDocument { - const references: Record = {} - - const referenceMap = new Map() - const uniqueReferences = new Set() - const visiting = new Set() - - const schemas = Arr.map(asts, (ast) => recur(ast)) - - return { - representations: schemas, - references - } - - function gen(prefix: string): string { - let candidate = prefix - let suffix = 0 - - while (uniqueReferences.has(candidate)) { - candidate = `${prefix}${++suffix}` - } - - uniqueReferences.add(candidate) - return candidate - } - - function recur(ast: SchemaAST.AST, prefix?: string): SchemaRepresentation.Representation { - const found = referenceMap.get(ast) - if (found !== undefined) { - return { _tag: "Reference", $ref: found } - } - - const last = SchemaAST.getLastEncoding(ast) - const identifier = InternalAnnotations.resolveIdentifier(ast) ?? prefix - - if (ast !== last) { - return recur(last, identifier) - } - - // Has identifier → always create reference - if (identifier !== undefined) { - const reference = gen(identifier) - referenceMap.set(ast, reference) - const out = on(ast) - const found = references[identifier] - // Reuse existing references when duplicate identifiers have the same representation - if (found !== undefined && Equal.equals(out, found)) { - referenceMap.set(ast, identifier) - return { _tag: "Reference", $ref: identifier } - } - references[reference] = out - return { _tag: "Reference", $ref: reference } - } - - // Recursion detected → create reference - if (visiting.has(ast)) { - const reference = gen(`${ast._tag}_`) - referenceMap.set(ast, reference) - return { _tag: "Reference", $ref: reference } - } - - // Normal case → inline - visiting.add(ast) - const out = on(ast) - visiting.delete(ast) - - // A descendant triggered reference creation (recursion) - const ref = referenceMap.get(ast) - if (ref !== undefined) { - references[ref] = out - return { _tag: "Reference", $ref: ref } - } - - return out - } - - function getEncodedSchema(last: SchemaAST.Declaration): SchemaAST.Declaration | SchemaAST.Null { - const getLink = last.annotations?.toCodecJson ?? last.annotations?.toCodec - if (Predicate.isFunction(getLink)) { - return SchemaAST.replaceEncoding(last, [ - getLink(last.typeParameters.map((tp) => InternalSchema.make(SchemaAST.toEncoded(tp)))) - ]) - } - return SchemaAST.null - } - - function on(last: SchemaAST.AST): SchemaRepresentation.Representation { - const annotations = fromASTAnnotations(last.annotations) - switch (last._tag) { - case "Declaration": { - // this must be executed before transforming the type parameters - const encodedSchema = recur(getEncodedSchema(last)) - return { - _tag: "Declaration", - typeParameters: last.typeParameters.map((ast) => recur(ast)), - encodedSchema, - checks: fromASTChecks(last.checks), - ...annotations - } - } - case "Null": - case "Undefined": - case "Void": - case "Never": - case "Unknown": - case "Any": - case "Boolean": - case "Symbol": - case "ObjectKeyword": - return { _tag: last._tag, ...annotations } - case "String": { - const contentMediaType = last.annotations?.contentMediaType - const contentSchema = last.annotations?.contentSchema - return { - _tag: last._tag, - checks: fromASTChecks(last.checks), - ...annotations, - ...(typeof contentMediaType === "string" && SchemaAST.isAST(contentSchema) - ? { contentSchema: recur(contentSchema) } - : undefined) - } - } - case "Number": - case "BigInt": - return { - _tag: last._tag, - checks: fromASTChecks(last.checks), - ...annotations - } - case "Literal": - return { - _tag: last._tag, - literal: last.literal, - ...annotations - } - case "UniqueSymbol": - return { - _tag: last._tag, - symbol: last.symbol, - ...annotations - } - case "Enum": - return { - _tag: last._tag, - enums: last.enums, - ...annotations - } - case "TemplateLiteral": - return { - _tag: last._tag, - parts: last.parts.map((ast) => recur(ast)), - ...annotations - } - case "Arrays": - return { - _tag: last._tag, - elements: last.elements.map((e) => { - const last = SchemaAST.getLastEncoding(e) - return { - isOptional: SchemaAST.isOptional(last), - type: recur(e), - ...fromASTAnnotations(last.context?.annotations) - } - }), - rest: last.rest.map((ast) => recur(ast)), - checks: fromASTChecks(last.checks), - ...annotations - } - case "Objects": - return { - _tag: last._tag, - propertySignatures: last.propertySignatures.map((ps) => { - const last = SchemaAST.getLastEncoding(ps.type) - return { - name: ps.name, - type: recur(ps.type), - isOptional: SchemaAST.isOptional(last), - isMutable: SchemaAST.isMutable(last), - ...fromASTAnnotations(last.context?.annotations) - } - }), - indexSignatures: last.indexSignatures.map((is) => ({ - parameter: recur(is.parameter), - type: recur(is.type) - })), - checks: fromASTChecks(last.checks), - ...annotations - } - case "Union": { - const types = InternalSchema.jsonReorder(last.types) - return { - _tag: last._tag, - types: types.map((ast) => recur(ast)), - mode: last.mode, - ...annotations - } - } - case "Suspend": { - return { - _tag: "Suspend", - checks: [], - thunk: recur(last.thunk()), - ...annotations - } - } - } - } - - function fromASTChecks( - checks: readonly [SchemaAST.Check, ...Array>] | undefined - ): Array> { - if (!checks) return [] - return checks.map(getCheck).filter((c) => c !== undefined) - - function getCheck(c: SchemaAST.Check): SchemaRepresentation.Check | undefined { - switch (c._tag) { - case "Filter": { - const meta = c.annotations?.meta - if (meta) { - return { - _tag: "Filter", - meta: meta._tag === "isPropertyNames" - ? { - _tag: "isPropertyNames", - propertyNames: recur(meta.propertyNames) - } - : meta, - ...fromASTAnnotations(c.annotations) - } - } - return undefined - } - case "FilterGroup": { - const checks = fromASTChecks(c.checks) - if (Arr.isArrayNonEmpty(checks)) { - return { - _tag: "FilterGroup", - checks, - ...fromASTAnnotations(c.annotations) - } - } - } - } - } - } -} - -/** @internal */ -export const fromASTBlacklist: Set = new Set([ - // `expected` is preserved because is useful to generate descriptions in JSON Schemas - "~structural", - "~sentinels", - "meta", - "arbitrary", - "toArbitrary", - "toEquivalence", - "toFormatter", - "toCodec", - "toCodecJson", - "toCodecIso", - SchemaAST.ClassTypeId -]) - -const standardJsonSchemaAnnotationKeys: ReadonlySet = new Set([ - "title", - "description", - "default", - "examples", - "readOnly", - "writeOnly", - "format", - "contentEncoding", - "contentMediaType", - "contentSchema" -]) - -function fromASTAnnotations( - annotations: Schema.Annotations.Annotations | undefined -): { annotations: Schema.Annotations.Annotations } | undefined { - if (annotations !== undefined) { - const filtered = Rec.filter(annotations, (_, k) => !fromASTBlacklist.has(k)) - if (!Rec.isEmptyRecord(filtered)) { - return { annotations: filtered } - } - } - return undefined -} - -/** @internal */ -export function toJsonSchemaDocument( - document: SchemaRepresentation.Document, - options?: Schema.ToJsonSchemaOptions -): JsonSchema.Document<"draft-2020-12"> { - const { definitions, dialect: source, schemas } = toJsonSchemaMultiDocument({ - representations: [document.representation], - references: document.references - }, options) - const schema = schemas[0] - return { dialect: source, schema, definitions } -} - -/** @internal */ -export function toJsonSchemaMultiDocument( - multiDocument: SchemaRepresentation.MultiDocument, - options?: Schema.ToJsonSchemaOptions -): JsonSchema.MultiDocument<"draft-2020-12"> { - const generateDescriptions = options?.generateDescriptions ?? false - const additionalProperties = options?.additionalProperties ?? false - const includeAnnotationKey = options?.includeAnnotationKey - - const definitions = Rec.map(multiDocument.references, (d) => recur(d)) - - return { - dialect: "draft-2020-12", - schemas: Arr.map(multiDocument.representations, (s) => recur(s)), - definitions - } - - function recur(s: SchemaRepresentation.Representation): JsonSchema.JsonSchema { - let js: JsonSchema.JsonSchema = on(s) - if ("annotations" in s) { - const a = collectJsonSchemaAnnotations(s.annotations) - if (a) { - js = { ...js, ...a } - } - } - if ("checks" in s) { - const checks = collectJsonSchemaChecks(s.checks, js.type) - for (const check of checks) { - js = appendJsonSchema(js, check) - } - } - return js - } - - function on(schema: SchemaRepresentation.Representation): JsonSchema.JsonSchema { - switch (schema._tag) { - case "Any": - case "Unknown": - return {} - case "ObjectKeyword": - return { anyOf: [{ type: "object" }, { type: "array" }] } - case "Void": - case "Undefined": - return { type: "null" } - case "BigInt": - return { - "type": "string", - "allOf": [ - { "pattern": "^-?\\d+$" } - ] - } - case "Symbol": - case "UniqueSymbol": - return { - "type": "string", - "allOf": [ - { "pattern": "^Symbol\\((.*)\\)$" } - ] - } - case "Declaration": - return recur(schema.encodedSchema) - case "Suspend": - return recur(schema.thunk) - case "Reference": - return { $ref: `#/$defs/${escapeToken(schema.$ref)}` } - case "Null": - return { type: "null" } - case "Never": - return { not: {} } - case "String": { - const out: JsonSchema.JsonSchema = { type: "string" } - if (schema.contentMediaType !== undefined) { - out.contentMediaType = schema.contentMediaType - } - if (schema.contentSchema !== undefined) { - out.contentSchema = recur(schema.contentSchema) - } - return out - } - case "Number": - return hasCheck(schema.checks, "isInt") ? - { type: "integer" } : - hasCheck(schema.checks, "isFinite") ? - { type: "number" } : - { - "anyOf": [ - { type: "number" }, - { type: "string", enum: ["NaN"] }, - { type: "string", enum: ["Infinity"] }, - { type: "string", enum: ["-Infinity"] } - ] - } - case "Boolean": - return { type: "boolean" } - case "Literal": { - const literal = schema.literal - if (typeof literal === "string") { - return { type: "string", enum: [literal] } - } - if (typeof literal === "number") { - return { type: "number", enum: [literal] } - } - if (typeof literal === "boolean") { - return { type: "boolean", enum: [literal] } - } - // bigint literals are not supported - return { type: "string", enum: [String(literal)] } - } - case "Enum": { - return recur({ - _tag: "Union", - types: schema.enums.map(([title, value]) => ({ - _tag: "Literal", - literal: value, - annotations: { title } - })), - mode: "anyOf", - annotations: schema.annotations - }) - } - case "TemplateLiteral": { - const pattern = schema.parts.map(getPartPattern).join("") - return { type: "string", pattern: `^${pattern}$` } - } - case "Arrays": { - // --------------------------------------------- - // handle post rest elements - // --------------------------------------------- - if (schema.rest.length > 1) { - throw new globalThis.Error("Generating a JSON Schema for post-rest elements is not supported") - } - const out: JsonSchema.JsonSchema = { type: "array" } - let minItems = schema.elements.length - const prefixItems: Array = schema.elements.map((e) => { - if (e.isOptional) { - minItems-- - } - const v = recur(e.type) - const a = collectJsonSchemaAnnotations(e.annotations) - return a ? appendJsonSchema(v, a) : v - }) - if (prefixItems.length > 0) { - out.prefixItems = prefixItems - out.maxItems = schema.elements.length - if (minItems > 0) { - out.minItems = minItems - } - } else { - out.items = false - } - if (schema.rest.length > 0) { - delete out.maxItems - const rest = recur(schema.rest[0]) - if (Object.keys(rest).length > 0) { - out.items = rest - } else { - delete out.items - } - } - return out - } - case "Objects": { - if (schema.propertySignatures.length === 0 && schema.indexSignatures.length === 0) { - return { anyOf: [{ type: "object" }, { type: "array" }] } - } - const out: JsonSchema.JsonSchema = { type: "object" } - const properties: Record = {} - const required: Array = [] - - for (const ps of schema.propertySignatures) { - const name = ps.name - if (typeof name !== "string") { - throw new globalThis.Error(`Unsupported property signature name: ${format(name)}`) - } - const v = recur(ps.type) - const a = collectJsonSchemaAnnotations(ps.annotations) - properties[name] = a ? appendJsonSchema(v, a) : v - // Property is required only if it's not explicitly optional AND doesn't contain Undefined - if (!ps.isOptional) { - required.push(name) - } - } - - if (Object.keys(properties).length > 0) { - out.properties = properties - } - if (required.length > 0) { - out.required = required - } - - out.additionalProperties = additionalProperties - const patternProperties: Record = {} - // Handle index signatures - for (const is of schema.indexSignatures) { - let type: JsonSchema.JsonSchema | false = recur(is.type) - // Collapse unannotated Never ({ not: {} }) to false, but keep annotated schemas as objects. - if (Object.keys(type).length === 1 && "not" in type) { - type = false - } - const patterns = getParameterPatterns(is.parameter) - if (patterns.length > 0) { - for (const pattern of patterns) { - patternProperties[pattern] = type - } - } else { - out.additionalProperties = type - } - } - if (Object.keys(patternProperties).length > 0) { - out.patternProperties = patternProperties - delete out.additionalProperties - } - if (Predicate.isObject(out.additionalProperties) && Rec.isEmptyRecord(out.additionalProperties)) { - delete out.additionalProperties - } - - return out - } - case "Union": { - const types = schema.types.map(recur) - if (types.length === 0) { - // anyOf MUST be a non-empty array - return { not: {} } - } - if (types.length > 1) { - const compacted = compactEnums(types) - if (compacted) return compacted - } - return schema.mode === "anyOf" ? { anyOf: types } : { oneOf: types } - } - } - } - - // Collapses [{type:"string",enum:["a"]},{type:"string",enum:["b"]}] into {type:"string",enum:["a","b"]}. - // Returns undefined if members have different types, extra keys (e.g. title), or empty enums. - function compactEnums( - types: ReadonlyArray - ): JsonSchema.JsonSchema | undefined { - let sharedType: string | undefined - const values: Array = [] - for (const t of types) { - const keys = Object.keys(t) - if (keys.length !== 2 || t.type === undefined || !Array.isArray(t.enum) || t.enum.length === 0) { - return undefined - } - if (sharedType === undefined) { - sharedType = t.type as string - } else if (t.type !== sharedType) { - return undefined - } - for (const v of t.enum) { - values.push(v) - } - } - return { type: sharedType, enum: values } - } - - function collectJsonSchemaAnnotations( - annotations: Schema.Annotations.Annotations | undefined - ): JsonSchema.JsonSchema | undefined { - if (annotations === undefined) return undefined - - const out: JsonSchema.JsonSchema = {} - if (typeof annotations.title === "string") out.title = annotations.title - if (typeof annotations.description === "string") out.description = annotations.description - else if (generateDescriptions && typeof annotations.expected === "string") out.description = annotations.expected - if (annotations.default !== undefined) out.default = annotations.default - if (Array.isArray(annotations.examples)) out.examples = annotations.examples - if (typeof annotations.readOnly === "boolean") out.readOnly = annotations.readOnly - if (typeof annotations.writeOnly === "boolean") out.writeOnly = annotations.writeOnly - if (typeof annotations.format === "string") out.format = annotations.format - if (typeof annotations.contentEncoding === "string") out.contentEncoding = annotations.contentEncoding - if (typeof annotations.contentMediaType === "string") out.contentMediaType = annotations.contentMediaType - - if (includeAnnotationKey) { - for (const [key, value] of Object.entries(annotations)) { - if (value === undefined) continue - if (standardJsonSchemaAnnotationKeys.has(key)) continue - if (!includeAnnotationKey(key)) continue - out[key] = value - } - } - - if (Object.keys(out).length > 0) return out - } - - function collectJsonSchemaChecks( - checks: ReadonlyArray>, - type: unknown - ): Array { - return checks.map(collectJsonSchemaCheck).filter((c) => c !== undefined) - - function collectJsonSchemaCheck(check: SchemaRepresentation.Check): JsonSchema.JsonSchema | undefined { - switch (check._tag) { - case "Filter": - return filterToJsonSchema(check, type) - case "FilterGroup": { - const checks = check.checks.map(collectJsonSchemaCheck).filter((c) => c !== undefined) - if (checks.length === 0) return undefined - let out = { allOf: checks } - const a = collectJsonSchemaAnnotations(check.annotations) - if (a) { - out = { ...out, ...a } - } - return out - } - } - } - } - - function filterToJsonSchema( - filter: SchemaRepresentation.Filter, - type: unknown - ): JsonSchema.JsonSchema | undefined { - const meta = filter.meta as SchemaRepresentation.Meta - if (!meta) return undefined - - let out = on(meta) - const a = collectJsonSchemaAnnotations(filter.annotations) - if (a) { - out = { ...out, ...a } - } - return out - - function on( - meta: SchemaRepresentation.Meta - ): JsonSchema.JsonSchema | undefined { - switch (meta._tag) { - case "isMinLength": - return type === "array" ? { minItems: meta.minLength } : { minLength: meta.minLength } - case "isMaxLength": - return type === "array" ? { maxItems: meta.maxLength } : { maxLength: meta.maxLength } - case "isLengthBetween": - return type === "array" - ? { allOf: [{ minItems: meta.minimum }, { maxItems: meta.maximum }] } - : { allOf: [{ minLength: meta.minimum }, { maxLength: meta.maximum }] } - case "isPattern": - case "isGUID": - case "isULID": - case "isBase64": - case "isBase64Url": - case "isStartsWith": - case "isEndsWith": - case "isIncludes": - case "isUppercased": - case "isLowercased": - case "isCapitalized": - case "isUncapitalized": - case "isTrimmed": - case "isStringFinite": - case "isStringBigInt": - case "isStringSymbol": - return { pattern: meta.regExp.source } - case "isUUID": - return { pattern: meta.regExp.source, format: "uuid" } - - case "isFinite": - case "isInt": - return undefined - case "isMultipleOf": - return { multipleOf: meta.divisor } - case "isGreaterThanOrEqualTo": - return { minimum: meta.minimum } - case "isLessThanOrEqualTo": - return { maximum: meta.maximum } - case "isGreaterThan": - return { exclusiveMinimum: meta.exclusiveMinimum } - case "isLessThan": - return { exclusiveMaximum: meta.exclusiveMaximum } - case "isBetween": { - return { - [meta.exclusiveMinimum ? "exclusiveMinimum" : "minimum"]: meta.minimum, - [meta.exclusiveMaximum ? "exclusiveMaximum" : "maximum"]: meta.maximum - } - } - - case "isUnique": - return { uniqueItems: true } - - case "isMinProperties": - return { minProperties: meta.minProperties } - case "isMaxProperties": - return { maxProperties: meta.maxProperties } - case "isPropertiesLengthBetween": - return { minProperties: meta.minimum, maxProperties: meta.maximum } - case "isPropertyNames": - return { propertyNames: recur(meta.propertyNames) } - - case "isDateValid": - return { format: "date-time" } - } - } - } - - function getParameterPatterns(parameter: SchemaRepresentation.Representation): Array { - switch (parameter._tag) { - default: - throw new globalThis.Error(`Unsupported index signature parameter: ${parameter._tag}`) - case "Reference": - return getParameterPatterns(multiDocument.references[parameter.$ref]) - case "String": - return getPatterns(parameter) - case "TemplateLiteral": - return [`^${parameter.parts.map(getPartPattern).join("")}$`] - case "Union": - return parameter.types.flatMap(getParameterPatterns) - } - } -} - -function getPatterns(s: SchemaRepresentation.String): Array { - return recur(s.checks) - - function recur(checks: ReadonlyArray>): Array { - return checks.flatMap((c) => { - switch (c._tag) { - case "Filter": { - if ("regExp" in c.meta) { - return [c.meta.regExp.source] - } - return [] - } - case "FilterGroup": - return recur(c.checks) - } - }) - } -} - -function hasCheck(checks: ReadonlyArray>, tag: string): boolean { - return checks.some((c) => { - switch (c._tag) { - case "Filter": - return c.meta._tag === tag - case "FilterGroup": - return hasCheck(c.checks, tag) - } - }) -} - -function appendJsonSchema(a: JsonSchema.JsonSchema, b: JsonSchema.JsonSchema): JsonSchema.JsonSchema { - if (Object.keys(a).length === 0) return b - const len = Object.keys(b).length - if (len === 0) return a - const members = Array.isArray(b.allOf) && len === 1 ? b.allOf : [b] - - if (Array.isArray(a.allOf)) { - return { ...a, allOf: [...a.allOf, ...members] } - } - - if (typeof a.$ref === "string") { - return { allOf: [a, ...members] } - } - - return { ...a, allOf: members } -} - -function getPartPattern(part: SchemaRepresentation.Representation): string { - switch (part._tag) { - case "Literal": - return RegEx.escape(globalThis.String(part.literal)) - case "String": - return SchemaAST.STRING_PATTERN - case "Number": - return SchemaAST.FINITE_PATTERN - case "TemplateLiteral": - return part.parts.map(getPartPattern).join("") - case "Union": - return part.types.map(getPartPattern).join("|") - default: - throw new globalThis.Error("Unsupported part", { cause: part }) - } -} diff --git a/packages/effect/src/internal/schema/representationJson.ts b/packages/effect/src/internal/schema/representationJson.ts new file mode 100644 index 00000000000..85b26b8d0e0 --- /dev/null +++ b/packages/effect/src/internal/schema/representationJson.ts @@ -0,0 +1,199 @@ +import * as Schema from "../../Schema.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import { projectDocument, projectMultiDocument } from "./toRepresentation.ts" + +const RepresentationSchema = Schema.suspend( + (): Schema.Codec => RepresentationUnion +) + +const RepresentationAnnotationSchema = Schema.Struct({ + id: Schema.NonEmptyString, + payload: Schema.Json +}) + +const CheckRepresentationAnnotationSchema = Schema.Struct({ + ...RepresentationAnnotationSchema.fields, + schemas: Schema.optional(Schema.Array(RepresentationSchema)) +}) + +const AnnotationsSchema = Schema.Record(Schema.String, Schema.Json) + +const CheckSchema = Schema.suspend((): Schema.Codec => CheckUnion) +const FilterSchema = Schema.Struct({ + _tag: Schema.tag("Filter"), + representation: CheckRepresentationAnnotationSchema, + annotations: Schema.optional(AnnotationsSchema), + aborted: Schema.Boolean +}) +const FilterGroupSchema = Schema.Struct({ + _tag: Schema.tag("FilterGroup"), + representation: Schema.optional(CheckRepresentationAnnotationSchema), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.NonEmptyArray(CheckSchema) +}) +const CheckUnion = Schema.Union([FilterSchema, FilterGroupSchema]) + +function keywordSchema>(tag: Tag) { + return Schema.Struct({ + _tag: Schema.tag(tag), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema) + }) +} + +const DeclarationSchema = Schema.Struct({ + _tag: Schema.tag("Declaration"), + representation: RepresentationAnnotationSchema, + annotations: Schema.optional(AnnotationsSchema), + typeParameters: Schema.Array(RepresentationSchema), + checks: Schema.Array(CheckSchema) +}) +const SuspendSchema = Schema.Struct({ + _tag: Schema.tag("Suspend"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Tuple([]), + thunk: RepresentationSchema +}) +const LiteralSchema = Schema.Struct({ + _tag: Schema.tag("Literal"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + literal: Schema.Union([ + Schema.Finite, + Schema.BigInt, + Schema.String, + Schema.Boolean + ]) +}) +const UniqueSymbolSchema = Schema.Struct({ + _tag: Schema.tag("UniqueSymbol"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + symbol: Schema.Symbol +}) +const EnumSchema = Schema.Struct({ + _tag: Schema.tag("Enum"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + enums: Schema.Array(Schema.Tuple([ + Schema.String, + Schema.Union([Schema.Number, Schema.String]) + ])) +}) +const TemplateLiteralSchema = Schema.Struct({ + _tag: Schema.tag("TemplateLiteral"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + parts: Schema.Array(RepresentationSchema) +}) +const ElementSchema = Schema.Struct({ + isOptional: Schema.Boolean, + type: RepresentationSchema, + annotations: Schema.optional(AnnotationsSchema) +}) +const ArraysSchema = Schema.Struct({ + _tag: Schema.tag("Arrays"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + elements: Schema.Array(ElementSchema), + rest: Schema.Array(RepresentationSchema) +}) +const StructuralPropertyKeySchema = Schema.Union([ + Schema.Symbol, + Schema.Number, + Schema.String +]) +const PropertySignatureSchema = Schema.Struct({ + name: StructuralPropertyKeySchema, + type: RepresentationSchema, + isOptional: Schema.Boolean, + isMutable: Schema.Boolean, + annotations: Schema.optional(AnnotationsSchema) +}) +const IndexSignatureSchema = Schema.Struct({ + parameter: RepresentationSchema, + type: RepresentationSchema +}) +const ObjectsSchema = Schema.Struct({ + _tag: Schema.tag("Objects"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + propertySignatures: Schema.Array(PropertySignatureSchema), + indexSignatures: Schema.Array(IndexSignatureSchema) +}) +const UnionSchema = Schema.Struct({ + _tag: Schema.tag("Union"), + annotations: Schema.optional(AnnotationsSchema), + checks: Schema.Array(CheckSchema), + types: Schema.Array(RepresentationSchema), + mode: Schema.Literals(["anyOf", "oneOf"]) +}) +const ReferenceSchema = Schema.Struct({ + _tag: Schema.tag("Reference"), + $ref: Schema.NonEmptyString +}) + +const RepresentationUnion = Schema.Union([ + DeclarationSchema, + ReferenceSchema, + SuspendSchema, + keywordSchema("Null"), + keywordSchema("Undefined"), + keywordSchema("Void"), + keywordSchema("Never"), + keywordSchema("Unknown"), + keywordSchema("Any"), + keywordSchema("String"), + keywordSchema("Number"), + keywordSchema("Boolean"), + keywordSchema("BigInt"), + keywordSchema("Symbol"), + keywordSchema("ObjectKeyword"), + LiteralSchema, + UniqueSymbolSchema, + EnumSchema, + TemplateLiteralSchema, + ArraysSchema, + ObjectsSchema, + UnionSchema +]) + +const DocumentSchema = Schema.Struct({ + representation: RepresentationSchema, + references: Schema.Record(Schema.String, RepresentationSchema) +}) + +const MultiDocumentSchema = Schema.Struct({ + representations: Schema.NonEmptyArray(RepresentationSchema), + references: Schema.Record(Schema.String, RepresentationSchema) +}) + +const DocumentFromJson: Schema.Codec = Schema.toCodecJson( + DocumentSchema +) + +const MultiDocumentFromJson: Schema.Codec = Schema.toCodecJson( + MultiDocumentSchema +) + +/** @internal */ +export function toJson(document: SchemaRepresentation.Document): Schema.Json { + const projected = projectDocument(document) + return Schema.encodeSync(DocumentFromJson)(projected) +} + +/** @internal */ +export function toJsonMultiDocument(document: SchemaRepresentation.MultiDocument): Schema.Json { + const projected = projectMultiDocument(document) + return Schema.encodeSync(MultiDocumentFromJson)(projected) +} + +/** @internal */ +export function fromJson(input: Schema.Json): SchemaRepresentation.Document { + return Schema.decodeSync(DocumentFromJson)(input) +} + +/** @internal */ +export function fromJsonMultiDocument(input: Schema.Json): SchemaRepresentation.MultiDocument { + return Schema.decodeSync(MultiDocumentFromJson)(input) +} diff --git a/packages/effect/src/internal/schema/schema.ts b/packages/effect/src/internal/schema/schema.ts index 752d3fc97b9..b611b3c884b 100644 --- a/packages/effect/src/internal/schema/schema.ts +++ b/packages/effect/src/internal/schema/schema.ts @@ -6,10 +6,50 @@ import * as SchemaAST from "../../SchemaAST.ts" import { SchemaError } from "../../SchemaError.ts" import type { Issue } from "../../SchemaIssue.ts" import * as SchemaParser from "../../SchemaParser.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" /** @internal */ export const TypeId = "~effect/Schema/Schema" +/** @internal */ +export function makeDeclarationReviver

( + id: string, + payloadSchema: Schema.Decoder

, + revive: SchemaRepresentation.DeclarationReviver

["revive"] +): SchemaRepresentation.DeclarationReviver

{ + return { + id, + payloadSchema, + revive + } +} + +/** @internal */ +export function makeFilterReviver

( + id: string, + payloadSchema: Schema.Decoder

, + revive: SchemaRepresentation.FilterReviver

["revive"] +): SchemaRepresentation.FilterReviver

{ + return { + id, + payloadSchema, + revive + } +} + +/** @internal */ +export function makeFilterGroupReviver

( + id: string, + payloadSchema: Schema.Decoder

, + revive: SchemaRepresentation.FilterGroupReviver

["revive"] +): SchemaRepresentation.FilterGroupReviver

{ + return { + id, + payloadSchema, + revive + } +} + const SchemaProto = { [TypeId]: TypeId, pipe() { @@ -51,45 +91,3 @@ export function fromIssueEffect( (cause) => Effect.failCauseSync(() => Cause.map(cause, (issue) => new SchemaError(issue))) ) } - -/** @internal */ -export const jsonReorder = makeReorder(getJsonPriority) - -function getJsonPriority(ast: SchemaAST.AST): number { - switch (ast._tag) { - case "BigInt": - case "Symbol": - case "UniqueSymbol": - return 0 - default: - return 1 - } -} - -/** @internal */ -export function makeReorder(getPriority: (ast: SchemaAST.AST) => number) { - return (types: ReadonlyArray): ReadonlyArray => { - // Create a map of original indices for O(1) lookup - const indexMap = new Map() - for (let i = 0; i < types.length; i++) { - indexMap.set(SchemaAST.toEncoded(types[i]), i) - } - - // Create a sorted copy of the types array - const sortedTypes = [...types].sort((a, b) => { - a = SchemaAST.toEncoded(a) - b = SchemaAST.toEncoded(b) - const pa = getPriority(a) - const pb = getPriority(b) - if (pa !== pb) return pa - pb - // If priorities are equal, maintain original order (stable sort) - return indexMap.get(a)! - indexMap.get(b)! - }) - - // Check if order changed by comparing arrays - const orderChanged = sortedTypes.some((ast, index) => ast !== types[index]) - - if (!orderChanged) return types - return sortedTypes - } -} diff --git a/packages/effect/src/internal/schema/arbitrary.ts b/packages/effect/src/internal/schema/toArbitrary.ts similarity index 99% rename from packages/effect/src/internal/schema/arbitrary.ts rename to packages/effect/src/internal/schema/toArbitrary.ts index 8fe9690715b..f5805a628ef 100644 --- a/packages/effect/src/internal/schema/arbitrary.ts +++ b/packages/effect/src/internal/schema/toArbitrary.ts @@ -403,10 +403,8 @@ function reportChecks(report: MutableReport, checks: SchemaAST.Checks | undefine visit(child, nextCovered) } } else if (!nextCovered) { - const meta = check.annotations?.meta - const description = typeof meta === "object" && meta !== null && "_tag" in meta && typeof meta._tag === "string" - ? meta._tag - : check.annotations?.identifier ?? check.annotations?.expected + const description = check.annotations?.representation?.id ?? check.annotations?.identifier ?? + check.annotations?.expected report.warnings.push({ _tag: "OpaqueFilter", path, ...(description === undefined ? {} : { description }) }) } } diff --git a/packages/effect/src/internal/schema/toCodeDocument.ts b/packages/effect/src/internal/schema/toCodeDocument.ts new file mode 100644 index 00000000000..9b96a7c40ad --- /dev/null +++ b/packages/effect/src/internal/schema/toCodeDocument.ts @@ -0,0 +1,608 @@ +import * as Arr from "../../Array.ts" +import { formatPropertyKey } from "../../Formatter.ts" +import type * as Schema from "../../Schema.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import { errorWithPath } from "../errors.ts" +import * as InternalAnnotations from "./annotations.ts" + +type Path = ReadonlyArray +type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation< + SchemaRepresentation.Representation +> + +/** @internal */ +export function makeCode(runtime: string, Type: string): SchemaRepresentation.Code { + return { runtime, Type } +} + +function renderNumber(value: number): string { + if (Object.is(value, -0)) return "-0" + if (Number.isNaN(value)) return "NaN" + if (value === Infinity) return "Infinity" + if (value === -Infinity) return "-Infinity" + return globalThis.String(value) +} + +function renderEmittableAnnotation(input: unknown): string | undefined { + if (input === null) return "null" + if (typeof input === "string") return JSON.stringify(input) + if (typeof input === "boolean") return globalThis.String(input) + if (typeof input === "number") return renderNumber(input) + if (typeof input === "bigint") return `${input}n` + if (typeof input === "symbol") { + const key = globalThis.Symbol.keyFor(input) + return key === undefined ? undefined : `Symbol.for(${JSON.stringify(key)})` + } + if (typeof input !== "object") return undefined + if (Array.isArray(input)) { + const values: Array = [] + for (const value of input) { + const rendered = renderEmittableAnnotation(value) + if (rendered === undefined) return undefined + values.push(rendered) + } + return `[${values.join(", ")}]` + } + + const entries: Array = [] + for (const [key, value] of Object.entries(input)) { + const rendered = renderEmittableAnnotation(value) + if (rendered === undefined) return undefined + entries.push(`${JSON.stringify(key)}: ${rendered}`) + } + return `{ ${entries.join(", ")} }` +} + +function renderAnnotations( + annotations: Schema.Annotations.Annotations | undefined +): string | undefined { + if (annotations === undefined) return undefined + const entries: Array = [] + for (const [key, value] of Object.entries(annotations)) { + if (InternalAnnotations.annotationExcludedKeys.has(key)) continue + const rendered = renderEmittableAnnotation(value) + if (rendered !== undefined) entries.push(`${JSON.stringify(key)}: ${rendered}`) + } + return entries.length === 0 ? undefined : `{ ${entries.join(", ")} }` +} + +/** @internal */ +export function sanitizeJavaScriptIdentifier(input: string): string { + if (input.length === 0) return "_" + const out = input.replace(/[^A-Za-z0-9_$]/gu, "_") + const first = out[0] + return first >= "a" && first <= "z" + ? first.toUpperCase() + out.slice(1) + : first >= "0" && first <= "9" + ? `_${out}` + : out +} + +function renderLiteral(value: string | number | boolean | bigint): string { + switch (typeof value) { + case "string": + return JSON.stringify(value) + case "number": + return renderNumber(value) + case "boolean": + return globalThis.String(value) + case "bigint": + return `${value}n` + } +} + +function isSimpleLiveLiteral( + representation: SchemaRepresentation.Representation +): representation is SchemaRepresentation.Literal { + return representation._tag === "Literal" && representation.checks.length === 0 && + representation.annotations === undefined +} + +function toTypeParts(parts: ReadonlyArray): ReadonlyArray { + let out = [""] + for (const part of parts) out = out.flatMap((prefix) => toTypePart(part).map((suffix) => prefix + suffix)) + return out +} + +function toTypePart(part: SchemaRepresentation.Representation): ReadonlyArray { + switch (part._tag) { + case "Literal": + return [globalThis.String(part.literal)] + case "String": + return ["${string}"] + case "Number": + return ["${number}"] + case "BigInt": + return ["${bigint}"] + case "TemplateLiteral": + return toTypeParts(part.parts) + case "Union": + return part.types.flatMap(toTypePart) + default: + return [] + } +} + +/** @internal */ +export interface TopologicalSort { + readonly nonRecursives: ReadonlyArray<{ + readonly $ref: string + readonly representation: SchemaRepresentation.Representation + }> + readonly recursives: Readonly> +} + +/** @internal */ +export function topologicalSort( + references: SchemaRepresentation.References +): TopologicalSort { + const identifiers = Object.keys(references) + const identifierSet = new Set(identifiers) + + function collectRefs(root: SchemaRepresentation.Representation): ReadonlySet { + const refs = new Set() + const visited = new WeakSet() + const stack: Array = [root] + + function pushRepresentationSchemas(representation: CheckRepresentationAnnotation | undefined): void { + if (representation?.schemas !== undefined) stack.push(...representation.schemas) + } + + function pushChecks( + checks: ReadonlyArray + ): void { + for (const check of checks) { + pushRepresentationSchemas(check.representation) + if (check._tag === "FilterGroup") pushChecks(check.checks) + } + } + + while (stack.length > 0) { + const representation = stack.pop()! + if (visited.has(representation)) continue + visited.add(representation) + if (representation._tag === "Reference") { + if (identifierSet.has(representation.$ref)) refs.add(representation.$ref) + continue + } + + pushChecks(representation.checks) + switch (representation._tag) { + case "Declaration": + pushRepresentationSchemas(representation.representation) + stack.push(...representation.typeParameters) + break + case "Suspend": + stack.push(representation.thunk) + break + case "TemplateLiteral": + stack.push(...representation.parts) + break + case "Arrays": + for (const element of representation.elements) stack.push(element.type) + stack.push(...representation.rest) + break + case "Objects": + for (const property of representation.propertySignatures) stack.push(property.type) + for (const signature of representation.indexSignatures) { + stack.push(signature.parameter, signature.type) + } + break + case "Union": + stack.push(...representation.types) + break + } + } + return refs + } + + const dependencies = new Map>( + identifiers.map((identifier) => [identifier, collectRefs(references[identifier])]) + ) + const recursive = new Set() + const state = new Map() + const stack: Array = [] + + function visit(identifier: string): void { + const current = state.get(identifier) ?? 0 + if (current === 1) { + const start = stack.indexOf(identifier) + for (let index = start; index < stack.length; index++) recursive.add(stack[index]) + return + } + if (current === 2) return + state.set(identifier, 1) + stack.push(identifier) + for (const dependency of dependencies.get(identifier)!) visit(dependency) + stack.pop() + state.set(identifier, 2) + } + + for (const identifier of identifiers) visit(identifier) + + const inDegree = new Map() + const dependents = new Map>() + for (const identifier of identifiers) { + if (!recursive.has(identifier)) { + inDegree.set(identifier, 0) + dependents.set(identifier, new Set()) + } + } + for (const [identifier, internalDependencies] of dependencies) { + if (recursive.has(identifier)) continue + for (const dependency of internalDependencies) { + if (recursive.has(dependency)) continue + inDegree.set(identifier, inDegree.get(identifier)! + 1) + dependents.get(dependency)!.add(identifier) + } + } + + const queue: Array = [] + for (const [identifier, degree] of inDegree) { + if (degree === 0) queue.push(identifier) + } + const nonRecursives: Array<{ + readonly $ref: string + readonly representation: SchemaRepresentation.Representation + }> = [] + for (let index = 0; index < queue.length; index++) { + const $ref = queue[index] + nonRecursives.push({ $ref, representation: references[$ref] }) + for (const dependent of dependents.get($ref)!) { + const degree = inDegree.get(dependent)! - 1 + inDegree.set(dependent, degree) + if (degree === 0) queue.push(dependent) + } + } + const recursives: Record = {} + for (const identifier of recursive) recursives[identifier] = references[identifier] + return { nonRecursives, recursives } +} + +/** @internal */ +export function toCodeDocument( + document: SchemaRepresentation.MultiDocument +): SchemaRepresentation.CodeDocument { + const artifacts: Array = [] + const sorted = topologicalSort(document.references) + const sanitizedReferences = new Map() + const uniqueIdentifiers = new Set() + let compilingRecursiveDefinition = false + let explicitSuspendDepth = 0 + + for (const { $ref } of sorted.nonRecursives) ensureUniqueIdentifier($ref) + for (const $ref of Object.keys(sorted.recursives)) ensureUniqueIdentifier($ref) + + const nonRecursives = sorted.nonRecursives.map(({ $ref, representation }) => ({ + $ref: ensureUniqueIdentifier($ref), + code: recur(representation, ["references", $ref]) + })) + const recursives: Record = {} + for (const [$ref, representation] of Object.entries(sorted.recursives)) { + compilingRecursiveDefinition = true + recursives[ensureUniqueIdentifier($ref)] = recur(representation, ["references", $ref]) + compilingRecursiveDefinition = false + } + const codes = document.representations.map((representation, index) => + recur(representation, ["representations", index]) + ) + + return { + codes, + references: { nonRecursives, recursives }, + artifacts + } + + function ensureUniqueIdentifier(original: string): string { + const existing = sanitizedReferences.get(original) + if (existing !== undefined) return existing + const candidate = freshIdentifier(original) + sanitizedReferences.set(original, candidate) + return candidate + } + + function freshIdentifier(seed: string): string { + const sanitized = sanitizeJavaScriptIdentifier(seed) + let candidate = sanitized + let suffix = 0 + while (uniqueIdentifiers.has(candidate)) candidate = `${sanitized}${++suffix}` + uniqueIdentifiers.add(candidate) + return candidate + } + + function addImport(importDeclaration: string): void { + if (!artifacts.some((artifact) => artifact._tag === "Import" && artifact.importDeclaration === importDeclaration)) { + artifacts.push({ _tag: "Import", importDeclaration }) + } + } + + function addSymbol(symbol: symbol): string { + const identifier = freshIdentifier("_symbol") + const key = globalThis.Symbol.keyFor(symbol) + const description = symbol.description + artifacts.push({ + _tag: "Symbol", + identifier, + code: makeCode( + key === undefined + ? `Symbol(${description === undefined ? "" : JSON.stringify(description)})` + : `Symbol.for(${JSON.stringify(key)})`, + `typeof ${identifier}` + ) + }) + return identifier + } + + function annotationSchemas( + representation: CheckRepresentationAnnotation | undefined, + path: Path + ): ReadonlyArray { + return representation?.schemas?.map((schema, index) => recur(schema, [...path, "schemas", index])) ?? [] + } + + function checkBrands( + check: SchemaRepresentation.Check + ): ReadonlyArray { + const own = InternalAnnotations.collectBrands(check.annotations) + if ( + check._tag === "FilterGroup" && + check.annotations?.toCode === undefined + ) { + return [...own, ...check.checks.flatMap(checkBrands)] + } + return own + } + + function runtimeBrands(brands: ReadonlyArray): string { + return brands.length === 0 + ? "" + : `.pipe(${brands.map((brand) => `Schema.brand(${JSON.stringify(brand)})`).join(", ")})` + } + + function typeBrands(brands: ReadonlyArray): string { + if (brands.length === 0) return "" + addImport(`import type * as Brand from "effect/Brand"`) + return brands.map((brand) => ` & Brand.Brand<${JSON.stringify(brand)}>`).join("") + } + + function runtimeAnnotate( + annotations: Schema.Annotations.Annotations | undefined, + method: "annotate" | "annotateKey" = "annotate" + ): string { + const rendered = renderAnnotations(annotations) + return rendered === undefined ? "" : `.${method}(${rendered})` + } + + function compileCheck( + check: SchemaRepresentation.Check, + path: Path + ): string { + const callback = check.annotations?.toCode + let runtime: string + if (callback !== undefined) { + const schemas = annotationSchemas(check.representation, [...path, "representation"]) + const output = (callback as SchemaRepresentation.Generation.Check)({ schemas }) + for (const importDeclaration of output.importDeclarations ?? []) addImport(importDeclaration) + runtime = output.runtime + } else if (check._tag === "Filter") { + throw errorWithPath("Missing toCode callback", [...path, "annotations", "toCode"]) + } else { + runtime = `Schema.makeFilterGroup([${ + check.checks.map((child, index) => compileCheck(child, [...path, "checks", index])).join(", ") + }])` + } + runtime += runtimeAnnotate(check.annotations) + if (check._tag === "Filter" && check.aborted) runtime += ".abort()" + return runtime + } + + function applyNode( + base: SchemaRepresentation.Code, + representation: Exclude, + path: Path, + includeTypeBrands: boolean = true + ): SchemaRepresentation.Code { + const nodeBrands = InternalAnnotations.collectBrands(representation.annotations) + let runtime = base.runtime + runtimeAnnotate(representation.annotations) + runtimeBrands(nodeBrands) + let Type = base.Type + (includeTypeBrands ? typeBrands(nodeBrands) : "") + for (let index = 0; index < representation.checks.length; index++) { + const check = representation.checks[index] + const brands = checkBrands(check) + runtime += `.check(${compileCheck(check, [...path, "checks", index])})${runtimeBrands(brands)}` + if (includeTypeBrands) Type += typeBrands(brands) + } + return makeCode(runtime, Type) + } + + function recur( + representation: SchemaRepresentation.Representation, + path: Path + ): SchemaRepresentation.Code { + if (representation._tag === "Reference") { + if (!Object.hasOwn(document.references, representation.$ref)) { + throw errorWithPath(`Invalid reference ${representation.$ref}`, [...path, "$ref"]) + } + const identifier = ensureUniqueIdentifier(representation.$ref) + if ( + compilingRecursiveDefinition && explicitSuspendDepth === 0 && + Object.hasOwn(sorted.recursives, representation.$ref) + ) { + return makeCode(`Schema.suspend((): Schema.Codec<${identifier}> => ${identifier})`, identifier) + } + return makeCode(identifier, identifier) + } + return applyNode(on(representation, path), representation, path) + } + + function on( + representation: Exclude, + path: Path + ): SchemaRepresentation.Code { + switch (representation._tag) { + case "Declaration": { + const callback = representation.annotations?.toCode + if (callback === undefined) { + throw errorWithPath("Missing toCode callback", [...path, "annotations", "toCode"]) + } + const typeParameters = representation.typeParameters.map((typeParameter, index) => + recur(typeParameter, [...path, "typeParameters", index]) + ) + const output = (callback as SchemaRepresentation.Generation.Declaration)({ typeParameters }) + for (const importDeclaration of output.importDeclarations ?? []) addImport(importDeclaration) + return makeCode(output.runtime, output.Type) + } + case "Suspend": { + explicitSuspendDepth++ + const thunk = recur(representation.thunk, [...path, "thunk"]) + explicitSuspendDepth-- + return makeCode(`Schema.suspend((): Schema.Codec<${thunk.Type}> => ${thunk.runtime})`, thunk.Type) + } + case "Null": + return makeCode("Schema.Null", "null") + case "Undefined": + return makeCode("Schema.Undefined", "undefined") + case "Void": + return makeCode("Schema.Void", "void") + case "Never": + return makeCode("Schema.Never", "never") + case "Unknown": + return makeCode("Schema.Unknown", "unknown") + case "Any": + return makeCode("Schema.Any", "any") + case "String": + return makeCode("Schema.String", "string") + case "Number": + return makeCode("Schema.Number", "number") + case "Boolean": + return makeCode("Schema.Boolean", "boolean") + case "BigInt": + return makeCode("Schema.BigInt", "bigint") + case "Symbol": + return makeCode("Schema.Symbol", "symbol") + case "Literal": { + const literal = renderLiteral(representation.literal) + return makeCode(`Schema.Literal(${literal})`, literal) + } + case "UniqueSymbol": { + const identifier = addSymbol(representation.symbol) + return makeCode(`Schema.UniqueSymbol(${identifier})`, `typeof ${identifier}`) + } + case "ObjectKeyword": + return makeCode("Schema.ObjectKeyword", "object") + case "Enum": { + const identifier = freshIdentifier("_Enum") + artifacts.push({ + _tag: "Enum", + identifier, + code: makeCode( + `enum ${identifier} { ${ + representation.enums.map(([name, value]) => `${JSON.stringify(name)} = ${renderLiteral(value)}`).join( + ", " + ) + } }`, + `typeof ${identifier}` + ) + }) + return makeCode(`Schema.Enum(${identifier})`, `typeof ${identifier}`) + } + case "TemplateLiteral": { + const parts = representation.parts.map((part, index) => recur(part, [...path, "parts", index])) + const Type = toTypeParts(representation.parts).map((part) => `\`${part}\``).join(" | ") + return makeCode(`Schema.TemplateLiteral([${parts.map((part) => part.runtime).join(", ")}])`, Type) + } + case "Arrays": { + const elements = representation.elements.map((element, index) => { + const type = recur(element.type, [...path, "elements", index, "type"]) + return makeCode( + `${element.isOptional ? "Schema.optionalKey(" : ""}${type.runtime}${element.isOptional ? ")" : ""}${ + runtimeAnnotate(element.annotations, "annotateKey") + }`, + `${type.Type}${element.isOptional ? "?" : ""}` + ) + }) + const rest = representation.rest.map((item, index) => recur(item, [...path, "rest", index])) + if (Arr.isArrayNonEmpty(rest)) { + const item = rest[0] + if (elements.length === 0 && rest.length === 1) { + return makeCode(`Schema.Array(${item.runtime})`, `ReadonlyArray<${item.Type}>`) + } + const post = rest.slice(1) + return makeCode( + `Schema.TupleWithRest(Schema.Tuple([${elements.map((element) => element.runtime).join(", ")}]), [${ + rest.map((item) => item.runtime).join(", ") + }])`, + `readonly [${elements.map((element) => element.Type).join(", ")}, ...Array<${item.Type}>${ + post.length > 0 ? `, ${post.map((item) => item.Type).join(", ")}` : "" + }]` + ) + } + return makeCode( + `Schema.Tuple([${elements.map((element) => element.runtime).join(", ")}])`, + `readonly [${elements.map((element) => element.Type).join(", ")}]` + ) + } + case "Objects": { + const properties = representation.propertySignatures.map((property, index) => { + const isSymbol = typeof property.name === "symbol" + const name = isSymbol + ? addSymbol(property.name) + : formatPropertyKey(property.name) + const type = recur(property.type, [...path, "propertySignatures", index, "type"]) + let runtime = type.runtime + if (property.isMutable) runtime = `Schema.mutableKey(${runtime})` + if (property.isOptional) runtime = `Schema.optionalKey(${runtime})` + const runtimeName = isSymbol ? `[${name}]` : name + const typeName = `${property.isMutable ? "" : "readonly "}${runtimeName}${property.isOptional ? "?" : ""}` + return makeCode( + `${runtimeName}: ${runtime}${runtimeAnnotate(property.annotations, "annotateKey")}`, + `${typeName}: ${type.Type}` + ) + }) + const indexSignatures = representation.indexSignatures.map((signature, index) => ({ + parameter: recur(signature.parameter, [...path, "indexSignatures", index, "parameter"]), + type: recur(signature.type, [...path, "indexSignatures", index, "type"]) + })) + const propertyRuntimes = properties.map((property) => property.runtime).join(", ") + const propertyTypes = properties.map((property) => property.Type).join(", ") + if (indexSignatures.length === 0) { + return makeCode( + `Schema.Struct({ ${propertyRuntimes} })`, + `{ ${propertyTypes} }` + ) + } + if (properties.length === 0 && indexSignatures.length === 1) { + const signature = indexSignatures[0] + return makeCode( + `Schema.Record(${signature.parameter.runtime}, ${signature.type.runtime})`, + `{ readonly [x: ${signature.parameter.Type}]: ${signature.type.Type} }` + ) + } + const indexRuntimes = indexSignatures.map((signature) => + `Schema.Record(${signature.parameter.runtime}, ${signature.type.runtime})` + ).join(", ") + const indexTypes = indexSignatures.map((signature) => + `readonly [x: ${signature.parameter.Type}]: ${signature.type.Type}` + ).join(", ") + return makeCode( + `Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`, + `{ ${propertyTypes}${properties.length > 0 ? ", " : ""}${indexTypes} }` + ) + } + case "Union": { + if (representation.types.length === 0) return makeCode("Schema.Never", "never") + if (representation.types.every(isSimpleLiveLiteral)) { + const literals = representation.types.map((literal) => renderLiteral(literal.literal)) + return literals.length === 1 + ? makeCode(`Schema.Literal(${literals[0]})`, literals[0]) + : makeCode(`Schema.Literals([${literals.join(", ")}])`, literals.join(" | ")) + } + const types = representation.types.map((type, index) => recur(type, [...path, "types", index])) + const mode = representation.mode === "anyOf" ? "" : `, { mode: "oneOf" }` + return makeCode( + `Schema.Union([${types.map((type) => type.runtime).join(", ")}]${mode})`, + types.map((type) => type.Type).join(" | ") + ) + } + } + } +} diff --git a/packages/effect/src/internal/schema/equivalence.ts b/packages/effect/src/internal/schema/toEquivalence.ts similarity index 100% rename from packages/effect/src/internal/schema/equivalence.ts rename to packages/effect/src/internal/schema/toEquivalence.ts diff --git a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts new file mode 100644 index 00000000000..f5d687b88a1 --- /dev/null +++ b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts @@ -0,0 +1,476 @@ +import * as Arr from "../../Array.ts" +import { escapeToken } from "../../JsonPointer.ts" +import type * as JsonSchema from "../../JsonSchema.ts" +import * as RegEx from "../../RegExp.ts" +import type * as Schema from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import { errorWithPath } from "../errors.ts" +import * as InternalAnnotations from "./annotations.ts" + +type Path = ReadonlyArray +type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation< + SchemaRepresentation.Representation +> + +const jsonSchemaAnnotationExcludedKeys = new Set([ + ...InternalAnnotations.annotationExcludedKeys, + InternalAnnotations.identifierFallbackKey, + "title", + "description", + "default", + "examples", + "readOnly", + "writeOnly", + "format", + "contentEncoding", + "contentMediaType", + "contentSchema" +]) + +function collectJsonSchemaAnnotations( + annotations: Schema.Annotations.Annotations | undefined, + options: Schema.ToJsonSchemaOptions | undefined +): JsonSchema.JsonSchema | undefined { + if (annotations === undefined) return undefined + + const out: JsonSchema.JsonSchema = {} + const title = annotations.title + if (typeof title === "string") out.title = title + const description = annotations.description + const expected = annotations.expected + if (typeof description === "string") out.description = description + else if (options?.generateDescriptions === true && typeof expected === "string") out.description = expected + + const defaultValue = annotations.default + if (SchemaAST.isJson(defaultValue)) out.default = defaultValue + const examples = annotations.examples + if (Array.isArray(examples) && SchemaAST.isJson(examples)) out.examples = examples + const readOnly = annotations.readOnly + if (typeof readOnly === "boolean") out.readOnly = readOnly + const writeOnly = annotations.writeOnly + if (typeof writeOnly === "boolean") out.writeOnly = writeOnly + const format = annotations.format + if (typeof format === "string") out.format = format + const contentEncoding = annotations.contentEncoding + if (typeof contentEncoding === "string") out.contentEncoding = contentEncoding + const contentMediaType = annotations.contentMediaType + if (typeof contentMediaType === "string") out.contentMediaType = contentMediaType + const contentSchema = annotations.contentSchema + if (SchemaAST.isJson(contentSchema)) out.contentSchema = contentSchema + + if (options?.includeAnnotationKey !== undefined) { + for (const [key, value] of Object.entries(annotations)) { + if ( + jsonSchemaAnnotationExcludedKeys.has(key) || + !options.includeAnnotationKey(key) + ) { + continue + } + if (SchemaAST.isJson(value)) out[key] = value + } + } + + return Object.keys(out).length === 0 ? undefined : out +} + +type JsonSchemaNumberType = "number" | "integer" + +function extractJsonSchemaNumberType(schema: JsonSchema.JsonSchema): { + readonly type: JsonSchemaNumberType | undefined + readonly schema: JsonSchema.JsonSchema +} { + let type: JsonSchemaNumberType | undefined = schema.type === "number" || schema.type === "integer" + ? schema.type + : undefined + let out = schema + if (type !== undefined) { + out = { ...schema } + delete out.type + } + if (Array.isArray(out.allOf)) { + const members: Array = [] + let changed = false + for (const member of out.allOf) { + const extracted = extractJsonSchemaNumberType(member) + if (extracted.type !== undefined) { + changed = true + if (type === undefined || extracted.type === "integer") type = extracted.type + } + if (Object.keys(extracted.schema).length > 0) members.push(extracted.schema) + } + if (changed) { + const { allOf: _, ...rest } = out + out = members.length === 0 ? rest : { ...rest, allOf: members } + } + } + return { type, schema: out } +} + +function isJsonSchemaNumberEncoding(schema: JsonSchema.JsonSchema): boolean { + return Array.isArray(schema.anyOf) && schema.anyOf.length === 4 && schema.anyOf[0]?.type === "number" && + schema.anyOf.slice(1).every((member) => member.type === "string") +} + +function appendJsonSchema( + left: JsonSchema.JsonSchema, + right: JsonSchema.JsonSchema +): JsonSchema.JsonSchema { + if (Object.keys(left).length === 0) return right + const rightKeys = Object.keys(right) + if (rightKeys.length === 0) return left + const leftType = left.type === "number" || left.type === "integer" ? left.type : undefined + const isNumberEncoding = isJsonSchemaNumberEncoding(left) + if (leftType !== undefined || isNumberEncoding) { + const extracted = extractJsonSchemaNumberType(right) + if (extracted.type !== undefined) { + const type = leftType === "integer" || extracted.type === "integer" ? "integer" : "number" + const base: JsonSchema.JsonSchema = { ...left, type } + if (isNumberEncoding) delete base.anyOf + return Object.keys(extracted.schema).length === 0 ? base : appendJsonSchema(base, extracted.schema) + } + } + const members = Array.isArray(right.allOf) && rightKeys.length === 1 ? right.allOf : [right] + if (Array.isArray(left.allOf)) { + return { ...left, allOf: [...left.allOf, ...members] } + } + if (typeof left.$ref === "string") { + return { allOf: [left, ...members] } + } + return { ...left, allOf: members } +} + +function compileJsonSchema( + representations: readonly [ + SchemaRepresentation.Representation, + ...Array + ], + rootPaths: ReadonlyArray, + references: SchemaRepresentation.References, + options: Schema.ToJsonSchemaOptions | undefined +): JsonSchema.MultiDocument<"draft-2020-12"> { + const definitions: Record = {} + for (const key of Object.keys(references)) { + definitions[key] = recur(references[key], ["references", key]) + } + const schemas = Arr.map(representations, (representation, index) => recur(representation, rootPaths[index])) + return { dialect: "draft-2020-12", schemas, definitions } + + function annotationSchemas( + representation: CheckRepresentationAnnotation | undefined, + path: Path + ): ReadonlyArray { + return representation?.schemas?.map((schema, index) => recur(schema, [...path, "schemas", index])) ?? [] + } + + function compileCheck( + check: SchemaRepresentation.Check, + type: JsonSchema.Type | undefined, + path: Path + ): JsonSchema.JsonSchema | undefined { + const annotations = check.annotations + const callback = annotations?.toJsonSchema + if (callback !== undefined) { + const schemas = annotationSchemas(check.representation, [...path, "representation"]) + const fragment = (callback as SchemaRepresentation.ToJsonSchema.Check)({ type, schemas }) + const ordinary = collectJsonSchemaAnnotations(annotations, options) + return ordinary === undefined ? fragment : { ...fragment, ...ordinary } + } + if (check._tag === "Filter") return undefined + + const children = check.checks + .map((child, index) => compileCheck(child, type, [...path, "checks", index])) + .filter((child): child is JsonSchema.JsonSchema => child !== undefined) + if (children.length === 0) return undefined + const ordinary = collectJsonSchemaAnnotations(annotations, options) + return ordinary === undefined ? { allOf: children } : { allOf: children, ...ordinary } + } + + function recur( + representation: SchemaRepresentation.Representation, + path: Path + ): JsonSchema.JsonSchema { + if (representation._tag === "Reference") { + if (!Object.hasOwn(references, representation.$ref)) { + throw errorWithPath(`Invalid reference ${representation.$ref}`, [...path, "$ref"]) + } + return { $ref: `#/$defs/${escapeToken(representation.$ref)}` } + } + + let output = on(representation, path) + const ordinary = collectJsonSchemaAnnotations(representation.annotations, options) + if (ordinary !== undefined) { + output = { ...output, ...ordinary } + } + for (let index = 0; index < representation.checks.length; index++) { + const type = typeof output.type === "string" && isJsonSchemaType(output.type) ? output.type : undefined + const check = compileCheck(representation.checks[index], type, [...path, "checks", index]) + if (check !== undefined) { + output = appendJsonSchema(output, check) + } + } + return output + } + + function on( + representation: Exclude, + path: Path + ): JsonSchema.JsonSchema { + switch (representation._tag) { + case "Any": + case "Unknown": + return {} + case "ObjectKeyword": + return { anyOf: [{ type: "object" }, { type: "array" }] } + case "Void": + case "Undefined": + case "Null": + return { type: "null" } + case "BigInt": + return { type: "string", allOf: [{ pattern: "^-?\\d+$" }] } + case "Symbol": + case "UniqueSymbol": + return { type: "string", allOf: [{ pattern: "^Symbol\\((.*)\\)$" }] } + case "Declaration": { + return {} + } + case "Suspend": + return recur(representation.thunk, [...path, "thunk"]) + case "Never": + return { not: {} } + case "String": + return { type: "string" } + case "Number": + return { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["NaN"] }, + { type: "string", enum: ["Infinity"] }, + { type: "string", enum: ["-Infinity"] } + ] + } + case "Boolean": + return { type: "boolean" } + case "Literal": + return typeof representation.literal === "bigint" + ? { type: "string", enum: [globalThis.String(representation.literal)] } + : { type: typeof representation.literal, enum: [representation.literal] } + case "Enum": { + const types = representation.enums.map(([title, literal]) => ({ + type: typeof literal, + enum: [literal], + title + })) + return types.length === 0 ? { not: {} } : { anyOf: types } + } + case "TemplateLiteral": + return { type: "string", pattern: `^${representation.parts.map(getPartPattern).join("")}$` } + case "Arrays": { + if (representation.rest.length > 1) { + throw errorWithPath("Invalid schema representation document", [...path, "rest"]) + } + const out: JsonSchema.JsonSchema = { type: "array" } + let minItems = representation.elements.length + const prefixItems = representation.elements.map((element, index) => { + if (element.isOptional) minItems-- + const compiled = recur(element.type, [...path, "elements", index, "type"]) + const annotations = collectJsonSchemaAnnotations(element.annotations, options) + return annotations === undefined ? compiled : appendJsonSchema(compiled, annotations) + }) + if (prefixItems.length > 0) { + out.prefixItems = prefixItems + out.maxItems = representation.elements.length + if (minItems > 0) out.minItems = minItems + } else { + out.items = false + } + if (representation.rest.length === 1) { + delete out.maxItems + const rest = recur(representation.rest[0], [...path, "rest", 0]) + if (Object.keys(rest).length > 0) out.items = rest + else delete out.items + } + return out + } + case "Objects": { + if (representation.propertySignatures.length === 0 && representation.indexSignatures.length === 0) { + return { anyOf: [{ type: "object" }, { type: "array" }] } + } + const out: JsonSchema.JsonSchema = { type: "object" } + const properties: Record = {} + const required: Array = [] + for (let index = 0; index < representation.propertySignatures.length; index++) { + const property = representation.propertySignatures[index] + if (typeof property.name !== "string") { + throw errorWithPath("Invalid schema representation document", [ + ...path, + "propertySignatures", + index, + "name" + ]) + } + const compiled = recur(property.type, [...path, "propertySignatures", index, "type"]) + const annotations = collectJsonSchemaAnnotations(property.annotations, options) + properties[property.name] = annotations === undefined ? compiled : appendJsonSchema(compiled, annotations) + if (!property.isOptional) required.push(property.name) + } + if (representation.propertySignatures.length > 0) out.properties = properties + if (required.length > 0) out.required = required + out.additionalProperties = options?.additionalProperties ?? false + const patternProperties: Record = {} + for (let index = 0; index < representation.indexSignatures.length; index++) { + const signature = representation.indexSignatures[index] + let type: JsonSchema.JsonSchema | false = recur( + signature.type, + [...path, "indexSignatures", index, "type"] + ) + if (Object.keys(type).length === 1 && "not" in type) type = false + const patterns = getParameterPatterns( + signature.parameter, + [...path, "indexSignatures", index, "parameter"], + new Set() + ) + if (patterns.length === 0) { + out.additionalProperties = type + } else { + for (const pattern of patterns) patternProperties[pattern] = type + } + } + if (Object.keys(patternProperties).length > 0) { + out.patternProperties = patternProperties + delete out.additionalProperties + } + if ( + typeof out.additionalProperties === "object" && + out.additionalProperties !== null && + Object.keys(out.additionalProperties).length === 0 + ) { + delete out.additionalProperties + } + return out + } + case "Union": { + const types = representation.types.map((type, index) => recur(type, [...path, "types", index])) + if (types.length === 0) return { not: {} } + if (types.length > 1) { + const compacted = compactEnums(types) + if (compacted !== undefined) return compacted + } + return representation.mode === "anyOf" ? { anyOf: types } : { oneOf: types } + } + } + } + + function getParameterPatterns( + parameter: SchemaRepresentation.Representation, + path: Path, + seenReferences: ReadonlySet + ): ReadonlyArray { + switch (parameter._tag) { + case "Reference": { + if (!Object.hasOwn(references, parameter.$ref)) { + throw errorWithPath(`Invalid reference ${parameter.$ref}`, [...path, "$ref"]) + } + if (seenReferences.has(parameter.$ref)) return [] + const next = new Set(seenReferences).add(parameter.$ref) + return getParameterPatterns(references[parameter.$ref], ["references", parameter.$ref], next) + } + case "String": + return collectPatterns(recur(parameter, path)) + case "TemplateLiteral": + return [`^${parameter.parts.map(getPartPattern).join("")}$`] + case "Union": + return parameter.types.flatMap((type, index) => + getParameterPatterns(type, [...path, "types", index], seenReferences) + ) + default: + throw errorWithPath("Invalid schema representation document", path) + } + } +} + +function isJsonSchemaType(input: string): input is JsonSchema.Type { + return input === "string" || input === "number" || input === "boolean" || input === "array" || + input === "object" || input === "null" || input === "integer" +} + +function compactEnums( + schemas: ReadonlyArray +): JsonSchema.JsonSchema | undefined { + let sharedType: unknown = undefined + const values: Array = [] + for (const schema of schemas) { + const keys = Object.keys(schema) + if (keys.length !== 2 || schema.type === undefined || !Array.isArray(schema.enum) || schema.enum.length === 0) { + return undefined + } + if (sharedType === undefined) sharedType = schema.type + else if (schema.type !== sharedType) return undefined + values.push(...schema.enum) + } + return { type: sharedType, enum: values } +} + +function collectPatterns(schema: JsonSchema.JsonSchema): ReadonlyArray { + const patterns: Array = [] + if (typeof schema.pattern === "string") patterns.push(schema.pattern) + for (const key of ["allOf", "anyOf", "oneOf"] as const) { + const members = schema[key] + if (Array.isArray(members)) { + for (const member of members) { + if (typeof member === "object" && member !== null && !Array.isArray(member)) { + patterns.push(...collectPatterns(member)) + } + } + } + } + return patterns +} + +function getPartPattern(part: SchemaRepresentation.Representation): string { + switch (part._tag) { + case "Literal": + return RegEx.escape(globalThis.String(part.literal)) + case "String": + return SchemaAST.STRING_PATTERN + case "Number": + return SchemaAST.FINITE_PATTERN + case "TemplateLiteral": + return part.parts.map(getPartPattern).join("") + case "Union": + return part.types.map(getPartPattern).join("|") + default: + throw errorWithPath("Invalid schema representation document", []) + } +} + +/** @internal */ +export function toJsonSchemaDocument( + document: SchemaRepresentation.Document, + options?: Schema.ToJsonSchemaOptions +): JsonSchema.Document<"draft-2020-12"> { + const output = compileJsonSchema( + [document.representation], + [["representation"]], + document.references, + options + ) + return { + dialect: output.dialect, + schema: output.schemas[0], + definitions: output.definitions + } +} + +/** @internal */ +export function toJsonSchemaMultiDocument( + document: SchemaRepresentation.MultiDocument, + options?: Schema.ToJsonSchemaOptions +): JsonSchema.MultiDocument<"draft-2020-12"> { + return compileJsonSchema( + document.representations, + document.representations.map((_, index) => ["representations", index]), + document.references, + options + ) +} diff --git a/packages/effect/src/internal/schema/toRepresentation.ts b/packages/effect/src/internal/schema/toRepresentation.ts new file mode 100644 index 00000000000..5d74c70b374 --- /dev/null +++ b/packages/effect/src/internal/schema/toRepresentation.ts @@ -0,0 +1,561 @@ +import * as Arr from "../../Array.ts" +import * as Equal from "../../Equal.ts" +import type * as Schema from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" +import * as InternalRecord from "../record.ts" +import * as InternalAnnotations from "./annotations.ts" + +/** @internal */ +export function toRepresentation( + ast: SchemaAST.AST, + encoded = false +): SchemaRepresentation.Document { + const { references, representations } = toRepresentations([ast], encoded) + return { representation: representations[0], references } +} + +/** @internal */ +export function toRepresentations( + asts: readonly [SchemaAST.AST, ...Array], + encoded = false +): SchemaRepresentation.MultiDocument { + return lowerASTs(encoded ? asts : Arr.map(asts, (ast) => SchemaAST.toType(ast)), [], encoded) +} + +type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation< + SchemaRepresentation.Representation +> + +function annotationsField(annotations: A | undefined): { readonly annotations: A } | undefined { + return annotations === undefined ? undefined : { annotations } +} + +function projectCheckRepresentationAnnotation( + input: CheckRepresentationAnnotation +): CheckRepresentationAnnotation { + return input.schemas === undefined + ? input + : { ...input, schemas: input.schemas.map(projectRepresentation) } +} + +function projectAnnotationBag( + input: Readonly> | undefined +): Schema.Annotations.Annotations | undefined { + if (input === undefined) return undefined + + const out: Record = {} + for (const [key, value] of Object.entries(input)) { + if (SchemaAST.isJson(value)) InternalRecord.set(out, key, value) + } + + return Object.keys(out).length === 0 + ? undefined + : out +} + +function projectCheck(check: SchemaRepresentation.Check): SchemaRepresentation.Check { + const representation = check.representation === undefined + ? undefined + : projectCheckRepresentationAnnotation(check.representation) + const fields = { + ...(representation === undefined ? undefined : { representation }), + ...annotationsField(projectAnnotationBag(check.annotations)) + } + switch (check._tag) { + case "Filter": + return { + _tag: "Filter", + aborted: check.aborted, + ...fields + } + case "FilterGroup": + return { + _tag: "FilterGroup", + checks: Arr.map(check.checks, projectCheck), + ...fields + } + } +} + +function projectRepresentation( + representation: SchemaRepresentation.Representation +): SchemaRepresentation.Representation { + if (representation._tag === "Reference") return representation + + const annotations = projectAnnotationBag(representation.annotations) + + if (representation._tag === "Suspend") { + return { + _tag: "Suspend", + checks: [], + thunk: projectRepresentation(representation.thunk), + ...annotationsField(annotations) + } + } + + const checks = representation.checks.map(projectCheck) + const fields = { checks, ...annotationsField(annotations) } + + switch (representation._tag) { + case "Declaration": + return { + _tag: "Declaration", + ...(representation.representation === undefined + ? undefined + : { representation: representation.representation }), + typeParameters: representation.typeParameters.map(projectRepresentation), + ...fields + } + case "Null": + case "Undefined": + case "Void": + case "Never": + case "Unknown": + case "Any": + case "String": + case "Number": + case "Boolean": + case "BigInt": + case "Symbol": + case "ObjectKeyword": + return { + _tag: representation._tag, + ...fields + } + case "Literal": + return { + _tag: "Literal", + literal: representation.literal, + ...fields + } + case "UniqueSymbol": + return { + _tag: "UniqueSymbol", + symbol: representation.symbol, + ...fields + } + case "Enum": + return { + _tag: "Enum", + enums: representation.enums, + ...fields + } + case "TemplateLiteral": + return { + _tag: "TemplateLiteral", + parts: representation.parts.map(projectRepresentation), + ...fields + } + case "Arrays": + return { + _tag: "Arrays", + elements: representation.elements.map((element) => ({ + isOptional: element.isOptional, + type: projectRepresentation(element.type), + ...annotationsField(projectAnnotationBag(element.annotations)) + })), + rest: representation.rest.map(projectRepresentation), + ...fields + } + case "Objects": + return { + _tag: "Objects", + propertySignatures: representation.propertySignatures.map((property) => ({ + name: property.name, + isOptional: property.isOptional, + isMutable: property.isMutable, + type: projectRepresentation(property.type), + ...annotationsField(projectAnnotationBag(property.annotations)) + })), + indexSignatures: representation.indexSignatures.map((index) => ({ + parameter: projectRepresentation(index.parameter), + type: projectRepresentation(index.type) + })), + ...fields + } + case "Union": + return { + _tag: "Union", + types: representation.types.map(projectRepresentation), + mode: representation.mode, + ...fields + } + } +} + +function projectReferences(references: SchemaRepresentation.References): SchemaRepresentation.References { + const out: Record = {} + for (const [key, value] of Object.entries(references)) { + InternalRecord.set(out, key, projectRepresentation(value)) + } + return out +} + +/** @internal */ +export function projectDocument( + document: SchemaRepresentation.Document +): SchemaRepresentation.Document { + return { + representation: projectRepresentation(document.representation), + references: projectReferences(document.references) + } +} + +/** @internal */ +export function projectMultiDocument( + document: SchemaRepresentation.MultiDocument +): SchemaRepresentation.MultiDocument { + return { + representations: Arr.map(document.representations, projectRepresentation), + references: projectReferences(document.references) + } +} + +/** @internal */ +export function fromSchemaMultiDocument( + document: SchemaRepresentation.SchemaMultiDocument +): SchemaRepresentation.MultiDocument { + const definitions = Object.entries(document.definitions).map(([key, schema]) => { + const original = schema.ast + const encoded = SchemaAST.toEncoded(original) + const body = SchemaAST.isSuspend(encoded) ? encoded.thunk() : encoded + return { key, original, encoded, body } + }) + const asts = Arr.map(document.schemas, (schema) => SchemaAST.toEncoded(schema.ast)) + return lowerASTs(asts, definitions) +} + +interface ExternalDefinition { + readonly key: string + readonly original: SchemaAST.AST + readonly encoded: SchemaAST.AST + readonly body: SchemaAST.AST +} + +// Preserve repeated structural nodes as references without adding noise for leaf nodes. +function isShareable(ast: SchemaAST.AST): boolean { + return SchemaAST.isArrays(ast) || + SchemaAST.isObjects(ast) || + (SchemaAST.isUnion(ast) && ast.types.some(isShareable)) +} + +function resolveReferenceIdentifier(ast: SchemaAST.AST): string | undefined { + const identifier = InternalAnnotations.resolveIdentifier(ast) + if (identifier !== undefined) return identifier + const fallback = InternalAnnotations.resolveIdentifierFallback(ast) + return fallback === undefined ? undefined : `${fallback}JsonEncoding` +} + +function lowerASTs( + asts: readonly [SchemaAST.AST, ...Array], + externalDefinitions: ReadonlyArray, + encoded = false +): SchemaRepresentation.MultiDocument { + const references: Record = {} + const referenceMap = new Map() + const uniqueReferences = new Set(externalDefinitions.map((definition) => definition.key)) + const visiting = new Set() + const visited = new Set() + const shared = new Set() + const externalBodyReferences = new Map() + + for (const definition of externalDefinitions) { + referenceMap.set(definition.original, definition.key) + referenceMap.set(definition.encoded, definition.key) + externalBodyReferences.set( + definition.body, + externalBodyReferences.has(definition.body) ? null : definition.key + ) + } + + for (const ast of asts) visit(ast) + for (const definition of externalDefinitions) visit(definition.body) + + const representations = Arr.map(asts, (ast) => recur(ast)) + + for (const definition of externalDefinitions) { + references[definition.key] = recur(definition.body, definition.key) + } + + return { representations, references } + + function generateReference(prefix: string): string { + let candidate = prefix + let suffix = 0 + while (uniqueReferences.has(candidate)) { + candidate = `${prefix}${++suffix}` + } + uniqueReferences.add(candidate) + return candidate + } + + function visit(input: SchemaAST.AST): void { + const ast = encoded ? SchemaAST.getLastEncoding(input) : input + if (visited.has(ast)) { + if (isShareable(ast)) shared.add(ast) + return + } + visited.add(ast) + visitChecks(ast.checks) + switch (ast._tag) { + case "Declaration": + case "Arrays": + case "Objects": + case "Union": + ast.recur((child) => { + visit(child) + return child + }) + break + case "TemplateLiteral": + ast.parts.forEach(visit) + break + case "Suspend": + visit(ast.thunk()) + break + } + } + + function visitChecks(checks: SchemaAST.Checks | undefined): void { + checks?.forEach((check) => { + check.annotations?.representation?.schemas?.forEach((schema) => visit(SchemaAST.toType(schema))) + if (check._tag === "FilterGroup") visitChecks(check.checks) + }) + } + + function recur( + ast: SchemaAST.AST, + ownedReference?: string + ): SchemaRepresentation.Representation { + let found = referenceMap.get(ast) + if (found === undefined && SchemaAST.isSuspend(ast)) { + const bodyReference = externalBodyReferences.get(ast.thunk()) + if (bodyReference !== undefined && bodyReference !== null) { + found = bodyReference + referenceMap.set(ast, bodyReference) + } + } + if (found !== undefined && found !== ownedReference) { + return { _tag: "Reference", $ref: found } + } + + if (encoded) { + const projected = SchemaAST.getLastEncoding(ast) + if (projected !== ast) { + return recur(projected, ownedReference) + } + } + + const identifier = ownedReference === undefined ? resolveReferenceIdentifier(ast) : undefined + if (identifier !== undefined) { + const reference = generateReference(identifier) + referenceMap.set(ast, reference) + const representation = on(ast) + const existing = references[identifier] + if (existing !== undefined && Equal.equals(representation, existing)) { + referenceMap.set(ast, identifier) + return { _tag: "Reference", $ref: identifier } + } + references[reference] = representation + return { _tag: "Reference", $ref: reference } + } + + if (ownedReference === undefined && shared.has(ast)) { + const reference = generateReference(`${ast._tag}_`) + referenceMap.set(ast, reference) + references[reference] = on(ast) + return { _tag: "Reference", $ref: reference } + } + + if (visiting.has(ast)) { + const reference = generateReference(`${ast._tag}_`) + referenceMap.set(ast, reference) + return { _tag: "Reference", $ref: reference } + } + + visiting.add(ast) + const representation = on(ast) + visiting.delete(ast) + + const reference = referenceMap.get(ast) + if (reference !== undefined && reference !== ownedReference) { + references[reference] = representation + return { _tag: "Reference", $ref: reference } + } + + return representation + } + + function on(ast: SchemaAST.AST): SchemaRepresentation.Representation { + const checks = fromChecks(ast.checks) + switch (ast._tag) { + case "Declaration": + return { + _tag: "Declaration", + typeParameters: ast.typeParameters.map((ast) => recur(ast)), + checks, + ...fromDeclarationAnnotations(ast.annotations) + } + case "Null": + case "Undefined": + case "Void": + case "Never": + case "Unknown": + case "Any": + case "String": + case "Boolean": + case "Number": + case "BigInt": + case "Symbol": + case "ObjectKeyword": + return { + _tag: ast._tag, + checks, + ...annotationsField(ast.annotations) + } + case "Literal": + return { + _tag: "Literal", + literal: ast.literal, + checks, + ...annotationsField(ast.annotations) + } + case "UniqueSymbol": + return { + _tag: "UniqueSymbol", + symbol: ast.symbol, + checks, + ...annotationsField(ast.annotations) + } + case "Enum": + return { + _tag: "Enum", + enums: ast.enums, + checks, + ...annotationsField(ast.annotations) + } + case "TemplateLiteral": + return { + _tag: "TemplateLiteral", + parts: ast.parts.map((ast) => recur(ast)), + checks, + ...annotationsField(ast.annotations) + } + case "Arrays": + return { + _tag: "Arrays", + elements: ast.elements.map((element) => { + const projected = encoded ? SchemaAST.getLastEncoding(element) : element + const annotations = projected.context?.annotations + return { + isOptional: SchemaAST.isOptional(projected), + type: recur(element), + ...annotationsField(annotations) + } + }), + rest: ast.rest.map((ast) => recur(ast)), + checks, + ...annotationsField(ast.annotations) + } + case "Objects": + return { + _tag: "Objects", + propertySignatures: ast.propertySignatures.map((property) => { + const projected = encoded ? SchemaAST.getLastEncoding(property.type) : property.type + const annotations = projected.context?.annotations + return { + name: property.name, + type: recur(property.type), + isOptional: SchemaAST.isOptional(projected), + isMutable: SchemaAST.isMutable(projected), + ...annotationsField(annotations) + } + }), + indexSignatures: ast.indexSignatures.map((index) => ({ + parameter: recur(index.parameter), + type: recur(index.type) + })), + checks, + ...annotationsField(ast.annotations) + } + case "Union": + return { + _tag: "Union", + types: ast.types.map((ast) => recur(ast)), + mode: ast.mode, + checks, + ...annotationsField(ast.annotations) + } + case "Suspend": + return { + _tag: "Suspend", + checks: [], + thunk: recur(ast.thunk()), + ...annotationsField(ast.annotations) + } + } + } + + function fromChecks( + checks: readonly [SchemaAST.Check, ...Array>] | undefined + ): Array { + return checks?.map(fromCheck) ?? [] + } + + function fromCheck( + check: SchemaAST.Check + ): SchemaRepresentation.Check { + switch (check._tag) { + case "Filter": + return { + _tag: "Filter", + aborted: check.aborted, + ...fromCheckAnnotations(check.annotations) + } + case "FilterGroup": + return { + _tag: "FilterGroup", + checks: Arr.map(check.checks, fromCheck), + ...fromCheckAnnotations(check.annotations) + } + } + } + + function fromDeclarationAnnotations< + A extends Schema.Annotations.Annotations & { + readonly representation?: SchemaRepresentation.RepresentationAnnotation | undefined + } + >(annotations: A | undefined): { + readonly representation?: SchemaRepresentation.RepresentationAnnotation | undefined + readonly annotations?: Omit | undefined + } | undefined { + if (annotations === undefined) return undefined + const { representation, ...ordinary } = annotations + return { + ...(representation === undefined ? undefined : { representation }), + ...(Object.keys(ordinary).length === 0 ? undefined : { annotations: ordinary }) + } + } + + function fromCheckAnnotations< + A extends Schema.Annotations.Annotations & { + readonly representation?: SchemaRepresentation.CheckRepresentationAnnotation | undefined + } + >(annotations: A | undefined): { + readonly representation?: CheckRepresentationAnnotation | undefined + readonly annotations?: Omit | undefined + } | undefined { + if (annotations === undefined) return undefined + const { representation, ...ordinary } = annotations + const projected = representation === undefined + ? undefined + : representation.schemas === undefined + ? representation as CheckRepresentationAnnotation + : { ...representation, schemas: representation.schemas.map((schema) => recur(SchemaAST.toType(schema))) } + return { + ...(projected === undefined ? undefined : { representation: projected }), + ...(Object.keys(ordinary).length === 0 ? undefined : { annotations: ordinary }) + } + } +} diff --git a/packages/effect/src/internal/stackTraceLimit.ts b/packages/effect/src/internal/stackTraceLimit.ts index 440e18044d5..7a7e7df8dff 100644 --- a/packages/effect/src/internal/stackTraceLimit.ts +++ b/packages/effect/src/internal/stackTraceLimit.ts @@ -16,10 +16,6 @@ */ import type { ErrorWithStackTraceLimit } from "./tracer.ts" -const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor -const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty -const ObjectIsExtensible = Object.isExtensible - /** * Check if `Error.stackTraceLimit` is writable. * Returns `false` if the property is frozen, non-writable, or `Error` is non-extensible. @@ -27,12 +23,12 @@ const ObjectIsExtensible = Object.isExtensible * @internal */ export const isStackTraceLimitWritable = (): boolean => { - const desc = ObjectGetOwnPropertyDescriptor(Error, "stackTraceLimit") + const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit") if (desc === undefined) { - return ObjectIsExtensible(Error) + return Object.isExtensible(Error) } - return ObjectPrototypeHasOwnProperty.call(desc, "writable") + return Object.hasOwn(desc, "writable") ? desc.writable === true : desc.set !== undefined } diff --git a/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts b/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts index 634f9d3d9fd..5ae8e73eac0 100644 --- a/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts +++ b/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts @@ -58,13 +58,23 @@ export function toCodecAnthropic( ): { readonly codec: Schema.ConstraintCodec readonly jsonSchema: JsonSchema.JsonSchema +} { + return toCodecAnthropicWith(schema, Schema.toJsonSchemaDocument) +} + +function toCodecAnthropicWith( + schema: Schema.ConstraintCodec, + toJsonSchemaDocument: (schema: Schema.Constraint) => JsonSchema.Document<"draft-2020-12"> +): { + readonly codec: Schema.ConstraintCodec + readonly jsonSchema: JsonSchema.JsonSchema } { const to = schema.ast const from = recur(SchemaAST.toEncoded(to)) const codec = from === to ? schema : Schema.make(SchemaAST.decodeTo(from, to, SchemaTransformation.passthrough())) - const document = JsonSchema.resolveTopLevel$ref(Schema.toJsonSchemaDocument(codec)) + const document = JsonSchema.resolveTopLevel$ref(toJsonSchemaDocument(codec)) const jsonSchema = { ...document.schema } if (Object.keys(document.definitions).length > 0) { jsonSchema.$defs = document.definitions @@ -331,8 +341,9 @@ const getChecks = (ast: SchemaAST.AST): Array => [ const getAnnotations = (annotations: Schema.Annotations.Filter | undefined): Array => { const out: Array = [] if (annotations !== undefined) { + const id = annotations.representation?.id const description = annotations?.description - ?? (annotations.meta?._tag === "isInt" || annotations.meta?._tag === "isFinite" + ?? (id === "effect/schema/isInt" || id === "effect/schema/isFinite" ? undefined : annotations?.expected) if (typeof description === "string") { @@ -353,11 +364,11 @@ const getAnnotations = (annotations: Schema.Annotations.Filter | undefined): Arr function getFilter(filter: SchemaAST.Filter): Array { let out: Array = [] const annotations = getAnnotations(filter.annotations) - const meta = filter.annotations?.meta - if (meta !== undefined) { - switch (meta._tag) { - case "isInt": - case "isFinite": { + const id = filter.annotations?.representation?.id + if (id !== undefined) { + switch (id) { + case "effect/schema/isInt": + case "effect/schema/isFinite": { out = out.concat(annotations) out.push({ _tag: "filter", filter: resetFilter(filter) }) break @@ -367,13 +378,17 @@ function getFilter(filter: SchemaAST.Filter): Array { break } } - if ("regExp" in meta && meta.regExp instanceof RegExp) { + if (isPattern(filter)) { out.push({ _tag: "filter", filter: resetFilter(filter) }) } } return out } +function isPattern(filter: SchemaAST.Filter): boolean { + return filter.annotations?.toJsonSchema?.({ type: undefined, schemas: [] }).pattern !== undefined +} + function resetFilter(filter: SchemaAST.Filter): SchemaAST.Filter { return filter.annotate({ description: undefined, diff --git a/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts b/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts index 54defd5d982..df81006c234 100644 --- a/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts +++ b/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts @@ -55,13 +55,23 @@ export function toCodecOpenAI( ): { codec: Schema.ConstraintCodec jsonSchema: JsonSchema.JsonSchema +} { + return toCodecOpenAIWith(schema, Schema.toJsonSchemaDocument) +} + +function toCodecOpenAIWith( + schema: Schema.ConstraintCodec, + toJsonSchemaDocument: (schema: Schema.Constraint) => JsonSchema.Document<"draft-2020-12"> +): { + codec: Schema.ConstraintCodec + jsonSchema: JsonSchema.JsonSchema } { const to = schema.ast const from = recurOpenAI(SchemaAST.toEncoded(to)) const codec = from === to ? schema : Schema.make(SchemaAST.decodeTo(from, to, SchemaTransformation.passthrough())) - const document = JsonSchema.resolveTopLevel$ref(Schema.toJsonSchemaDocument(codec)) + const document = JsonSchema.resolveTopLevel$ref(toJsonSchemaDocument(codec)) const jsonSchema = rewriteOpenAI(document.schema) if (Object.keys(document.definitions).length > 0) { jsonSchema.$defs = Rec.map(document.definitions, rewriteOpenAI) @@ -362,10 +372,10 @@ const get = (ast: SchemaAST.AST): { } // OpenAI does not support allOf, so we merge multiple regex patterns into a single isPattern filter if (regexSources.length === 1) { - filters.push(SchemaAST.isPattern(new RegExp(regexSources[0]))) + filters.push(Schema.isPattern(new RegExp(regexSources[0]))) } else if (regexSources.length > 1) { const combined = regexSources.map((s) => `(?=[\\s\\S]*?(?:${s}))`).join("") - filters.push(SchemaAST.isPattern(new RegExp(`^${combined}`))) + filters.push(Schema.isPattern(new RegExp(`^${combined}`))) } return { annotations: Object.keys(annotations).length > 0 ? annotations : undefined, @@ -381,8 +391,9 @@ const getChecks = (ast: SchemaAST.AST, isArray: boolean): Array => [ const getAnnotations = (annotations: Schema.Annotations.Filter | undefined): Array => { const out: Array = [] if (annotations !== undefined) { + const id = annotations.representation?.id const description = annotations?.description - ?? (annotations.meta?._tag === "isInt" || annotations.meta?._tag === "isFinite" + ?? (id === "effect/schema/isInt" || id === "effect/schema/isFinite" ? undefined : annotations?.expected) if (typeof description === "string") { @@ -403,26 +414,26 @@ const getAnnotations = (annotations: Schema.Annotations.Filter | undefined): Arr function getFilter(filter: SchemaAST.Filter, isArray: boolean): Array { let out: Array = [] const annotations = getAnnotations(filter.annotations) - const meta = filter.annotations?.meta - if (meta !== undefined) { - switch (meta._tag) { - case "isMinLength": - case "isMaxLength": - case "isLengthBetween": { + const id = filter.annotations?.representation?.id + if (id !== undefined) { + switch (id) { + case "effect/schema/isMinLength": + case "effect/schema/isMaxLength": + case "effect/schema/isLengthBetween": { out = out.concat(annotations) if (isArray) { out.push({ _tag: "filter", filter: resetFilter(filter) }) } break } - case "isInt": - case "isFinite": - case "isGreaterThan": - case "isGreaterThanOrEqualTo": - case "isLessThan": - case "isLessThanOrEqualTo": - case "isBetween": - case "isMultipleOf": { + case "effect/schema/isInt": + case "effect/schema/isFinite": + case "effect/schema/isGreaterThan": + case "effect/schema/isGreaterThanOrEqualTo": + case "effect/schema/isLessThan": + case "effect/schema/isLessThanOrEqualTo": + case "effect/schema/isBetween": + case "effect/schema/isMultipleOf": { out = out.concat(annotations) out.push({ _tag: "filter", filter: resetFilter(filter) }) break @@ -432,13 +443,18 @@ function getFilter(filter: SchemaAST.Filter, isArray: boolean): Array): string | undefined { + return filter.annotations?.toJsonSchema?.({ type: undefined, schemas: [] }).pattern as string | undefined +} + function resetFilter(filter: SchemaAST.Filter): SchemaAST.Filter { return filter.annotate({ description: undefined, diff --git a/packages/effect/src/unstable/ai/Tool.ts b/packages/effect/src/unstable/ai/Tool.ts index 775e9a136f5..00ba1faaf67 100644 --- a/packages/effect/src/unstable/ai/Tool.ts +++ b/packages/effect/src/unstable/ai/Tool.ts @@ -1666,10 +1666,18 @@ export const getJsonSchema = (tool: Tool, options?: { export const getJsonSchemaFromSchema = (schema: S, options?: { readonly transformer?: CodecTransformer }): JsonSchema.JsonSchema => { + return getJsonSchemaFromSchemaWith(schema, Schema.toJsonSchemaDocument, options) +} + +const getJsonSchemaFromSchemaWith = ( + schema: S, + toJsonSchemaDocument: (schema: Schema.Constraint) => JsonSchema.Document<"draft-2020-12">, + options?: { readonly transformer?: CodecTransformer } +): JsonSchema.JsonSchema => { if (Predicate.isNotUndefined(options?.transformer)) { return options.transformer(schema).jsonSchema } - const document = Schema.toJsonSchemaDocument(schema) + const document = toJsonSchemaDocument(schema) if (Object.keys(document.definitions).length > 0) { document.schema.$defs = document.definitions } @@ -1915,13 +1923,13 @@ function filter(obj: any) { next = [] for (const node of nodes) { - if (Object.prototype.hasOwnProperty.call(node, "__proto__")) { + if (Object.hasOwn(node, "__proto__")) { throw new SyntaxError("Object contains forbidden prototype property") } if ( - Object.prototype.hasOwnProperty.call(node, "constructor") && - Object.prototype.hasOwnProperty.call(node.constructor, "prototype") + Object.hasOwn(node, "constructor") && + Object.hasOwn(node.constructor, "prototype") ) { throw new SyntaxError("Object contains forbidden prototype property") } diff --git a/packages/effect/src/unstable/ai/internal/codec-transformer.ts b/packages/effect/src/unstable/ai/internal/codec-transformer.ts index f1d15f1b7c6..7d3b858b79a 100644 --- a/packages/effect/src/unstable/ai/internal/codec-transformer.ts +++ b/packages/effect/src/unstable/ai/internal/codec-transformer.ts @@ -2,12 +2,18 @@ import * as JsonSchema from "../../../JsonSchema.ts" import * as Schema from "../../../Schema.ts" import type { CodecTransformer } from "../LanguageModel.ts" -/** @internal */ -export const defaultCodecTransformer: CodecTransformer = (codec) => { - const document = JsonSchema.resolveTopLevel$ref(Schema.toJsonSchemaDocument(codec)) - const jsonSchema = { ...document.schema } - if (Object.keys(document.definitions).length > 0) { - jsonSchema.$defs = document.definitions +const makeDefaultCodecTransformer = ( + toJsonSchemaDocument: (schema: Schema.Constraint) => JsonSchema.Document<"draft-2020-12"> +): CodecTransformer => { + return (codec) => { + const document = JsonSchema.resolveTopLevel$ref(toJsonSchemaDocument(codec)) + const jsonSchema = { ...document.schema } + if (Object.keys(document.definitions).length > 0) { + jsonSchema.$defs = document.definitions + } + return { codec, jsonSchema } } - return { codec, jsonSchema } } + +/** @internal */ +export const defaultCodecTransformer = makeDefaultCodecTransformer(Schema.toJsonSchemaDocument) diff --git a/packages/effect/src/unstable/http/Cookies.ts b/packages/effect/src/unstable/http/Cookies.ts index 2da2fe004af..e2b7d1d6c03 100644 --- a/packages/effect/src/unstable/http/Cookies.ts +++ b/packages/effect/src/unstable/http/Cookies.ts @@ -65,15 +65,6 @@ export interface CookiesSchema extends Schema.declare Schema.link()( @@ -146,14 +137,6 @@ export interface CookieSchema extends Schema.declare {} export const CookieSchema: CookieSchema = Schema.declare( isCookie, { - typeConstructor: { - _tag: "effect/http/Cookie" - }, - generation: { - runtime: `Cookies.CookieSchema`, - Type: `Cookies.Cookie`, - importDeclaration: `import * as Cookie from "effect/unstable/http/Cookies"` - }, expected: "Cookie" } ) diff --git a/packages/effect/src/unstable/http/Headers.ts b/packages/effect/src/unstable/http/Headers.ts index f0fb26744bd..065655c9bdf 100644 --- a/packages/effect/src/unstable/http/Headers.ts +++ b/packages/effect/src/unstable/http/Headers.ts @@ -130,15 +130,6 @@ export interface HeadersSchema extends Schema.declare Equivalence, toCodec: () => diff --git a/packages/effect/src/unstable/http/Multipart.ts b/packages/effect/src/unstable/http/Multipart.ts index 8df44a064c5..b5e7e42a589 100644 --- a/packages/effect/src/unstable/http/Multipart.ts +++ b/packages/effect/src/unstable/http/Multipart.ts @@ -276,6 +276,13 @@ export class MultipartError extends Data.TaggedError("MultipartError")<{ */ export interface PersistedFileSchema extends Schema.declare {} +const PersistedFileEncoded = Schema.Struct({ + key: Schema.String, + name: Schema.String, + contentType: Schema.String.annotate({ contentEncoding: "binary" }), + path: Schema.String +}) + /** * Schema for persisted multipart files. * @@ -290,23 +297,19 @@ export interface PersistedFileSchema extends Schema.declare {} export const PersistedFileSchema: PersistedFileSchema = Schema.declare( isPersistedFile, { - typeConstructor: { - _tag: "effect/http/PersistedFile" - }, - generation: { - runtime: `Multipart.PersistedFileSchema`, - Type: `Multipart.PersistedFile`, - importDeclaration: `import * as Multipart from "effect/unstable/http/Multipart"` + representation: { + id: "effect/http/PersistedFile", + payload: null }, + toCode: () => ({ + runtime: "Multipart.PersistedFileSchema", + Type: "Multipart.PersistedFile", + importDeclarations: [`import * as Multipart from "effect/unstable/http/Multipart"`] + }), expected: "PersistedFile", toCodecJson: () => Schema.link()( - Schema.Struct({ - key: Schema.String, - name: Schema.String, - contentType: Schema.String.annotate({ contentEncoding: "binary" }), - path: Schema.String - }), + PersistedFileEncoded, SchemaTransformation.transform({ decode: ({ contentType, key, name, path }) => new PersistedFileImpl(key, name, contentType, path), encode: (file) => ({ diff --git a/packages/effect/src/unstable/http/UrlParams.ts b/packages/effect/src/unstable/http/UrlParams.ts index c2483a24e1e..69e93557ad4 100644 --- a/packages/effect/src/unstable/http/UrlParams.ts +++ b/packages/effect/src/unstable/http/UrlParams.ts @@ -236,15 +236,6 @@ export interface UrlParamsSchema extends Schema.declare Equivalence, toCodec: () => diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index 31a709ef5af..8866e316dda 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -14,13 +14,14 @@ import * as Context from "../../Context.ts" import * as Equal from "../../Equal.ts" import { constFalse } from "../../Function.ts" import * as InternalRecord from "../../internal/record.ts" +import * as InternalToJsonSchemaDocument from "../../internal/schema/toJsonSchemaDocument.ts" +import * as InternalToRepresentation from "../../internal/schema/toRepresentation.ts" import * as JsonPatch from "../../JsonPatch.ts" import { escapeToken } from "../../JsonPointer.ts" import * as JsonSchema from "../../JsonSchema.ts" import * as Option from "../../Option.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" -import * as SchemaRepresentation from "../../SchemaRepresentation.ts" import * as HttpMethod from "../http/HttpMethod.ts" import * as HttpApi from "./HttpApi.ts" import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" @@ -212,6 +213,15 @@ export const annotations: ( const apiCache = new WeakMap() +type CompileSchemas = ( + asts: readonly [SchemaAST.AST, ...Array] +) => JsonSchema.MultiDocument<"openapi-3.1"> + +const compileSchemas: CompileSchemas = (asts) => + JsonSchema.toMultiDocumentOpenApi3_1( + InternalToJsonSchemaDocument.toJsonSchemaMultiDocument(InternalToRepresentation.toRepresentations(asts, true)) + ) + /** * This function checks if a given tag exists within the provided context. If * the tag is present, it retrieves the associated value and applies the given @@ -251,7 +261,15 @@ function processAnnotation( export function fromApi( api: HttpApi.HttpApi ): OpenAPISpec { - const cached = apiCache.get(api) + return fromApiWith(api, apiCache, compileSchemas) +} + +function fromApiWith( + api: HttpApi.HttpApi, + cache: WeakMap, + compileSchemas: CompileSchemas +): OpenAPISpec { + const cached = cache.get(api) if (cached !== undefined) { return cached } @@ -599,12 +617,7 @@ export function fromApi op.ast) - ) - const jsonSchemaMultiDocument = JsonSchema.toMultiDocumentOpenApi3_1( - SchemaRepresentation.toJsonSchemaMultiDocument(multiDocument) - ) + const jsonSchemaMultiDocument = compileSchemas(Arr.map(pathOps, (op) => op.ast)) const patchOps: Array = pathOps.map((op, i) => { const oppath = escapePath(op.path) const value = jsonSchemaMultiDocument.schemas[i] @@ -639,7 +652,7 @@ export function fromApi | undefined))?.typeConstructor?._tag === + ((ast.annotations as (Schema.Annotations.Declaration | undefined))?.representation?.id === "effect/http/PersistedFile") ) { return Uint8ArrayEncoding.ast diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 4829e50bce3..442a56d2ce0 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -9511,27 +9511,27 @@ describe("Check", () => { it("isStringFinite", async () => { const schema = Schema.String.check(Schema.isStringFinite()) - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isStringFinite", - regExp: /^[+-]?\d*\.?\d+(?:[Ee][+-]?\d+)?$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isStringFinite", + payload: null }) }) it("isStringBigInt", async () => { const schema = Schema.String.check(Schema.isStringBigInt()) - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isStringBigInt", - regExp: /^-?\d+$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isStringBigInt", + payload: null }) }) it("isStringSymbol", async () => { const schema = Schema.String.check(Schema.isStringSymbol()) - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isStringSymbol", - regExp: /^Symbol\((.*)\)$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isStringSymbol", + payload: null }) }) @@ -9543,11 +9543,9 @@ describe("Check", () => { asserts.arbitrary().verifyGeneration() } - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isUUID", - regExp: - /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$/, - version: undefined + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isUUID", + payload: { version: null } }) const decoding = asserts.decoding() @@ -9585,9 +9583,9 @@ describe("Check", () => { asserts.arbitrary().verifyGeneration() } - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isGUID", - regExp: /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isGUID", + payload: null }) const decoding = asserts.decoding() @@ -9607,9 +9605,9 @@ describe("Check", () => { asserts.arbitrary().verifyGeneration() } - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isULID", - regExp: /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isULID", + payload: null }) const decoding = asserts.decoding() @@ -9623,18 +9621,18 @@ describe("Check", () => { it("isBase64", async () => { const schema = Schema.String.check(Schema.isBase64()) - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isBase64", - regExp: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isBase64", + payload: null }) }) it("isBase64Url", async () => { const schema = Schema.String.check(Schema.isBase64Url()) - deepStrictEqual(Schema.resolveAnnotations(schema)?.["meta"], { - _tag: "isBase64Url", - regExp: /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/ + deepStrictEqual(Schema.resolveAnnotations(schema)?.representation, { + id: "effect/schema/isBase64Url", + payload: null }) }) diff --git a/packages/effect/test/schema/SchemaAST.test.ts b/packages/effect/test/schema/SchemaAST.test.ts index 0c6e88cbc9e..b9cf3333738 100644 --- a/packages/effect/test/schema/SchemaAST.test.ts +++ b/packages/effect/test/schema/SchemaAST.test.ts @@ -19,8 +19,20 @@ describe("SchemaAST", () => { strictEqual(SchemaAST.isJson(Symbol.for("symbol")), false) strictEqual(SchemaAST.isJson([]), true) strictEqual(SchemaAST.isJson([1]), true) + strictEqual(SchemaAST.isJson(new Array(1)), false) strictEqual(SchemaAST.isJson([1, undefined]), false) strictEqual(SchemaAST.isJson([1, 1n]), false) + let getterCalls = 0 + const arrayWithGetter = [0] + Object.defineProperty(arrayWithGetter, "0", { + enumerable: true, + get() { + getterCalls++ + return 1 + } + }) + strictEqual(SchemaAST.isJson(arrayWithGetter), true) + strictEqual(getterCalls, 1) strictEqual(SchemaAST.isJson({}), true) strictEqual(SchemaAST.isJson({ a: 1 }), true) strictEqual(SchemaAST.isJson({ a: undefined }), false) diff --git a/packages/effect/test/schema/representation/builtInRevivers.test.ts b/packages/effect/test/schema/representation/builtInRevivers.test.ts new file mode 100644 index 00000000000..87ff6cdefd1 --- /dev/null +++ b/packages/effect/test/schema/representation/builtInRevivers.test.ts @@ -0,0 +1,1091 @@ +import { assert, describe, it } from "@effect/vitest" +import { Formatter, Schema, type SchemaAST, SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +function assertFilterReviver(input: { + readonly schema: Schema.Codec + readonly id: string + readonly payload: Schema.Json + readonly schemas?: ReadonlyArray + readonly reviver: SchemaRepresentation.FilterReviver + readonly dependencies?: ReadonlyArray + readonly valid: unknown + readonly invalid: unknown + readonly hasToJsonSchema?: boolean +}): void { + const check = input.schema.ast.checks?.at(-1) + assert.isDefined(check) + assert.strictEqual(check._tag, "Filter") + if (check._tag !== "Filter") return + assert.deepStrictEqual(check.annotations?.representation, { + id: input.id, + payload: input.payload, + ...(input.schemas === undefined ? undefined : { schemas: input.schemas }) + }) + + const document = SchemaRepresentation.toRepresentation(input.schema.ast) + const json = SchemaRepresentation.toJson(document) + const revived = SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), { + revivers: [input.reviver, ...(input.dependencies ?? [])] + }) as Schema.Codec + + assert.strictEqual(Schema.decodeUnknownResult(revived)(input.valid)._tag, "Success") + assert.strictEqual(Schema.decodeUnknownResult(revived)(input.invalid)._tag, "Failure") + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(revived.ast)), + json + ) + const revivedCheck = revived.ast.checks?.at(-1) + assert.isDefined(revivedCheck) + assert.strictEqual(revivedCheck._tag, "Filter") + if (revivedCheck._tag !== "Filter") return + assert.strictEqual(revivedCheck.annotations?.representation?.id, input.id) + assert.strictEqual( + typeof revivedCheck.annotations?.toJsonSchema, + input.hasToJsonSchema === false ? "undefined" : "function" + ) + assert.strictEqual(typeof revivedCheck.annotations?.toCode, "function") +} + +function assertDeclarationReviver(input: { + readonly schema: Schema.Top + readonly id: string + readonly payload: Schema.Json + readonly reviver: SchemaRepresentation.DeclarationReviver + readonly dependencies?: ReadonlyArray +}): void { + const representation = input.schema.ast.annotations?.representation + assert.deepStrictEqual(representation, { + id: input.id, + payload: input.payload + }) + + const document = SchemaRepresentation.toRepresentation(input.schema.ast) + const json = SchemaRepresentation.toJson(document) + const revived = SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), { + revivers: [input.reviver, ...(input.dependencies ?? [])] + }) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(revived.ast)), + json + ) + assert.strictEqual( + (revived.ast.annotations?.representation as { readonly id?: string } | undefined)?.id, + input.id + ) + assert.strictEqual(typeof revived.ast.annotations?.toCode, "function") +} + +describe("SchemaRepresentation built-in string revivers", () => { + it("revives isStringFinite", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isStringFinite()), + id: "effect/schema/isStringFinite", + payload: null, + reviver: Schema.isStringFiniteReviver, + valid: "1.5", + invalid: "Infinity" + }) + }) + + it("revives isStringBigInt", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isStringBigInt()), + id: "effect/schema/isStringBigInt", + payload: null, + reviver: Schema.isStringBigIntReviver, + valid: "-10", + invalid: "1.5" + }) + }) + + it("revives isStringSymbol", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isStringSymbol()), + id: "effect/schema/isStringSymbol", + payload: null, + reviver: Schema.isStringSymbolReviver, + valid: "Symbol(shared)", + invalid: "shared" + }) + }) + + it("revives isMinLength", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isMinLength(1.8)), + id: "effect/schema/isMinLength", + payload: { minLength: 1 }, + reviver: Schema.isMinLengthReviver, + valid: "a", + invalid: "" + }) + }) + + it("revives isMaxLength", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isMaxLength(3.8)), + id: "effect/schema/isMaxLength", + payload: { maxLength: 3 }, + reviver: Schema.isMaxLengthReviver, + valid: "abc", + invalid: "abcd" + }) + }) + + it("revives isLengthBetween", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isLengthBetween(1.8, 3.8)), + id: "effect/schema/isLengthBetween", + payload: { minimum: 1, maximum: 3 }, + reviver: Schema.isLengthBetweenReviver, + valid: "ab", + invalid: "" + }) + }) + + it("revives isPattern", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isPattern(/^a+$/i)), + id: "effect/schema/isPattern", + payload: { source: "^a+$", flags: "i" }, + reviver: Schema.isPatternReviver, + valid: "AAA", + invalid: "bbb" + }) + }) + + it("revives isTrimmed", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isTrimmed()), + id: "effect/schema/isTrimmed", + payload: null, + reviver: Schema.isTrimmedReviver, + valid: "text", + invalid: " text " + }) + }) + + it("revives isUUID", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isUUID(4)), + id: "effect/schema/isUUID", + payload: { version: 4 }, + reviver: Schema.isUUIDReviver, + valid: "123e4567-e89b-42d3-a456-426614174000", + invalid: "123e4567-e89b-12d3-a456-426614174000" + }) + }) + + it("revives isGUID", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isGUID()), + id: "effect/schema/isGUID", + payload: null, + reviver: Schema.isGUIDReviver, + valid: "123e4567-e89b-12d3-a456-426614174000", + invalid: "not-a-guid" + }) + }) + + it("revives isULID", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isULID()), + id: "effect/schema/isULID", + payload: null, + reviver: Schema.isULIDReviver, + valid: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + invalid: "not-a-ulid" + }) + }) + + it("revives isBase64", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isBase64()), + id: "effect/schema/isBase64", + payload: null, + reviver: Schema.isBase64Reviver, + valid: "YQ==", + invalid: "?" + }) + }) + + it("revives isBase64Url", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isBase64Url()), + id: "effect/schema/isBase64Url", + payload: null, + reviver: Schema.isBase64UrlReviver, + valid: "YQ", + invalid: "?" + }) + }) + + it("revives isStartsWith", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isStartsWith("pre")), + id: "effect/schema/isStartsWith", + payload: { startsWith: "pre" }, + reviver: Schema.isStartsWithReviver, + valid: "prefix", + invalid: "suffix" + }) + }) + + it("revives isEndsWith", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isEndsWith("end")), + id: "effect/schema/isEndsWith", + payload: { endsWith: "end" }, + reviver: Schema.isEndsWithReviver, + valid: "weekend", + invalid: "ending" + }) + }) + + it("revives isIncludes", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isIncludes("mid")), + id: "effect/schema/isIncludes", + payload: { includes: "mid" }, + reviver: Schema.isIncludesReviver, + valid: "middle", + invalid: "outside" + }) + }) + + it("revives isUppercased", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isUppercased()), + id: "effect/schema/isUppercased", + payload: null, + reviver: Schema.isUppercasedReviver, + valid: "ABC1", + invalid: "Abc" + }) + }) + + it("revives isLowercased", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isLowercased()), + id: "effect/schema/isLowercased", + payload: null, + reviver: Schema.isLowercasedReviver, + valid: "abc1", + invalid: "Abc" + }) + }) + + it("revives isCapitalized", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isCapitalized()), + id: "effect/schema/isCapitalized", + payload: null, + reviver: Schema.isCapitalizedReviver, + valid: "Hello", + invalid: "hello" + }) + }) + + it("revives isUncapitalized", () => { + assertFilterReviver({ + schema: Schema.String.check(Schema.isUncapitalized()), + id: "effect/schema/isUncapitalized", + payload: null, + reviver: Schema.isUncapitalizedReviver, + valid: "hello", + invalid: "Hello" + }) + }) +}) + +function expectInvalidPayload(json: Schema.Json, reviver: SchemaRepresentation.AnyReviver): void { + const path = Formatter.formatPath([ + "representation", + "checks", + 0, + "representation", + "payload" + ]) + throws( + () => SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), { revivers: [reviver] }), + `Invalid representation payload for ${reviver.id}\n at ${path}` + ) +} + +describe("SchemaRepresentation built-in number revivers", () => { + it("revives isFinite", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isFinite()), + id: "effect/schema/isFinite", + payload: null, + reviver: Schema.isFiniteReviver, + valid: 1, + invalid: Number.POSITIVE_INFINITY + }) + }) + + it("revives isInt", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isInt()), + id: "effect/schema/isInt", + payload: null, + reviver: Schema.isIntReviver, + valid: 1, + invalid: 1.5 + }) + }) + + it("revives isMultipleOf", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isMultipleOf(3)), + id: "effect/schema/isMultipleOf", + payload: { divisor: 3 }, + reviver: Schema.isMultipleOfReviver, + valid: 6, + invalid: 7 + }) + }) + + it("revives isGreaterThan", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isGreaterThan(1)), + id: "effect/schema/isGreaterThan", + payload: { exclusiveMinimum: 1 }, + reviver: Schema.isGreaterThanReviver, + valid: 2, + invalid: 1 + }) + }) + + it("revives isGreaterThanOrEqualTo", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1)), + id: "effect/schema/isGreaterThanOrEqualTo", + payload: { minimum: 1 }, + reviver: Schema.isGreaterThanOrEqualToReviver, + valid: 1, + invalid: 0 + }) + }) + + it("revives isLessThan", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isLessThan(2)), + id: "effect/schema/isLessThan", + payload: { exclusiveMaximum: 2 }, + reviver: Schema.isLessThanReviver, + valid: 1, + invalid: 2 + }) + }) + + it("revives isLessThanOrEqualTo", () => { + assertFilterReviver({ + schema: Schema.Number.check(Schema.isLessThanOrEqualTo(2)), + id: "effect/schema/isLessThanOrEqualTo", + payload: { maximum: 2 }, + reviver: Schema.isLessThanOrEqualToReviver, + valid: 2, + invalid: 3 + }) + }) + + it("revives isBetween", () => { + assertFilterReviver({ + schema: Schema.Number.check( + Schema.isBetween({ minimum: 1, maximum: 3, exclusiveMinimum: true }) + ), + id: "effect/schema/isBetween", + payload: { minimum: 1, maximum: 3, exclusiveMinimum: true }, + reviver: Schema.isBetweenReviver, + valid: 2, + invalid: 1 + }) + }) + + it("normalizes isBetween flags", () => { + const check = Schema.isBetween({ + minimum: 1, + maximum: 3, + exclusiveMinimum: false, + exclusiveMaximum: true + }) + + assert.deepStrictEqual(check.annotations?.representation, { + id: "effect/schema/isBetween", + payload: { minimum: 1, maximum: 3, exclusiveMaximum: true } + }) + }) + + it("rejects a non-numeric isMultipleOf payload", () => { + const json = SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation(Schema.Number.check(Schema.isMultipleOf(2)).ast) + ) as any + json.representation.checks[0].representation.payload.divisor = "2" + + expectInvalidPayload(json, Schema.isMultipleOfReviver) + }) + + it("rejects a non-canonical isBetween payload", () => { + const json = SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation( + Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 3 })).ast + ) + ) as any + json.representation.checks[0].representation.payload.exclusiveMinimum = false + + expectInvalidPayload(json, Schema.isBetweenReviver) + }) +}) + +describe("SchemaRepresentation built-in BigInt revivers", () => { + it("revives isGreaterThanBigInt", () => { + assertFilterReviver({ + schema: Schema.BigInt.check(Schema.isGreaterThanBigInt(10n)), + id: "effect/schema/isGreaterThanBigInt", + payload: { exclusiveMinimum: "10" }, + reviver: Schema.isGreaterThanBigIntReviver, + valid: 11n, + invalid: 10n + }) + }) + + it("revives isGreaterThanOrEqualToBigInt", () => { + assertFilterReviver({ + schema: Schema.BigInt.check(Schema.isGreaterThanOrEqualToBigInt(10n)), + id: "effect/schema/isGreaterThanOrEqualToBigInt", + payload: { minimum: "10" }, + reviver: Schema.isGreaterThanOrEqualToBigIntReviver, + valid: 10n, + invalid: 9n + }) + }) + + it("revives isLessThanBigInt", () => { + assertFilterReviver({ + schema: Schema.BigInt.check(Schema.isLessThanBigInt(10n)), + id: "effect/schema/isLessThanBigInt", + payload: { exclusiveMaximum: "10" }, + reviver: Schema.isLessThanBigIntReviver, + valid: 9n, + invalid: 10n + }) + }) + + it("revives isLessThanOrEqualToBigInt", () => { + assertFilterReviver({ + schema: Schema.BigInt.check(Schema.isLessThanOrEqualToBigInt(10n)), + id: "effect/schema/isLessThanOrEqualToBigInt", + payload: { maximum: "10" }, + reviver: Schema.isLessThanOrEqualToBigIntReviver, + valid: 10n, + invalid: 11n + }) + }) + + it("revives isBetweenBigInt", () => { + assertFilterReviver({ + schema: Schema.BigInt.check( + Schema.isBetweenBigInt({ minimum: -10n, maximum: 10n, exclusiveMaximum: true }) + ), + id: "effect/schema/isBetweenBigInt", + payload: { minimum: "-10", maximum: "10", exclusiveMaximum: true }, + reviver: Schema.isBetweenBigIntReviver, + valid: 0n, + invalid: 10n + }) + }) + + it("persists large bounds as canonical decimal strings", () => { + const value = 900719925474099312345678901234567890n + assert.deepStrictEqual(Schema.isGreaterThanBigInt(value).annotations?.representation, { + id: "effect/schema/isGreaterThanBigInt", + payload: { exclusiveMinimum: "900719925474099312345678901234567890" } + }) + }) + + it("normalizes isBetweenBigInt flags", () => { + assert.deepStrictEqual( + Schema.isBetweenBigInt({ + minimum: -1n, + maximum: 1n, + exclusiveMinimum: false, + exclusiveMaximum: true + }).annotations?.representation, + { + id: "effect/schema/isBetweenBigInt", + payload: { minimum: "-1", maximum: "1", exclusiveMaximum: true } + } + ) + }) + + it("rejects non-canonical decimal payloads", () => { + const json = SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation(Schema.BigInt.check(Schema.isGreaterThanBigInt(1n)).ast) + ) as any + json.representation.checks[0].representation.payload.exclusiveMinimum = "01" + + throws( + () => + SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), { + revivers: [Schema.isGreaterThanBigIntReviver] + }), + `Invalid representation payload for effect/schema/isGreaterThanBigInt\n at ["representation"]["checks"][0]["representation"]["payload"]` + ) + }) +}) + +function date(millis: number): Date { + return Schema.decodeUnknownSync(Schema.DateFromMillis)(millis) +} + +const epoch = "1970-01-01T00:00:00.000Z" + +describe("SchemaRepresentation built-in Date revivers", () => { + it("revives isDateValid", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isDateValid()), + id: "effect/schema/isDateValid", + payload: null, + reviver: Schema.isDateValidReviver, + valid: date(0), + invalid: date(Number.NaN), + hasToJsonSchema: false + }) + }) + + it("revives isGreaterThanDate", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isGreaterThanDate(date(0))), + id: "effect/schema/isGreaterThanDate", + payload: { exclusiveMinimum: epoch }, + reviver: Schema.isGreaterThanDateReviver, + valid: date(1), + invalid: date(0) + }) + }) + + it("revives isGreaterThanOrEqualToDate", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isGreaterThanOrEqualToDate(date(0))), + id: "effect/schema/isGreaterThanOrEqualToDate", + payload: { minimum: epoch }, + reviver: Schema.isGreaterThanOrEqualToDateReviver, + valid: date(0), + invalid: date(-1) + }) + }) + + it("revives isLessThanDate", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isLessThanDate(date(0))), + id: "effect/schema/isLessThanDate", + payload: { exclusiveMaximum: epoch }, + reviver: Schema.isLessThanDateReviver, + valid: date(-1), + invalid: date(0) + }) + }) + + it("revives isLessThanOrEqualToDate", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isLessThanOrEqualToDate(date(0))), + id: "effect/schema/isLessThanOrEqualToDate", + payload: { maximum: epoch }, + reviver: Schema.isLessThanOrEqualToDateReviver, + valid: date(0), + invalid: date(1) + }) + }) + + it("revives isBetweenDate", () => { + assertFilterReviver({ + schema: Schema.Any.check( + Schema.isBetweenDate({ minimum: date(0), maximum: date(2), exclusiveMaximum: true }) + ), + id: "effect/schema/isBetweenDate", + payload: { + minimum: epoch, + maximum: "1970-01-01T00:00:00.002Z", + exclusiveMaximum: true + }, + reviver: Schema.isBetweenDateReviver, + valid: date(1), + invalid: date(2) + }) + }) + + it("persists millisecond precision", () => { + assert.deepStrictEqual(Schema.isGreaterThanDate(date(123)).annotations?.representation, { + id: "effect/schema/isGreaterThanDate", + payload: { exclusiveMinimum: "1970-01-01T00:00:00.123Z" } + }) + }) + + it("normalizes isBetweenDate flags", () => { + assert.deepStrictEqual( + Schema.isBetweenDate({ + minimum: date(-1), + maximum: date(1), + exclusiveMinimum: false, + exclusiveMaximum: true + }).annotations?.representation, + { + id: "effect/schema/isBetweenDate", + payload: { + minimum: "1969-12-31T23:59:59.999Z", + maximum: "1970-01-01T00:00:00.001Z", + exclusiveMaximum: true + } + } + ) + }) +}) + +describe("SchemaRepresentation built-in collection revivers", () => { + it("revives isMinSize", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isMinSize(2)), + id: "effect/schema/isMinSize", + payload: { minSize: 2 }, + reviver: Schema.isMinSizeReviver, + valid: new Set([1, 2]), + invalid: new Set([1]) + }) + }) + + it("revives isMaxSize", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isMaxSize(1)), + id: "effect/schema/isMaxSize", + payload: { maxSize: 1 }, + reviver: Schema.isMaxSizeReviver, + valid: new Set([1]), + invalid: new Set([1, 2]) + }) + }) + + it("revives isSizeBetween", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isSizeBetween(1, 2)), + id: "effect/schema/isSizeBetween", + payload: { minimum: 1, maximum: 2 }, + reviver: Schema.isSizeBetweenReviver, + valid: new Set([1]), + invalid: new Set() + }) + }) + + it("revives isUnique", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isUnique()), + id: "effect/schema/isUnique", + payload: null, + reviver: Schema.isUniqueReviver, + valid: [1, 2], + invalid: [1, 1] + }) + }) + + it("normalizes isMinSize", () => { + assert.deepStrictEqual(Schema.isMinSize(-1).annotations?.representation, { + id: "effect/schema/isMinSize", + payload: { minSize: 0 } + }) + }) + + it("normalizes isMaxSize", () => { + assert.deepStrictEqual(Schema.isMaxSize(2.9).annotations?.representation, { + id: "effect/schema/isMaxSize", + payload: { maxSize: 2 } + }) + }) + + it("normalizes isSizeBetween", () => { + assert.deepStrictEqual(Schema.isSizeBetween(1.9, 3.7).annotations?.representation, { + id: "effect/schema/isSizeBetween", + payload: { minimum: 1, maximum: 3 } + }) + }) +}) + +describe("SchemaRepresentation built-in object revivers", () => { + it("revives isMinProperties", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isMinProperties(2)), + id: "effect/schema/isMinProperties", + payload: { minProperties: 2 }, + reviver: Schema.isMinPropertiesReviver, + valid: { a: 1, b: 2 }, + invalid: { a: 1 } + }) + }) + + it("revives isMaxProperties", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isMaxProperties(1)), + id: "effect/schema/isMaxProperties", + payload: { maxProperties: 1 }, + reviver: Schema.isMaxPropertiesReviver, + valid: { a: 1 }, + invalid: { a: 1, b: 2 } + }) + }) + + it("revives isPropertiesLengthBetween", () => { + assertFilterReviver({ + schema: Schema.Any.check(Schema.isPropertiesLengthBetween(1, 2)), + id: "effect/schema/isPropertiesLengthBetween", + payload: { minimum: 1, maximum: 2 }, + reviver: Schema.isPropertiesLengthBetweenReviver, + valid: { a: 1 }, + invalid: {} + }) + }) + + it("revives isPropertyNames", () => { + const names = Schema.String.check(Schema.isPattern(/^[A-Z]/)) + assertFilterReviver({ + schema: Schema.Any.check(Schema.isPropertyNames(names)), + id: "effect/schema/isPropertyNames", + payload: null, + schemas: [names.ast], + reviver: Schema.isPropertyNamesReviver, + dependencies: [Schema.isPatternReviver], + valid: { Alpha: 1 }, + invalid: { alpha: 1 } + }) + }) + + it("persists the encoded key schema for isPropertyNames", () => { + const names = Schema.String.check(Schema.isPattern(/^[A-Z]/)) + const check = Schema.isPropertyNames(names) + + assert.deepStrictEqual(check.annotations?.representation, { + id: "effect/schema/isPropertyNames", + payload: null, + schemas: [names.ast] + }) + }) + + it("normalizes isMinProperties", () => { + assert.deepStrictEqual(Schema.isMinProperties(-1).annotations?.representation, { + id: "effect/schema/isMinProperties", + payload: { minProperties: 0 } + }) + }) + + it("normalizes isMaxProperties", () => { + assert.deepStrictEqual(Schema.isMaxProperties(2.9).annotations?.representation, { + id: "effect/schema/isMaxProperties", + payload: { maxProperties: 2 } + }) + }) + + it("normalizes isPropertiesLengthBetween", () => { + assert.deepStrictEqual(Schema.isPropertiesLengthBetween(1.9, 3.7).annotations?.representation, { + id: "effect/schema/isPropertiesLengthBetween", + payload: { minimum: 1, maximum: 3 } + }) + }) +}) + +describe("SchemaRepresentation built-in declaration revivers", () => { + it("revives Option", () => { + assertDeclarationReviver({ + schema: Schema.Option(Schema.String), + id: "effect/schema/Option", + payload: null, + reviver: Schema.OptionReviver + }) + }) + + it("revives Result", () => { + assertDeclarationReviver({ + schema: Schema.Result(Schema.String, Schema.Number), + id: "effect/schema/Result", + payload: null, + reviver: Schema.ResultReviver + }) + }) + + it("revives Redacted", () => { + assertDeclarationReviver({ + schema: Schema.Redacted(Schema.String), + id: "effect/schema/Redacted", + payload: null, + reviver: Schema.RedactedReviver + }) + }) + + it("revives CauseReason", () => { + assertDeclarationReviver({ + schema: Schema.CauseReason(Schema.String, Schema.Number), + id: "effect/schema/CauseReason", + payload: null, + reviver: Schema.CauseReasonReviver + }) + }) + + it("revives Cause", () => { + assertDeclarationReviver({ + schema: Schema.Cause(Schema.String, Schema.Number), + id: "effect/schema/Cause", + payload: null, + reviver: Schema.CauseReviver + }) + }) + + it("revives Error", () => { + assertDeclarationReviver({ + schema: Schema.Error(), + id: "effect/schema/Error", + payload: null, + reviver: Schema.ErrorReviver + }) + }) + + it("revives Exit", () => { + assertDeclarationReviver({ + schema: Schema.Exit(Schema.String, Schema.Number, Schema.Boolean), + id: "effect/schema/Exit", + payload: null, + reviver: Schema.ExitReviver + }) + }) + + it("revives ReadonlyMap", () => { + assertDeclarationReviver({ + schema: Schema.ReadonlyMap(Schema.String, Schema.Number), + id: "effect/schema/ReadonlyMap", + payload: null, + reviver: Schema.ReadonlyMapReviver + }) + }) + + it("revives HashMap", () => { + assertDeclarationReviver({ + schema: Schema.HashMap(Schema.String, Schema.Number), + id: "effect/schema/HashMap", + payload: null, + reviver: Schema.HashMapReviver + }) + }) + + it("revives ReadonlySet", () => { + assertDeclarationReviver({ + schema: Schema.ReadonlySet(Schema.String), + id: "effect/schema/ReadonlySet", + payload: null, + reviver: Schema.ReadonlySetReviver + }) + }) + + it("revives HashSet", () => { + assertDeclarationReviver({ + schema: Schema.HashSet(Schema.String), + id: "effect/schema/HashSet", + payload: null, + reviver: Schema.HashSetReviver + }) + }) + + it("revives Chunk", () => { + assertDeclarationReviver({ + schema: Schema.Chunk(Schema.String), + id: "effect/schema/Chunk", + payload: null, + reviver: Schema.ChunkReviver + }) + }) + + it("revives RegExp", () => { + assertDeclarationReviver({ + schema: Schema.RegExp, + id: "effect/schema/RegExp", + payload: null, + reviver: Schema.RegExpReviver + }) + }) + + it("revives URL", () => { + assertDeclarationReviver({ + schema: Schema.URL, + id: "effect/schema/URL", + payload: null, + reviver: Schema.URLReviver + }) + }) + + it("revives Date", () => { + assertDeclarationReviver({ + schema: Schema.Date, + id: "effect/schema/Date", + payload: null, + reviver: Schema.DateReviver + }) + }) + + it("revives Duration", () => { + assertDeclarationReviver({ + schema: Schema.Duration, + id: "effect/schema/Duration", + payload: null, + reviver: Schema.DurationReviver + }) + }) + + it("revives BigDecimal", () => { + assertDeclarationReviver({ + schema: Schema.BigDecimal, + id: "effect/schema/BigDecimal", + payload: null, + reviver: Schema.BigDecimalReviver + }) + }) + + it("revives File", () => { + assertDeclarationReviver({ + schema: Schema.File, + id: "effect/schema/File", + payload: null, + reviver: Schema.FileReviver + }) + }) + + it("revives FormData", () => { + assertDeclarationReviver({ + schema: Schema.FormData, + id: "effect/schema/FormData", + payload: null, + reviver: Schema.FormDataReviver + }) + }) + + it("revives URLSearchParams", () => { + assertDeclarationReviver({ + schema: Schema.URLSearchParams, + id: "effect/schema/URLSearchParams", + payload: null, + reviver: Schema.URLSearchParamsReviver + }) + }) + + it("revives Uint8Array", () => { + assertDeclarationReviver({ + schema: Schema.Uint8Array, + id: "effect/schema/Uint8Array", + payload: null, + reviver: Schema.Uint8ArrayReviver + }) + }) + + it("revives DateTimeUtc", () => { + assertDeclarationReviver({ + schema: Schema.DateTimeUtc, + id: "effect/schema/DateTimeUtc", + payload: null, + reviver: Schema.DateTimeUtcReviver + }) + }) + + it("revives TimeZoneOffset", () => { + assertDeclarationReviver({ + schema: Schema.TimeZoneOffset, + id: "effect/schema/TimeZoneOffset", + payload: null, + reviver: Schema.TimeZoneOffsetReviver + }) + }) + + it("revives TimeZoneNamed", () => { + assertDeclarationReviver({ + schema: Schema.TimeZoneNamed, + id: "effect/schema/TimeZoneNamed", + payload: null, + reviver: Schema.TimeZoneNamedReviver + }) + }) + + it("revives TimeZone", () => { + assertDeclarationReviver({ + schema: Schema.TimeZone, + id: "effect/schema/TimeZone", + payload: null, + reviver: Schema.TimeZoneReviver + }) + }) + + it("revives DateTimeZoned", () => { + assertDeclarationReviver({ + schema: Schema.DateTimeZoned, + id: "effect/schema/DateTimeZoned", + payload: null, + reviver: Schema.DateTimeZonedReviver + }) + }) + + it("revives Json", () => { + assertDeclarationReviver({ + schema: Schema.Json, + id: "effect/schema/Json", + payload: null, + reviver: Schema.JsonReviver + }) + }) + + it("revives MutableJson", () => { + assertDeclarationReviver({ + schema: Schema.MutableJson, + id: "effect/schema/MutableJson", + payload: null, + reviver: Schema.MutableJsonReviver + }) + }) + + it("persists Error includeStack", () => { + assert.deepStrictEqual(Schema.Error({ includeStack: true }).ast.annotations?.representation, { + id: "effect/schema/Error", + payload: { includeStack: true } + }) + }) + + it("persists Error excludeCause", () => { + assert.deepStrictEqual(Schema.Error({ excludeCause: true }).ast.annotations?.representation, { + id: "effect/schema/Error", + payload: { excludeCause: true } + }) + }) + + it("omits disabled Error options", () => { + assert.deepStrictEqual( + Schema.Error({ includeStack: false, excludeCause: false }).ast.annotations?.representation, + { id: "effect/schema/Error", payload: null } + ) + }) + + it("persists a Redacted label", () => { + assert.deepStrictEqual( + Schema.Redacted(Schema.String, { label: "password" }).ast.annotations?.representation, + { id: "effect/schema/Redacted", payload: { label: "password" } } + ) + }) + + it("persists Redacted disallowJsonEncode", () => { + assert.deepStrictEqual( + Schema.Redacted(Schema.String, { disallowJsonEncode: true }).ast.annotations?.representation, + { id: "effect/schema/Redacted", payload: { disallowJsonEncode: true } } + ) + }) + + it("omits disabled Redacted options", () => { + assert.deepStrictEqual( + Schema.Redacted(Schema.String, { + label: undefined, + disallowJsonEncode: false + }).ast.annotations?.representation, + { id: "effect/schema/Redacted", payload: null } + ) + }) +}) diff --git a/packages/effect/test/schema/representation/fromASTs.test.ts b/packages/effect/test/schema/representation/fromASTs.test.ts deleted file mode 100644 index 47ad4a86057..00000000000 --- a/packages/effect/test/schema/representation/fromASTs.test.ts +++ /dev/null @@ -1,1109 +0,0 @@ -import { Array as Arr, Option, Predicate, Schema, SchemaGetter, SchemaRepresentation } from "effect" -import { describe, it } from "vitest" -import { deepStrictEqual } from "../../utils/assert.ts" - -describe("fromASTs", () => { - function assertFromASTs(schemas: readonly [Schema.Constraint, ...Array], expected: { - readonly representations: readonly [ - SchemaRepresentation.Representation, - ...Array - ] - readonly references?: SchemaRepresentation.References - }) { - const document = SchemaRepresentation.fromASTs(Arr.map(schemas, (s) => s.ast)) - deepStrictEqual(document, { - representations: expected.representations, - references: expected.references ?? {} - }) - } - - it("should handle multiple schemas", () => { - const A = Schema.String.annotate({ identifier: "id", description: "a" }) - const B = Schema.String.annotate({ identifier: "id", description: "b" }) - const C = Schema.Tuple([A, B]) - assertFromASTs([A, B, C], { - representations: [ - { _tag: "Reference", $ref: "id" }, - { _tag: "Reference", $ref: "id1" }, - { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "id" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "id1" } - } - ], - rest: [], - checks: [] - } - ], - references: { - id: { - _tag: "String", - checks: [], - annotations: { identifier: "id", description: "a" } - }, - id1: { - _tag: "String", - checks: [], - annotations: { identifier: "id", description: "b" } - } - } - }) - }) -}) - -describe("fromAST", () => { - function assertFromAST(schema: Schema.Constraint, expected: { - readonly representation: SchemaRepresentation.Representation - readonly references?: SchemaRepresentation.References - }) { - const document = SchemaRepresentation.fromAST(schema.ast) - deepStrictEqual(document, { - representation: expected.representation, - references: expected.references ?? {} - }) - } - - describe("String", () => { - it("String", () => { - assertFromAST(Schema.String, { - representation: { - _tag: "String", - checks: [] - } - }) - }) - - it("String & brand", () => { - assertFromAST(Schema.String.pipe(Schema.brand("a")), { - representation: { - _tag: "String", - checks: [], - annotations: { brands: ["a"] } - } - }) - }) - - it("String & brand & brand", () => { - assertFromAST(Schema.String.pipe(Schema.brand("a"), Schema.brand("b")), { - representation: { - _tag: "String", - checks: [], - annotations: { brands: ["a", "b"] } - } - }) - }) - }) - - it("URL", () => { - assertFromAST(Schema.URL, { - representation: { - _tag: "Declaration", - annotations: { - expected: "URL", - typeConstructor: { _tag: "URL" }, - generation: { - runtime: "Schema.URL", - Type: "globalThis.URL" - } - }, - checks: [], - typeParameters: [], - encodedSchema: { - _tag: "String", - annotations: { - expected: "a string that will be decoded as a URL" - }, - checks: [] - } - } - }) - }) - - it("RegExp", () => { - assertFromAST(Schema.RegExp, { - representation: { - _tag: "Declaration", - annotations: { - expected: "RegExp", - typeConstructor: { _tag: "RegExp" }, - generation: { - runtime: "Schema.RegExp", - Type: "globalThis.RegExp" - } - }, - checks: [], - typeParameters: [], - encodedSchema: { - _tag: "Objects", - propertySignatures: [ - { - name: "source", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }, - { - name: "flags", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("URLSearchParams", () => { - assertFromAST(Schema.URLSearchParams, { - representation: { - _tag: "Declaration", - annotations: { - expected: "URLSearchParams", - typeConstructor: { _tag: "URLSearchParams" }, - generation: { - runtime: "Schema.URLSearchParams", - Type: "globalThis.URLSearchParams" - } - }, - checks: [], - typeParameters: [], - encodedSchema: { - _tag: "String", - annotations: { - expected: "a query string that will be decoded as URLSearchParams" - }, - checks: [] - } - } - }) - }) - - it("Option(Number)", () => { - assertFromAST(Schema.Option(Schema.Number), { - representation: { - _tag: "Declaration", - annotations: { - expected: "Option", - typeConstructor: { _tag: "effect/Option" }, - generation: { - runtime: "Schema.Option(?)", - Type: "Option.Option", - importDeclaration: `import * as Option from "effect/Option"` - } - }, - checks: [], - typeParameters: [ - { _tag: "Number", checks: [] } - ], - encodedSchema: { - _tag: "Union", - types: [ - { - _tag: "Objects", - propertySignatures: [ - { - name: "_tag", - type: { _tag: "Literal", literal: "Some" }, - isOptional: false, - isMutable: false - }, - { - name: "value", - type: { _tag: "Number", checks: [] }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }, - { - _tag: "Objects", - propertySignatures: [ - { - name: "_tag", - type: { _tag: "Literal", literal: "None" }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - ], - mode: "anyOf" - } - } - }) - }) - - describe("node kinds", () => { - it("primitive nodes", () => { - assertFromAST( - Schema.Tuple([ - Schema.Null, - Schema.Undefined, - Schema.Void, - Schema.Never, - Schema.Unknown, - Schema.Any, - Schema.Boolean, - Schema.BigInt, - Schema.Symbol, - Schema.ObjectKeyword - ]), - { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "Null" } }, - { isOptional: false, type: { _tag: "Undefined" } }, - { isOptional: false, type: { _tag: "Void" } }, - { isOptional: false, type: { _tag: "Never" } }, - { isOptional: false, type: { _tag: "Unknown" } }, - { isOptional: false, type: { _tag: "Any" } }, - { isOptional: false, type: { _tag: "Boolean" } }, - { isOptional: false, type: { _tag: "BigInt", checks: [] } }, - { isOptional: false, type: { _tag: "Symbol" } }, - { isOptional: false, type: { _tag: "ObjectKeyword" } } - ], - rest: [], - checks: [] - } - } - ) - }) - - it("literal-like nodes", () => { - const symbol = Symbol.for("a") - assertFromAST( - Schema.Tuple([ - Schema.Literal("a"), - Schema.UniqueSymbol(symbol), - Schema.Enum({ A: "a", B: "b", One: 1 }), - Schema.TemplateLiteral(["a", Schema.String, Schema.Number]) - ]), - { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "Literal", literal: "a" } }, - { isOptional: false, type: { _tag: "UniqueSymbol", symbol } }, - { - isOptional: false, - type: { - _tag: "Enum", - enums: [["A", "a"], ["B", "b"], ["One", 1]] - } - }, - { - isOptional: false, - type: { - _tag: "TemplateLiteral", - parts: [ - { _tag: "Literal", literal: "a" }, - { _tag: "String", checks: [] }, - { _tag: "Number", checks: [] } - ] - } - } - ], - rest: [], - checks: [] - } - } - ) - }) - - it("string content schema", () => { - const document = SchemaRepresentation.fromAST( - Schema.fromJsonString(Schema.Struct({ a: Schema.String })).ast - ) - const representation = document.representation as SchemaRepresentation.String - deepStrictEqual(representation.contentSchema, { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }) - deepStrictEqual(representation.annotations?.expected, "a string that will be decoded as JSON") - deepStrictEqual(representation.annotations?.contentMediaType, "application/json") - }) - - it("tuple rest and mutable properties", () => { - assertFromAST( - Schema.Tuple([ - Schema.TupleWithRest(Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)]), [Schema.Boolean]), - Schema.Struct({ a: Schema.mutableKey(Schema.String) }) - ]), - { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "String", checks: [] } }, - { isOptional: true, type: { _tag: "Number", checks: [] } } - ], - rest: [{ _tag: "Boolean" }], - checks: [] - } - }, - { - isOptional: false, - type: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: true - } - ], - indexSignatures: [], - checks: [] - } - } - ], - rest: [], - checks: [] - } - } - ) - }) - - it("declaration without an encoded schema", () => { - assertFromAST( - Schema.declare((u): u is string => typeof u === "string", { expected: "string declaration" }), - { - representation: { - _tag: "Declaration", - typeParameters: [], - encodedSchema: { _tag: "Null" }, - checks: [], - annotations: { expected: "string declaration" } - } - } - ) - }) - }) - - describe("checks", () => { - it("array and object checks", () => { - assertFromAST( - Schema.Tuple([ - Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isUnique()), - Schema.Record(Schema.String, Schema.Number).check( - Schema.isMinProperties(1), - Schema.isMaxProperties(2) - ) - ]), - { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { - _tag: "Arrays", - elements: [], - rest: [{ _tag: "String", checks: [] }], - checks: [ - { - _tag: "Filter", - meta: { _tag: "isMinLength", minLength: 1 }, - annotations: { expected: "a value with a length of at least 1" } - }, - { - _tag: "Filter", - meta: { _tag: "isUnique" }, - annotations: { expected: "an array with unique items" } - } - ] - } - }, - { - isOptional: false, - type: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { - parameter: { _tag: "String", checks: [] }, - type: { _tag: "Number", checks: [] } - } - ], - checks: [ - { - _tag: "Filter", - meta: { _tag: "isMinProperties", minProperties: 1 }, - annotations: { expected: "a value with at least 1 entry" } - }, - { - _tag: "Filter", - meta: { _tag: "isMaxProperties", maxProperties: 2 }, - annotations: { expected: "a value with at most 2 entries" } - } - ] - } - } - ], - rest: [], - checks: [] - } - } - ) - }) - - it("filter groups", () => { - assertFromAST( - Schema.String.check( - Schema.makeFilterGroup([ - Schema.isMinLength(1), - Schema.isMaxLength(2) - ], { description: "range" }) - ), - { - representation: { - _tag: "String", - checks: [ - { - _tag: "FilterGroup", - checks: [ - { - _tag: "Filter", - meta: { _tag: "isMinLength", minLength: 1 }, - annotations: { expected: "a value with a length of at least 1" } - }, - { - _tag: "Filter", - meta: { _tag: "isMaxLength", maxLength: 2 }, - annotations: { expected: "a value with a length of at most 2" } - } - ], - annotations: { description: "range" } - } - ] - } - } - ) - }) - - it("drops checks without representation metadata", () => { - assertFromAST( - Schema.String.check(Schema.makeFilter((s) => s.length > 0, { expected: "custom" })), - { - representation: { - _tag: "String", - checks: [] - } - } - ) - assertFromAST( - Schema.String.check( - Schema.makeFilterGroup([ - Schema.makeFilter((s) => s.length > 0, { expected: "custom" }) - ], { description: "group" }) - ), - { - representation: { - _tag: "String", - checks: [] - } - } - ) - }) - }) - - describe("Record", () => { - describe("checks", () => { - it("isPropertyNames", () => { - assertFromAST( - Schema.Record(Schema.String, Schema.Number) - .check(Schema.isPropertyNames(Schema.String.check(Schema.isPattern(/^[A-Z]/)))), - { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { - parameter: { _tag: "String", checks: [] }, - type: { _tag: "Number", checks: [] } - } - ], - checks: [ - { - _tag: "Filter", - meta: { - _tag: "isPropertyNames", - propertyNames: { - _tag: "String", - checks: [ - { - _tag: "Filter", - meta: { _tag: "isPattern", regExp: new RegExp("^[A-Z]") }, - annotations: { expected: "a string matching the RegExp ^[A-Z]" } - } - ] - } - }, - annotations: { expected: "an object with property names matching the schema" } - } - ] - }, - references: {} - } - ) - }) - }) - }) - - describe("Class", () => { - it("Class", () => { - class A extends Schema.Class("A")({ - a: Schema.String - }) {} - assertFromAST(A, { - representation: { _tag: "Reference", $ref: "A" }, - references: { - A: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { - _tag: "String", - checks: [] - }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("toType(Class)", () => { - class A extends Schema.Class("A")({ - a: Schema.String - }) {} - assertFromAST(Schema.toType(A), { - representation: { _tag: "Reference", $ref: "A" }, - references: { - A: { - _tag: "Declaration", - annotations: { - identifier: "A" - }, - checks: [], - typeParameters: [ - { _tag: "Reference", $ref: "A1" } - ], - encodedSchema: { _tag: "Reference", $ref: "A1" } - }, - A1: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { - _tag: "String", - checks: [] - }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("the type side and the class used together", () => { - class A extends Schema.Class("A")({ - a: Schema.String - }) {} - assertFromAST(Schema.Tuple([Schema.toType(A), A]), { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "A" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "A1" } - } - ], - rest: [], - checks: [] - }, - references: { - A: { - _tag: "Declaration", - annotations: { identifier: "A" }, - checks: [], - typeParameters: [ - { _tag: "Reference", $ref: "A1" } - ], - encodedSchema: { _tag: "Reference", $ref: "A1" } - }, - A1: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { - _tag: "String", - checks: [] - }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - }) - - describe("reference handling", () => { - it("using a schema with an identifier twice should point to the identifier as a reference", () => { - const S = Schema.String.annotate({ identifier: "id" }) - assertFromAST(Schema.Tuple([S, S]), { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "id" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "id" } - } - ], - rest: [], - checks: [] - }, - references: { - id: { - _tag: "String", - checks: [], - annotations: { identifier: "id" } - } - } - }) - }) - - it("should handle duplicate identifiers on different schemas with different representations", () => { - assertFromAST( - Schema.Union([ - Schema.String.annotate({ identifier: "id", description: "a" }), - Schema.String.annotate({ identifier: "id", description: "b" }) - ]), - { - representation: { - _tag: "Union", - mode: "anyOf", - types: [ - { _tag: "Reference", $ref: "id" }, - { _tag: "Reference", $ref: "id1" } - ] - }, - references: { - id: { - _tag: "String", - checks: [], - annotations: { identifier: "id", description: "a" } - }, - id1: { - _tag: "String", - checks: [], - annotations: { identifier: "id", description: "b" } - } - } - } - ) - }) - - it("should handle duplicate identifiers on different schemas with the same representation", () => { - const X = Schema.String.annotate({ title: "X", identifier: "X" }) - assertFromAST( - Schema.Struct({ - a: X, - b: Schema.NullOr(X), - c: Schema.optionalKey(X), - d: Schema.optionalKey(Schema.NullOr(X)), - e: Schema.NullOr(X).pipe( - Schema.encodeTo(Schema.optionalKey(X), { - decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)), - encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull)) - }) - ) - }), - { - representation: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "Reference", $ref: "X" }, - isOptional: false, - isMutable: false - }, - { - name: "b", - type: { - _tag: "Union", - mode: "anyOf", - types: [ - { _tag: "Reference", $ref: "X" }, - { _tag: "Null" } - ] - }, - isOptional: false, - isMutable: false - }, - { - name: "c", - type: { _tag: "Reference", $ref: "X" }, - isOptional: true, - isMutable: false - }, - { - name: "d", - type: { - _tag: "Union", - mode: "anyOf", - types: [ - { _tag: "Reference", $ref: "X" }, - { _tag: "Null" } - ] - }, - isOptional: true, - isMutable: false - }, - { - name: "e", - type: { _tag: "Reference", $ref: "X" }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }, - references: { - X: { - _tag: "String", - checks: [], - annotations: { identifier: "X", title: "X" } - } - } - } - ) - }) - - describe("suspend", () => { - it("non-recursive", () => { - assertFromAST(Schema.suspend(() => Schema.String), { - representation: { - _tag: "Suspend", - checks: [], - thunk: { - _tag: "String", - checks: [] - } - } - }) - }) - - it("no identifier annotation", () => { - type A = { - readonly a?: A - } - const A = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A)) - }) - - assertFromAST(A, { - representation: { _tag: "Reference", $ref: "Objects_" }, - references: { - Objects_: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "Objects_" } - }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("outer identifier annotation", () => { - type A = { - readonly a?: A - } - const A = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A)) - }).annotate({ identifier: "A" }) // outer identifier annotation - - assertFromAST(A, { - representation: { _tag: "Reference", $ref: "A" }, - references: { - A: { - _tag: "Objects", - annotations: { identifier: "A" }, - propertySignatures: [ - { - name: "a", - type: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "A" } - }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("inner identifier annotation", () => { - type A = { - readonly a?: A - } - const A = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A.annotate({ identifier: "A" }))) - }) - - assertFromAST(A, { - representation: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "Reference", $ref: "Suspend_" }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }, - references: { - A: { - _tag: "Objects", - annotations: { identifier: "A" }, - propertySignatures: [ - { - name: "a", - type: { _tag: "Reference", $ref: "Suspend_" }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }, - Suspend_: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "A" } - } - } - }) - }) - - it("suspend identifier annotation", () => { - type A = { - readonly a?: A - } - const A = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A).annotate({ identifier: "A" })) - }) - - assertFromAST(A, { - representation: { _tag: "Reference", $ref: "Objects_" }, - references: { - A: { - _tag: "Suspend", - annotations: { identifier: "A" }, - checks: [], - thunk: { _tag: "Reference", $ref: "Objects_" } - }, - Objects_: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "Reference", $ref: "A" }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - - it("duplicate identifiers", () => { - type A = { - readonly a?: A - } - const A = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A)) - }).annotate({ identifier: "A" }) - - type A1 = { - readonly a?: A1 - } - const A1 = Schema.Struct({ - a: Schema.optionalKey(Schema.suspend((): Schema.Codec => A1)) - }).annotate({ identifier: "A" }) - - const schema = Schema.Tuple([A, A1]) - assertFromAST(schema, { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "A" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "A1" } - } - ], - rest: [], - checks: [] - }, - references: { - A: { - _tag: "Objects", - annotations: { identifier: "A" }, - propertySignatures: [ - { - name: "a", - type: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "A" } - }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - }, - A1: { - _tag: "Objects", - annotations: { identifier: "A" }, - propertySignatures: [ - { - name: "a", - type: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "A1" } - }, - isOptional: true, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - }) - - describe("transformation schemas with identifiers", () => { - it("Class", () => { - class A extends Schema.Class("A")({ - a: Schema.String - }) {} - assertFromAST(Schema.Tuple([A, A]), { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "A" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "A" } - } - ], - rest: [], - checks: [] - }, - references: { - A: { - _tag: "Objects", - propertySignatures: [ - { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] - } - } - }) - }) - }) - }) -}) diff --git a/packages/effect/test/schema/representation/fromJson.test.ts b/packages/effect/test/schema/representation/fromJson.test.ts new file mode 100644 index 00000000000..4062ce91f92 --- /dev/null +++ b/packages/effect/test/schema/representation/fromJson.test.ts @@ -0,0 +1,361 @@ +import { assert, describe, it } from "@effect/vitest" +import { SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +describe("SchemaRepresentation.fromJson", () => { + it("decodes a document", () => { + const input = { + representation: { + _tag: "String", + annotations: { description: "value" }, + checks: [] + }, + references: {} + } as const + + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Null", () => { + const input = { representation: { _tag: "Null", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Undefined", () => { + const input = { representation: { _tag: "Undefined", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Void", () => { + const input = { representation: { _tag: "Void", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Never", () => { + const input = { representation: { _tag: "Never", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Unknown", () => { + const input = { representation: { _tag: "Unknown", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Any", () => { + const input = { representation: { _tag: "Any", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Number", () => { + const input = { representation: { _tag: "Number", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Boolean", () => { + const input = { representation: { _tag: "Boolean", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes BigInt", () => { + const input = { representation: { _tag: "BigInt", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Symbol", () => { + const input = { representation: { _tag: "Symbol", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes ObjectKeyword", () => { + const input = { representation: { _tag: "ObjectKeyword", checks: [] }, references: {} } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Enum", () => { + const input = { + representation: { _tag: "Enum", enums: [["A", "a"], ["One", 1]], checks: [] }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes TemplateLiteral", () => { + const input = { + representation: { + _tag: "TemplateLiteral", + parts: [ + { _tag: "Literal", literal: "prefix-", checks: [] }, + { _tag: "String", checks: [] } + ], + checks: [] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Arrays", () => { + const input = { + representation: { + _tag: "Arrays", + elements: [{ type: { _tag: "String", checks: [] }, isOptional: false }], + rest: [{ _tag: "Number", checks: [] }], + checks: [] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Objects", () => { + const input = { + representation: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: true + }], + indexSignatures: [{ + parameter: { _tag: "String", checks: [] }, + type: { _tag: "Number", checks: [] } + }], + checks: [] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Union", () => { + const input = { + representation: { + _tag: "Union", + types: [{ _tag: "String", checks: [] }, { _tag: "Number", checks: [] }], + mode: "oneOf", + checks: [] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Reference", () => { + const input = { + representation: { _tag: "Reference", $ref: "Value" }, + references: { Value: { _tag: "String", checks: [] } } + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Suspend", () => { + const input = { + representation: { + _tag: "Suspend", + thunk: { _tag: "Reference", $ref: "Value" }, + checks: [] + }, + references: { Value: { _tag: "String", checks: [] } } + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Declaration", () => { + const input = { + representation: { + _tag: "Declaration", + representation: { id: "acme/schema/Value", payload: null }, + annotations: {}, + typeParameters: [{ _tag: "String", checks: [] }], + checks: [] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes Filter", () => { + const input = { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + representation: { id: "acme/schema/filter", payload: null }, + annotations: {}, + aborted: true + }] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes FilterGroup", () => { + const input = { + representation: { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + checks: [{ + _tag: "Filter", + representation: { id: "acme/schema/filter", payload: null }, + annotations: {}, + aborted: false + }] + }] + }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("decodes bigint structural values", () => { + assert.deepStrictEqual( + SchemaRepresentation.fromJson({ + representation: { + _tag: "Literal", + literal: "1", + checks: [] + }, + references: {} + }), + { + representation: { _tag: "Literal", literal: 1n, checks: [] }, + references: {} + } + ) + }) + + it("decodes negative zero structural values", () => { + const document = SchemaRepresentation.fromJson({ + representation: { + _tag: "Literal", + literal: -0, + checks: [] + }, + references: {} + }) + + assert.strictEqual(document.representation._tag, "Literal") + if (document.representation._tag !== "Literal") return + assert.isTrue(Object.is(document.representation.literal, -0)) + }) + + it("preserves string literals resembling non-finite numbers", () => { + for (const literal of ["NaN", "Infinity", "-Infinity"]) { + const input = { + representation: { _tag: "Literal", literal, checks: [] }, + references: {} + } as const + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + } + }) + + it("decodes global symbols", () => { + assert.deepStrictEqual( + SchemaRepresentation.fromJson({ + representation: { + _tag: "UniqueSymbol", + symbol: "Symbol(acme/schema/key)", + checks: [] + }, + references: {} + }), + { + representation: { + _tag: "UniqueSymbol", + symbol: Symbol.for("acme/schema/key"), + checks: [] + }, + references: {} + } + ) + }) + + it("does not coerce strings resembling symbols", () => { + const input = { + representation: { _tag: "Literal", literal: "Symbol(a)", checks: [] }, + references: {} + } as const + + assert.deepStrictEqual(SchemaRepresentation.fromJson(input), input) + }) + + it("requires representation on persisted Filters", () => { + throws( + () => + SchemaRepresentation.fromJson({ + representation: { _tag: "String", checks: [{ _tag: "Filter", aborted: false }] }, + references: {} + }), + `Missing key\n at ["representation"]["checks"][0]["representation"]` + ) + }) + + it("requires representation on persisted Declarations", () => { + throws( + () => + SchemaRepresentation.fromJson({ + representation: { _tag: "Declaration", typeParameters: [], checks: [] }, + references: {} + }), + `Missing key\n at ["representation"]["representation"]` + ) + }) + + it("rejects empty references", () => { + throws( + () => + SchemaRepresentation.fromJson({ + representation: { _tag: "Reference", $ref: "" }, + references: {} + }), + `Expected a value with a length of at least 1, got ""\n at ["representation"]["$ref"]` + ) + }) + + it("rejects non-JSON annotations", () => { + throws( + () => + SchemaRepresentation.fromJson({ + representation: { + _tag: "String", + annotations: { invalid: 1n }, + checks: [] + }, + references: {} + } as never), + `Expected JSON value, got 1n\n at ["representation"]["annotations"]["invalid"]` + ) + }) + + it("rejects non-JSON roots", () => { + const input = () => undefined + throws( + () => SchemaRepresentation.fromJson(input as never), + `Expected object, got () => void 0` + ) + }) + + it("does not invoke toJSON", () => { + let calls = 0 + const input = { + representation: { _tag: "String", checks: [], unexpected: true }, + references: {}, + toJSON() { + calls++ + return null + } + } + + assert.deepStrictEqual( + SchemaRepresentation.fromJson(input as never), + { + representation: { _tag: "String", checks: [] }, + references: {} + } + ) + assert.strictEqual(calls, 0) + }) +}) diff --git a/packages/effect/test/schema/representation/fromJsonMultiDocument.test.ts b/packages/effect/test/schema/representation/fromJsonMultiDocument.test.ts new file mode 100644 index 00000000000..f046f5988f3 --- /dev/null +++ b/packages/effect/test/schema/representation/fromJsonMultiDocument.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from "@effect/vitest" +import { SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +describe("SchemaRepresentation.fromJsonMultiDocument", () => { + it("decodes every root in order", () => { + const input = { + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Number", checks: [] }, + { _tag: "Literal", literal: "1", checks: [] } + ], + references: {} + } as const + + assert.deepStrictEqual(SchemaRepresentation.fromJsonMultiDocument(input), { + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Number", checks: [] }, + { _tag: "Literal", literal: 1n, checks: [] } + ], + references: {} + }) + }) + + it("decodes shared references", () => { + const input = { + representations: [ + { _tag: "Reference", $ref: "Shared" }, + { _tag: "Reference", $ref: "Shared" } + ], + references: { + Shared: { _tag: "String", checks: [] } + } + } as const + + assert.deepStrictEqual(SchemaRepresentation.fromJsonMultiDocument(input), input) + }) + + it("rejects an empty roots array", () => { + throws( + () => SchemaRepresentation.fromJsonMultiDocument({ representations: [], references: {} }), + `Missing key\n at ["representations"][0]` + ) + }) +}) diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index 9c526a7b8bc..75d9189b005 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -2,54 +2,53 @@ import { JsonSchema, Schema, SchemaRepresentation } from "effect" import { describe, it } from "vitest" import { assertFalse, assertTrue, deepStrictEqual, strictEqual } from "../../utils/assert.ts" -const json = (annotations?: Schema.Annotations.Annotations) => { - const representation = SchemaRepresentation.fromAST(Schema.Json.ast).representation - return annotations === undefined - ? representation - : { - ...representation, - annotations: { - ...("annotations" in representation ? representation.annotations : undefined), - ...annotations - } - } +function toSchemaFromJsonSchemaDocument( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions +): Schema.Top { + return SchemaRepresentation.fromJsonSchemaDocument(document, options) +} + +function fromJsonSchemaRepresentation( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions +): SchemaRepresentation.Document { + return SchemaRepresentation.toRepresentation(SchemaRepresentation.fromJsonSchemaDocument(document, options).ast) } describe("fromJsonSchemaDocument", () => { function assertFromJsonSchema( input: { readonly schema: JsonSchema.JsonSchema - readonly options?: { - readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined - } - }, - expected: { - readonly representation: SchemaRepresentation.Representation - readonly references?: Record + readonly options?: SchemaRepresentation.FromJsonSchemaOptions }, - runtime?: string + expected: Schema.Json ) { - const expectedDocument: SchemaRepresentation.Document = { - representation: expected.representation, - references: expected.references ?? {} - } const jsonDocument = JsonSchema.fromSchemaDraft2020_12(input.schema) - const document = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, input.options) - deepStrictEqual(document, expectedDocument) - const multiDocument = SchemaRepresentation.toMultiDocument(document) - if (runtime !== undefined) { - strictEqual(SchemaRepresentation.toCodeDocument(multiDocument).codes[0].runtime, runtime) - } - return document + const schema = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, input.options) + const document = SchemaRepresentation.toRepresentation(schema.ast) + deepStrictEqual(SchemaRepresentation.toJson(document), expected) + return schema } it("{}", () => { assertFromJsonSchema( { schema: {} }, { - representation: json() - }, - "Schema.Json" + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) assertFromJsonSchema( { @@ -63,16 +62,28 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: json({ - title: "a", - description: "b", - default: "c", - examples: ["d"], - readOnly: true, - writeOnly: true - }) - }, - `Schema.Json.annotate({ "title": "a", "description": "b", "default": "c", "examples": ["d"], "readOnly": true, "writeOnly": true })` + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value", + "title": "a", + "description": "b", + "default": "c", + "examples": [ + "d" + ], + "readOnly": true, + "writeOnly": true + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) }) @@ -81,16 +92,27 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { const: "a" } }, { - representation: { _tag: "Literal", literal: "a" } - }, - `Schema.Literal("a")` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + "references": {} + } ) assertFromJsonSchema( { schema: { const: "a", description: "a" } }, { - representation: { _tag: "Literal", literal: "a", annotations: { description: "a" } } - }, - `Schema.Literal("a").annotate({ "description": "a" })` + "representation": { + "_tag": "Literal", + "annotations": { + "description": "a" + }, + "checks": [], + "literal": "a" + }, + "references": {} + } ) }) @@ -98,9 +120,13 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { const: 1 } }, { - representation: { _tag: "Literal", literal: 1 } - }, - `Schema.Literal(1)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + "references": {} + } ) }) @@ -108,9 +134,13 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { const: true } }, { - representation: { _tag: "Literal", literal: true } - }, - `Schema.Literal(true)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": true + }, + "references": {} + } ) }) @@ -118,16 +148,25 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { const: null } }, { - representation: { _tag: "Null" } - }, - `Schema.Null` + "representation": { + "_tag": "Null", + "checks": [] + }, + "references": {} + } ) assertFromJsonSchema( { schema: { const: null, description: "a" } }, { - representation: { _tag: "Null", annotations: { description: "a" } } - }, - `Schema.Null.annotate({ "description": "a" })` + "representation": { + "_tag": "Null", + "annotations": { + "description": "a" + }, + "checks": [] + }, + "references": {} + } ) }) @@ -135,9 +174,20 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { const: {} } }, { - representation: json() - }, - `Schema.Json` + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) }) }) @@ -147,16 +197,27 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { enum: ["a"] } }, { - representation: { _tag: "Literal", literal: "a" } - }, - `Schema.Literal("a")` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + "references": {} + } ) assertFromJsonSchema( { schema: { enum: ["a"], description: "a" } }, { - representation: { _tag: "Literal", literal: "a", annotations: { description: "a" } } - }, - `Schema.Literal("a").annotate({ "description": "a" })` + "representation": { + "_tag": "Literal", + "annotations": { + "description": "a" + }, + "checks": [], + "literal": "a" + }, + "references": {} + } ) }) @@ -164,9 +225,13 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { enum: [1] } }, { - representation: { _tag: "Literal", literal: 1 } - }, - `Schema.Literal(1)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + "references": {} + } ) }) @@ -174,9 +239,13 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { enum: [true] } }, { - representation: { _tag: "Literal", literal: true } - }, - `Schema.Literal(true)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": true + }, + "references": {} + } ) }) @@ -184,31 +253,51 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { enum: ["a", 1] } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, - { _tag: "Literal", literal: 1 } + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + { + "_tag": "Literal", + "checks": [], + "literal": 1 + } ], - mode: "anyOf" - } - }, - `Schema.Literals(["a", 1])` + "mode": "anyOf" + }, + "references": {} + } ) assertFromJsonSchema( { schema: { enum: ["a", 1], description: "a" } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, - { _tag: "Literal", literal: 1 } + "representation": { + "_tag": "Union", + "annotations": { + "description": "a" + }, + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + { + "_tag": "Literal", + "checks": [], + "literal": 1 + } ], - mode: "anyOf", - annotations: { description: "a" } - } - }, - `Schema.Literals(["a", 1]).annotate({ "description": "a" })` + "mode": "anyOf" + }, + "references": {} + } ) }) @@ -216,16 +305,24 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { enum: ["a", null] } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, - { _tag: "Null" } + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + { + "_tag": "Null", + "checks": [] + } ], - mode: "anyOf" - } - }, - `Schema.Union([Schema.Literal("a"), Schema.Null])` + "mode": "anyOf" + }, + "references": {} + } ) }) }) @@ -234,23 +331,37 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { anyOf: [{ const: "a" }, { enum: [1, 2] }] } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, { - _tag: "Union", - types: [ - { _tag: "Literal", literal: 1 }, - { _tag: "Literal", literal: 2 } + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + { + "_tag": "Literal", + "checks": [], + "literal": 2 + } ], - mode: "anyOf" + "mode": "anyOf" } ], - mode: "anyOf" - } - }, - `Schema.Union([Schema.Literal("a"), Schema.Literals([1, 2])])` + "mode": "anyOf" + }, + "references": {} + } ) }) @@ -268,35 +379,120 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [ + "representation": { + "_tag": "Union", + "checks": [], + "types": [ { - _tag: "Objects", - propertySignatures: [ - { name: "a", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } }, - { name: "id", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } } + "_tag": "Objects", + "checks": [], + "propertySignatures": [ + { + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + }, + { + "name": "id", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + } ], - indexSignatures: [{ parameter: { _tag: "String", checks: [] }, type: json() }], - checks: [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] }, { - _tag: "Objects", - propertySignatures: [ + "_tag": "Objects", + "checks": [], + "propertySignatures": [ { - name: "b", - isOptional: false, - isMutable: false, - type: { _tag: "Number", checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }] } + "name": "b", + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } + ] + }, + "isOptional": false, + "isMutable": false }, - { name: "id", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } } + { + "name": "id", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + } ], - indexSignatures: [{ parameter: { _tag: "String", checks: [] }, type: json() }], - checks: [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] } ], - mode: "anyOf" - } + "mode": "anyOf" + }, + "references": {} } ) }) @@ -305,23 +501,37 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { oneOf: [{ const: "a" }, { enum: [1, 2] }] } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, { - _tag: "Union", - types: [ - { _tag: "Literal", literal: 1 }, - { _tag: "Literal", literal: 2 } + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + { + "_tag": "Literal", + "checks": [], + "literal": 2 + } ], - mode: "anyOf" + "mode": "anyOf" } ], - mode: "oneOf" - } - }, - `Schema.Union([Schema.Literal("a"), Schema.Literals([1, 2])], { mode: "oneOf" })` + "mode": "oneOf" + }, + "references": {} + } ) }) @@ -339,35 +549,120 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [ + "representation": { + "_tag": "Union", + "checks": [], + "types": [ { - _tag: "Objects", - propertySignatures: [ - { name: "a", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } }, - { name: "id", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } } + "_tag": "Objects", + "checks": [], + "propertySignatures": [ + { + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + }, + { + "name": "id", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + } ], - indexSignatures: [{ parameter: { _tag: "String", checks: [] }, type: json() }], - checks: [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] }, { - _tag: "Objects", - propertySignatures: [ + "_tag": "Objects", + "checks": [], + "propertySignatures": [ { - name: "b", - isOptional: false, - isMutable: false, - type: { _tag: "Number", checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }] } + "name": "b", + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } + ] + }, + "isOptional": false, + "isMutable": false }, - { name: "id", isOptional: false, isMutable: false, type: { _tag: "String", checks: [] } } + { + "name": "id", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + } ], - indexSignatures: [{ parameter: { _tag: "String", checks: [] }, type: json() }], - checks: [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] } ], - mode: "oneOf" - } + "mode": "oneOf" + }, + "references": {} } ) }) @@ -377,9 +672,12 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "null" } }, { - representation: { _tag: "Null" } - }, - `Schema.Null` + "representation": { + "_tag": "Null", + "checks": [] + }, + "references": {} + } ) }) }) @@ -389,9 +687,12 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "string" } }, { - representation: { _tag: "String", checks: [] } - }, - `Schema.String` + "representation": { + "_tag": "String", + "checks": [] + }, + "references": {} + } ) }) @@ -400,12 +701,32 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "string", minLength: 1 } }, { - representation: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }] - } - }, - `Schema.String.check(Schema.isMinLength(1))` + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -413,12 +734,32 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "string", maxLength: 1 } }, { - representation: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 1 } }] - } - }, - `Schema.String.check(Schema.isMaxLength(1))` + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at most 1", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 1 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -426,26 +767,66 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "string", pattern: "a*" } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("a*") } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "a*", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp a*", + "arbitrary": { + "constraint": { + "patterns": [ + "a*" + ] + } + } + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isPattern(new RegExp("a*")))` + }, + "references": {} + } ) assertFromJsonSchema( { schema: { pattern: "a*" } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("a*") } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "a*", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp a*", + "arbitrary": { + "constraint": { + "patterns": [ + "a*" + ] + } + } + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isPattern(new RegExp("a*")))` + }, + "references": {} + } ) }) }) @@ -456,14 +837,30 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "number" } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite())` + }, + "references": {} + } ) }) @@ -472,15 +869,43 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "number", minimum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isGreaterThanOrEqualTo", minimum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 1 + } + }, + "annotations": { + "expected": "a value greater than or equal to 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isGreaterThanOrEqualTo(1))` + }, + "references": {} + } ) }) @@ -488,15 +913,43 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "number", maximum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isLessThanOrEqualTo", maximum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThanOrEqualTo", + "payload": { + "maximum": 1 + } + }, + "annotations": { + "expected": "a value less than or equal to 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isLessThanOrEqualTo(1))` + }, + "references": {} + } ) }) @@ -504,31 +957,87 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "number", exclusiveMinimum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isGreaterThan", exclusiveMinimum: 1 } } - ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isGreaterThan(1))` - ) - }) - - it("exclusiveMaximum", () => { - assertFromJsonSchema( - { schema: { type: "number", exclusiveMaximum: 1 } }, + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThan", + "payload": { + "exclusiveMinimum": 1 + } + }, + "annotations": { + "expected": "a value greater than 1" + }, + "aborted": false + } + ] + }, + "references": {} + } + ) + }) + + it("exclusiveMaximum", () => { + assertFromJsonSchema( + { schema: { type: "number", exclusiveMaximum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isLessThan", exclusiveMaximum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThan", + "payload": { + "exclusiveMaximum": 1 + } + }, + "annotations": { + "expected": "a value less than 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isLessThan(1))` + }, + "references": {} + } ) }) @@ -536,15 +1045,43 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "number", multipleOf: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isMultipleOf", divisor: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMultipleOf", + "payload": { + "divisor": 1 + } + }, + "annotations": { + "expected": "a value that is a multiple of 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isMultipleOf(1))` + }, + "references": {} + } ) }) }) @@ -555,14 +1092,29 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer" } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt())` + }, + "references": {} + } ) }) @@ -571,15 +1123,42 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer", minimum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isGreaterThanOrEqualTo", minimum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 1 + } + }, + "annotations": { + "expected": "a value greater than or equal to 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(1))` + }, + "references": {} + } ) }) @@ -587,15 +1166,42 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer", maximum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isLessThanOrEqualTo", maximum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThanOrEqualTo", + "payload": { + "maximum": 1 + } + }, + "annotations": { + "expected": "a value less than or equal to 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isLessThanOrEqualTo(1))` + }, + "references": {} + } ) }) @@ -603,15 +1209,42 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer", exclusiveMinimum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isGreaterThan", exclusiveMinimum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThan", + "payload": { + "exclusiveMinimum": 1 + } + }, + "annotations": { + "expected": "a value greater than 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(1))` + }, + "references": {} + } ) }) @@ -619,15 +1252,42 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer", exclusiveMaximum: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isLessThan", exclusiveMaximum: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThan", + "payload": { + "exclusiveMaximum": 1 + } + }, + "annotations": { + "expected": "a value less than 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isLessThan(1))` + }, + "references": {} + } ) }) @@ -635,15 +1295,42 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "integer", multipleOf: 1 } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isMultipleOf", divisor: 1 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMultipleOf", + "payload": { + "divisor": 1 + } + }, + "annotations": { + "expected": "a value that is a multiple of 1" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isMultipleOf(1))` + }, + "references": {} + } ) }) }) @@ -654,9 +1341,12 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "boolean" } }, { - representation: { _tag: "Boolean" } - }, - `Schema.Boolean` + "representation": { + "_tag": "Boolean", + "checks": [] + }, + "references": {} + } ) }) }) @@ -666,14 +1356,27 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "array" } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [] - } - }, - `Schema.Array(Schema.Json)` + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) }) @@ -686,9 +1389,19 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { _tag: "Arrays", elements: [], rest: [{ _tag: "String", checks: [] }], checks: [] } - }, - `Schema.Array(Schema.String)` + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [], + "rest": [ + { + "_tag": "String", + "checks": [] + } + ] + }, + "references": {} + } ) }) @@ -702,16 +1415,22 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: true, type: { _tag: "String", checks: [] } } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": true, + "type": { + "_tag": "String", + "checks": [] + } + } ], - rest: [], - checks: [] - } - }, - `Schema.Tuple([Schema.optionalKey(Schema.String)])` + "rest": [] + }, + "references": {} + } ) assertFromJsonSchema( @@ -724,16 +1443,22 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "String", checks: [] } } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + } ], - rest: [], - checks: [] - } - }, - `Schema.Tuple([Schema.String])` + "rest": [] + }, + "references": {} + } ) }) @@ -748,18 +1473,45 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "String", checks: [] } } - ], - rest: [ - { _tag: "Number", checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }] } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + } ], - checks: [] - } - }, - `Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number.check(Schema.isFinite())])` + "rest": [ + { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } + ] + } + ] + }, + "references": {} + } ) }) @@ -768,14 +1520,47 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "array", minItems: 1 } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [{ _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }] - } - }, - `Schema.Array(Schema.Json).check(Schema.isMinLength(1))` + "representation": { + "_tag": "Arrays", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) }) @@ -783,14 +1568,47 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "array", maxItems: 1 } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [{ _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 1 } }] - } - }, - `Schema.Array(Schema.Json).check(Schema.isMaxLength(1))` + "representation": { + "_tag": "Arrays", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at most 1", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 1 + } + } + }, + "aborted": false + } + ], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) }) @@ -798,14 +1616,44 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "array", uniqueItems: true } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [{ _tag: "Filter", meta: { _tag: "isUnique" } }] - } - }, - `Schema.Array(Schema.Json).check(Schema.isUnique())` + "representation": { + "_tag": "Arrays", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isUnique", + "payload": null + }, + "annotations": { + "expected": "an array with unique items", + "arbitrary": { + "constraint": { + "unique": true + } + } + }, + "aborted": false + } + ], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) }) }) @@ -816,16 +1664,33 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "object" } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: json() } - ], - checks: [] - } - }, - `Schema.Record(Schema.String, Schema.Json)` + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] + }, + "references": {} + } ) assertFromJsonSchema( { @@ -835,14 +1700,14 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [], - checks: [] - } - }, - `Schema.Struct({ })` + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [] + }, + "references": {} + } ) }) @@ -855,16 +1720,25 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: { _tag: "Boolean" } } - ], - checks: [] - } - }, - `Schema.Record(Schema.String, Schema.Boolean)` + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Boolean", + "checks": [] + } + } + ] + }, + "references": {} + } ) }) @@ -879,27 +1753,33 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [ + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [ { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false }, { - name: "b", - type: { _tag: "String", checks: [] }, - isOptional: true, - isMutable: false + "name": "b", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": true, + "isMutable": false } ], - indexSignatures: [], - checks: [] - } - }, - `Schema.Struct({ "a": Schema.String, "b": Schema.optionalKey(Schema.String) })` + "indexSignatures": [] + }, + "references": {} + } ) }) @@ -914,21 +1794,35 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [{ - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: { _tag: "Boolean" } } + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [ + { + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false + } ], - checks: [] - } - }, - `Schema.StructWithRest(Schema.Struct({ "a": Schema.String }), [Schema.Record(Schema.String, Schema.Boolean)])` + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Boolean", + "checks": [] + } + } + ] + }, + "references": {} + } ) }) @@ -944,22 +1838,47 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [ { - parameter: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("a*") } }] + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "a*", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp a*", + "arbitrary": { + "constraint": { + "patterns": [ + "a*" + ] + } + } + }, + "aborted": false + } + ] }, - type: { _tag: "String", checks: [] } + "type": { + "_tag": "String", + "checks": [] + } } - ], - checks: [] - } - }, - `Schema.Record(Schema.String.check(Schema.isPattern(new RegExp("a*"))), Schema.String)` + ] + }, + "references": {} + } ) assertFromJsonSchema( { @@ -973,29 +1892,97 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [ { - parameter: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("a*") } }] + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "a*", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp a*", + "arbitrary": { + "constraint": { + "patterns": [ + "a*" + ] + } + } + }, + "aborted": false + } + ] }, - type: { _tag: "String", checks: [] } + "type": { + "_tag": "String", + "checks": [] + } }, { - parameter: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("b*") } }] + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "b*", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp b*", + "arbitrary": { + "constraint": { + "patterns": [ + "b*" + ] + } + } + }, + "aborted": false + } + ] }, - type: { _tag: "Number", checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }] } + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } + ] + } } - ], - checks: [] - } - }, - `Schema.StructWithRest(Schema.Struct({ }), [Schema.Record(Schema.String.check(Schema.isPattern(new RegExp("a*"))), Schema.String), Schema.Record(Schema.String.check(Schema.isPattern(new RegExp("b*"))), Schema.Number.check(Schema.isFinite()))])` + ] + }, + "references": {} + } ) }) @@ -1004,16 +1991,53 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "object", minProperties: 1 } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: json() } + "representation": { + "_tag": "Objects", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinProperties", + "payload": { + "minProperties": 1 + } + }, + "annotations": { + "expected": "a value with at least 1 entry", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } ], - checks: [{ _tag: "Filter", meta: { _tag: "isMinProperties", minProperties: 1 } }] - } - }, - `Schema.Record(Schema.String, Schema.Json).check(Schema.isMinProperties(1))` + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] + }, + "references": {} + } ) }) @@ -1021,16 +2045,53 @@ describe("fromJsonSchemaDocument", () => { assertFromJsonSchema( { schema: { type: "object", maxProperties: 1 } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: json() } + "representation": { + "_tag": "Objects", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxProperties", + "payload": { + "maxProperties": 1 + } + }, + "annotations": { + "expected": "a value with at most 1 entry", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 1 + } + } + }, + "aborted": false + } ], - checks: [{ _tag: "Filter", meta: { _tag: "isMaxProperties", maxProperties: 1 } }] - } - }, - `Schema.Record(Schema.String, Schema.Json).check(Schema.isMaxProperties(1))` + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } + ] + }, + "references": {} + } ) }) }) @@ -1045,28 +2106,74 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ + "representation": { + "_tag": "Objects", + "checks": [ { - parameter: { _tag: "String", checks: [] }, - type: json() + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPropertyNames", + "payload": null, + "schemas": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[A-Z]", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp ^[A-Z]", + "arbitrary": { + "constraint": { + "patterns": [ + "^[A-Z]" + ] + } + } + }, + "aborted": false + } + ] + } + ] + }, + "annotations": { + "expected": "an object with property names matching the schema", + "~structural": true + }, + "aborted": false } ], - checks: [{ - _tag: "Filter", - meta: { - _tag: "isPropertyNames", - propertyNames: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("^[A-Z]") } }] + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] } } - }] - } - }, - `Schema.Record(Schema.String, Schema.Json).check(Schema.isPropertyNames(Schema.String.check(Schema.isPattern(new RegExp("^[A-Z]")))))` + ] + }, + "references": {} + } ) }) @@ -1079,18 +2186,52 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: json() } + "representation": { + "_tag": "Objects", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPropertyNames", + "payload": null, + "schemas": [ + { + "_tag": "Never", + "checks": [] + } + ] + }, + "annotations": { + "expected": "an object with property names matching the schema", + "~structural": true + }, + "aborted": false + } ], - checks: [ - { _tag: "Filter", meta: { _tag: "isPropertyNames", propertyNames: { _tag: "Never" } } } + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + } ] - } - }, - `Schema.Record(Schema.String, Schema.Json).check(Schema.isPropertyNames(Schema.Never))` + }, + "references": {} + } ) }) @@ -1105,37 +2246,112 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: json() } - ], - checks: [ + "representation": { + "_tag": "Objects", + "checks": [ { - _tag: "Filter", - meta: { - _tag: "isPropertyNames", - propertyNames: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isPattern", regExp: new RegExp("^[A-Z]") } }] - } - } + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPropertyNames", + "payload": null, + "schemas": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[A-Z]", + "flags": "" + } + }, + "annotations": { + "expected": "a string matching the RegExp ^[A-Z]", + "arbitrary": { + "constraint": { + "patterns": [ + "^[A-Z]" + ] + } + } + }, + "aborted": false + } + ] + } + ] + }, + "annotations": { + "expected": "an object with property names matching the schema", + "~structural": true + }, + "aborted": false }, { - _tag: "Filter", - meta: { - _tag: "isPropertyNames", - propertyNames: { - _tag: "String", - checks: [{ _tag: "Filter", meta: { _tag: "isMinLength", minLength: 2 } }] - } + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPropertyNames", + "payload": null, + "schemas": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at least 2", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 2 + } + } + }, + "aborted": false + } + ] + } + ] + }, + "annotations": { + "expected": "an object with property names matching the schema", + "~structural": true + }, + "aborted": false + } + ], + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] } } ] - } - }, - `Schema.Record(Schema.String, Schema.Json).check(Schema.isPropertyNames(Schema.String.check(Schema.isPattern(new RegExp("^[A-Z]"))))).check(Schema.isPropertyNames(Schema.String.check(Schema.isMinLength(2))))` + }, + "references": {} + } ) }) }) @@ -1147,13 +2363,23 @@ describe("fromJsonSchemaDocument", () => { schema: { type: ["string", "null"] } }, { - representation: { - _tag: "Union", - types: [{ _tag: "String", checks: [] }, { _tag: "Null" }], - mode: "anyOf" - } - }, - `Schema.Union([Schema.String, Schema.Null])` + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "String", + "checks": [] + }, + { + "_tag": "Null", + "checks": [] + } + ], + "mode": "anyOf" + }, + "references": {} + } ) assertFromJsonSchema( { @@ -1163,18 +2389,128 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [{ _tag: "String", checks: [] }, { _tag: "Null" }], - mode: "anyOf", - annotations: { description: "a" } + "representation": { + "_tag": "Union", + "annotations": { + "description": "a" + }, + "checks": [], + "types": [ + { + "_tag": "String", + "checks": [] + }, + { + "_tag": "Null", + "checks": [] + } + ], + "mode": "anyOf" + }, + "references": {} + } + ) + }) + + it("imports true schemas and structured enum members", () => { + assertFromJsonSchema( + { schema: { allOf: [true, { type: "string" }] } }, + { + "representation": { + "_tag": "String", + "checks": [] + }, + "references": {} + } + ) + assertFromJsonSchema( + { schema: { enum: [[], {}] } }, + { + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + }, + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ], + "mode": "anyOf" + }, + "references": {} + } + ) + }) + + it("imports built-in JSON Schema annotations", () => { + assertFromJsonSchema( + { + schema: { + type: "string", + format: "email", + contentEncoding: "base64", + contentMediaType: "application/json", + contentSchema: { type: "number" } } }, - `Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "a" })` + { + "representation": { + "_tag": "String", + "annotations": { + "format": "email", + "contentEncoding": "base64", + "contentMediaType": "application/json", + "contentSchema": { "type": "number" } + }, + "checks": [] + }, + "references": {} + } ) }) describe("$ref", () => { + it("treats a reference with an empty token as unconstrained", () => { + assertFromJsonSchema( + { schema: { $ref: "" } }, + { + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } + ) + }) + it("should create a Reference and a definition", () => { assertFromJsonSchema( { @@ -1188,18 +2524,28 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { _tag: "Reference", $ref: "A" }, - references: { - A: { - _tag: "String", - checks: [] + "representation": { + "_tag": "Reference", + "$ref": "A" + }, + "references": { + "A": { + "_tag": "Suspend", + "annotations": { + "identifier": "A" + }, + "checks": [], + "thunk": { + "_tag": "String", + "checks": [] + } } } } ) }) - it("should resolve the $ref if there are annotations", () => { + it("should preserve an annotated $ref as a stable suspend", () => { assertFromJsonSchema( { schema: { @@ -1213,22 +2559,35 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [], - annotations: { description: "a" } - }, - references: { - A: { - _tag: "String", - checks: [] + "representation": { + "_tag": "Suspend", + "annotations": { + "description": "a" + }, + "checks": [], + "thunk": { + "_tag": "Reference", + "$ref": "A" + } + }, + "references": { + "A": { + "_tag": "Suspend", + "annotations": { + "identifier": "A" + }, + "checks": [], + "thunk": { + "_tag": "String", + "checks": [] + } } } } ) }) - it("should resolve the $ref if there is an allOf", () => { + it("should preserve a $ref refined only by annotations as a stable suspend", () => { assertFromJsonSchema( { schema: { @@ -1244,15 +2603,28 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [], - annotations: { description: "a" } - }, - references: { - A: { - _tag: "String", - checks: [] + "representation": { + "_tag": "Suspend", + "annotations": { + "description": "a" + }, + "checks": [], + "thunk": { + "_tag": "Reference", + "$ref": "A" + } + }, + "references": { + "A": { + "_tag": "Suspend", + "annotations": { + "identifier": "A" + }, + "checks": [], + "thunk": { + "_tag": "String", + "checks": [] + } } } } @@ -1288,38 +2660,49 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { _tag: "Reference", $ref: "A" }, - references: { - A: { - _tag: "Objects", - propertySignatures: [ - { - name: "name", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }, - { - name: "children", - type: { - _tag: "Arrays", - elements: [], - rest: [{ - _tag: "Suspend", - checks: [], - thunk: { - _tag: "Reference", - $ref: "A" - } - }], - checks: [] + "representation": { + "_tag": "Reference", + "$ref": "A" + }, + "references": { + "A": { + "_tag": "Suspend", + "annotations": { + "identifier": "A" + }, + "checks": [], + "thunk": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [ + { + "name": "name", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false }, - isOptional: false, - isMutable: false - } - ], - indexSignatures: [], - checks: [] + { + "name": "children", + "type": { + "_tag": "Arrays", + "checks": [], + "elements": [], + "rest": [ + { + "_tag": "Reference", + "$ref": "A" + } + ] + }, + "isOptional": false, + "isMutable": false + } + ], + "indexSignatures": [] + } } } } @@ -1330,21 +2713,6 @@ describe("fromJsonSchemaDocument", () => { describe("allOf", () => { it("resolves references on either side of an intersection", () => { const definition: JsonSchema.JsonSchema = { type: "string", minLength: 1 } - const expected = { - representation: { - _tag: "String" as const, - checks: [ - { _tag: "Filter" as const, meta: { _tag: "isMinLength" as const, minLength: 1 } }, - { _tag: "Filter" as const, meta: { _tag: "isMaxLength" as const, maxLength: 2 } } - ] - }, - references: { - A: { - _tag: "String" as const, - checks: [{ _tag: "Filter" as const, meta: { _tag: "isMinLength" as const, minLength: 1 } }] - } - } - } for ( const schema of [ { @@ -1358,7 +2726,52 @@ describe("fromJsonSchemaDocument", () => { } ] ) { - assertFromJsonSchema({ schema }, expected) + assertFromJsonSchema({ schema }, { + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + }) } }) @@ -1371,15 +2784,30 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [], - annotations: { description: "a" } - } - }, - `Schema.Array(Schema.Json).annotate({ "description": "a" })` + "representation": { + "_tag": "Arrays", + "annotations": { + "description": "a" + }, + "checks": [], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) assertFromJsonSchema( { @@ -1390,15 +2818,17 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [], - checks: [], - annotations: { description: "a" } - } - }, - `Schema.Struct({ }).annotate({ "description": "a" })` + "representation": { + "_tag": "Objects", + "annotations": { + "description": "a" + }, + "checks": [], + "propertySignatures": [], + "indexSignatures": [] + }, + "references": {} + } ) }) @@ -1410,8 +2840,13 @@ describe("fromJsonSchemaDocument", () => { allOf: [{ anyOf: [{ type: "number" }, { type: "boolean" }] }] } }, - { representation: { _tag: "Never" } }, - `Schema.Never` + { + "representation": { + "_tag": "Never", + "checks": [] + }, + "references": {} + } ) }) @@ -1422,10 +2857,10 @@ describe("fromJsonSchemaDocument", () => { ) { for (const literal of [valid, invalid]) { for (const allOf of [[refinement, { const: literal }], [{ const: literal }, refinement]]) { - const document = SchemaRepresentation.fromJsonSchemaDocument( + const schema = toSchemaFromJsonSchemaDocument( JsonSchema.fromSchemaDraft2020_12({ allOf }) ) - const is = Schema.is(SchemaRepresentation.toSchema(document)) + const is = Schema.is(schema) if (literal === valid) { assertTrue(is(literal)) } else { @@ -1436,58 +2871,63 @@ describe("fromJsonSchemaDocument", () => { } describe("literal refinements", () => { - const cases: ReadonlyArray< - readonly [ - name: string, - refinement: JsonSchema.JsonSchema, - valid: string | number, - invalid: string | number - ] - > = [ - ["minLength", { type: "string", minLength: 2 }, "ab", "a"], - ["maxLength", { type: "string", maxLength: 1 }, "a", "ab"], - ["pattern", { type: "string", pattern: "^a+$" }, "aa", "ab"], - ["integer", { type: "integer" }, 1, 1.5], - ["multipleOf", { type: "number", multipleOf: 0.1 }, 0.3, 0.31], - [ - "multipleOf beyond the toFixed precision limit", - { type: "number", multipleOf: Number("1e-101") }, - 0, - Number("5e-102") - ], - ["multipleOf with a large scientific operand", { type: "number", multipleOf: 2 }, Number("1e21"), 1], - [ - "multipleOf with a nonzero subnormal remainder", - { type: "number", multipleOf: Number("1e-323") }, - 0, - Number("1.042e-321") - ], - ["minimum", { type: "number", minimum: 1 }, 1, 0], - ["maximum", { type: "number", maximum: 1 }, 1, 2], - ["exclusiveMinimum", { type: "number", exclusiveMinimum: 1 }, 2, 1], - ["exclusiveMaximum", { type: "number", exclusiveMaximum: 1 }, 0, 1], - [ - "filter group", + it("minLength", () => { + assertLiteralRefinement({ type: "string", minLength: 2 }, "ab", "a") + }) + + it("maxLength", () => { + assertLiteralRefinement({ type: "string", maxLength: 1 }, "a", "ab") + }) + + it("pattern", () => { + assertLiteralRefinement({ type: "string", pattern: "^a+$" }, "aa", "ab") + }) + + it("integer", () => { + assertLiteralRefinement({ type: "integer" }, 1, 1.5) + }) + + it("multipleOf", () => { + assertLiteralRefinement({ type: "number", multipleOf: 0.1 }, 0.3, 0.31) + }) + + it("multipleOf beyond the toFixed precision limit", () => { + assertLiteralRefinement({ type: "number", multipleOf: Number("1e-101") }, 0, Number("5e-102")) + }) + + it("multipleOf with a large scientific operand", () => { + assertLiteralRefinement({ type: "number", multipleOf: 2 }, Number("1e21"), 1) + }) + + it("multipleOf with a nonzero subnormal remainder", () => { + assertLiteralRefinement({ type: "number", multipleOf: Number("1e-323") }, 0, Number("1.042e-321")) + }) + + it("minimum", () => { + assertLiteralRefinement({ type: "number", minimum: 1 }, 1, 0) + }) + + it("maximum", () => { + assertLiteralRefinement({ type: "number", maximum: 1 }, 1, 2) + }) + + it("exclusiveMinimum", () => { + assertLiteralRefinement({ type: "number", exclusiveMinimum: 1 }, 2, 1) + }) + + it("exclusiveMaximum", () => { + assertLiteralRefinement({ type: "number", exclusiveMaximum: 1 }, 0, 1) + }) + + it("filter group", () => { + assertLiteralRefinement( { type: "number", allOf: [{ minimum: 1, maximum: 2, description: "range" }] }, 2, 0 - ] - ] - - for (const [name, refinement, valid, invalid] of cases) { - it(name, () => { - assertLiteralRefinement(refinement, valid, invalid) - }) - } + ) + }) it("filters enum members", () => { - const expected = { - representation: { - _tag: "Union" as const, - types: [{ _tag: "Literal" as const, literal: "ab" }], - mode: "anyOf" as const - } - } for ( const [schema, member] of [ [{ type: "string", minLength: 2 }, { enum: ["a", "ab"] }], @@ -1496,8 +2936,21 @@ describe("fromJsonSchemaDocument", () => { ) { assertFromJsonSchema( { schema: { ...schema, allOf: [member] } }, - expected, - `Schema.Literal("ab")` + { + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "ab" + } + ], + "mode": "anyOf" + }, + "references": {} + } ) } }) @@ -1513,12 +2966,12 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [] - } - }, - `Schema.String` + "representation": { + "_tag": "String", + "checks": [] + }, + "references": {} + } ) }) @@ -1534,14 +2987,32 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isMinLength(1))` + }, + "references": {} + } ) }) @@ -1556,14 +3027,33 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 }, annotations: { description: "b" } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + }, + "description": "b" + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isMinLength(1, { "description": "b" }))` + }, + "references": {} + } ) }) @@ -1579,15 +3069,35 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } } - ], - annotations: { description: "a" } - } - }, - `Schema.String.annotate({ "description": "a" }).check(Schema.isMinLength(1))` + "representation": { + "_tag": "String", + "annotations": { + "description": "a" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -1603,13 +3113,15 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [], - annotations: { description: "b" } - } - }, - `Schema.String.annotate({ "description": "b" })` + "representation": { + "_tag": "String", + "annotations": { + "description": "b" + }, + "checks": [] + }, + "references": {} + } ) }) @@ -1625,15 +3137,36 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 }, annotations: { description: "b" } } - ], - annotations: { description: "a" } - } - }, - `Schema.String.annotate({ "description": "a" }).check(Schema.isMinLength(1, { "description": "b" }))` + "representation": { + "_tag": "String", + "annotations": { + "description": "a" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + }, + "description": "b" + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -1649,15 +3182,51 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 } }, - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isMaxLength(2)).check(Schema.isMinLength(1))` + }, + "references": {} + } ) }) @@ -1674,16 +3243,54 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 } }, - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } } - ], - annotations: { description: "a" } - } - }, - `Schema.String.annotate({ "description": "a" }).check(Schema.isMaxLength(2)).check(Schema.isMinLength(1))` + "representation": { + "_tag": "String", + "annotations": { + "description": "a" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -1700,16 +3307,55 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 } }, - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 }, annotations: { description: "b" } } - ], - annotations: { description: "a" } - } - }, - `Schema.String.annotate({ "description": "a" }).check(Schema.isMaxLength(2)).check(Schema.isMinLength(1, { "description": "b" }))` + "representation": { + "_tag": "String", + "annotations": { + "description": "a" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + }, + "description": "b" + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -1724,15 +3370,51 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }, - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isMinLength(1)).check(Schema.isMaxLength(2))` + }, + "references": {} + } ) }) @@ -1747,21 +3429,59 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ + "representation": { + "_tag": "String", + "checks": [ { - _tag: "FilterGroup", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }, - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 } } - ], - annotations: { description: "b" } + "_tag": "FilterGroup", + "annotations": { + "description": "b" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + } + }, + "aborted": false + } + ] } ] - } - }, - `Schema.String.check(Schema.makeFilterGroup([Schema.isMinLength(1), Schema.isMaxLength(2)], { "description": "b" }))` + }, + "references": {} + } ) }) @@ -1776,15 +3496,52 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }, - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 }, annotations: { description: "c" } } + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + }, + "description": "c" + }, + "aborted": false + } ] - } - }, - `Schema.String.check(Schema.isMinLength(1)).check(Schema.isMaxLength(2, { "description": "c" }))` + }, + "references": {} + } ) }) @@ -1799,21 +3556,60 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "String", - checks: [ + "representation": { + "_tag": "String", + "checks": [ { - _tag: "FilterGroup", - checks: [ - { _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }, - { _tag: "Filter", meta: { _tag: "isMaxLength", maxLength: 2 }, annotations: { description: "c" } } - ], - annotations: { description: "b" } + "_tag": "FilterGroup", + "annotations": { + "description": "b" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 2 + } + }, + "annotations": { + "expected": "a value with a length of at most 2", + "~structural": true, + "arbitrary": { + "constraint": { + "maxLength": 2 + } + }, + "description": "c" + }, + "aborted": false + } + ] } ] - } - }, - `Schema.String.check(Schema.makeFilterGroup([Schema.isMinLength(1), Schema.isMaxLength(2, { "description": "c" })], { "description": "b" }))` + }, + "references": {} + } ) }) @@ -1828,12 +3624,13 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: "a" - } - }, - `Schema.Literal("a")` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + "references": {} + } ) assertFromJsonSchema( { @@ -1846,13 +3643,16 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: "a", - annotations: { description: "b" } - } - }, - `Schema.Literal("a").annotate({ "description": "b" })` + "representation": { + "_tag": "Literal", + "annotations": { + "description": "b" + }, + "checks": [], + "literal": "a" + }, + "references": {} + } ) assertFromJsonSchema( { @@ -1864,16 +3664,25 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" }, - { _tag: "Literal", literal: "b" } + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + }, + { + "_tag": "Literal", + "checks": [], + "literal": "b" + } ], - mode: "anyOf" - } - }, - `Schema.Literals(["a", "b"])` + "mode": "anyOf" + }, + "references": {} + } ) }) @@ -1888,20 +3697,64 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: "a" } + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": "a" + } ], - mode: "anyOf" - } - }, - `Schema.Literal("a")` + "mode": "anyOf" + }, + "references": {} + } ) }) }) describe("type: number", () => { + it("number & number preserves annotations after removing duplicate checks", () => { + assertFromJsonSchema( + { + schema: { + type: "number", + allOf: [{ type: "number", description: "b" }] + } + }, + { + "representation": { + "_tag": "Number", + "annotations": { + "description": "b" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } + ) + }) + it("number & integer", () => { assertFromJsonSchema( { @@ -1913,15 +3766,46 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isInt" } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isInt())` + }, + "references": {} + } ) }) @@ -1937,17 +3821,72 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isGreaterThanOrEqualTo", minimum: 2 } }, - { _tag: "Filter", meta: { _tag: "isLessThanOrEqualTo", maximum: 2 } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 2 + } + }, + "annotations": { + "expected": "a value greater than or equal to 2" + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThanOrEqualTo", + "payload": { + "maximum": 2 + } + }, + "annotations": { + "expected": "a value less than or equal to 2" + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isFinite()).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(2)).check(Schema.isLessThanOrEqualTo(2))` + }, + "references": {} + } ) }) @@ -1962,15 +3901,46 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isInt" } }, - { _tag: "Filter", meta: { _tag: "isFinite" } } + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + } ] - } - }, - `Schema.Number.check(Schema.isInt()).check(Schema.isFinite())` + }, + "references": {} + } ) }) @@ -1985,26 +3955,154 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Number", - checks: [ - { _tag: "Filter", meta: { _tag: "isFinite" } }, + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, { - _tag: "FilterGroup", - checks: [ - { _tag: "Filter", meta: { _tag: "isGreaterThanOrEqualTo", minimum: 1 } }, + "_tag": "FilterGroup", + "annotations": { + "description": "b" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 1 + } + }, + "annotations": { + "expected": "a value greater than or equal to 1" + }, + "aborted": false + }, { - _tag: "Filter", - meta: { _tag: "isLessThanOrEqualTo", maximum: 2 }, - annotations: { description: "c" } + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThanOrEqualTo", + "payload": { + "maximum": 2 + } + }, + "annotations": { + "expected": "a value less than or equal to 2", + "description": "c" + }, + "aborted": false } - ], - annotations: { description: "b" } + ] } ] + }, + "references": {} + } + ) + }) + + it("continues intersecting after an annotated filter group", () => { + assertFromJsonSchema( + { + schema: { + type: "number", + allOf: [ + { minimum: 1, maximum: 2, description: "range" }, + { type: "integer" } + ] } }, - `Schema.Number.check(Schema.isFinite()).check(Schema.makeFilterGroup([Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(2, { "description": "c" })], { "description": "b" }))` + { + "representation": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + }, + "annotations": { + "expected": "a finite number", + "arbitrary": { + "constraint": { + "noInfinity": true, + "noNaN": true + } + } + }, + "aborted": false + }, + { + "_tag": "FilterGroup", + "annotations": { + "description": "range" + }, + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 1 + } + }, + "annotations": { + "expected": "a value greater than or equal to 1" + }, + "aborted": false + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isLessThanOrEqualTo", + "payload": { + "maximum": 2 + } + }, + "annotations": { + "expected": "a value less than or equal to 2" + }, + "aborted": false + } + ] + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + }, + "annotations": { + "expected": "an integer", + "arbitrary": { + "constraint": { + "integer": true + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } ) }) @@ -2019,12 +4117,13 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: 1 - } - }, - `Schema.Literal(1)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + "references": {} + } ) assertFromJsonSchema( { @@ -2037,13 +4136,16 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: 1, - annotations: { description: "b" } - } - }, - `Schema.Literal(1).annotate({ "description": "b" })` + "representation": { + "_tag": "Literal", + "annotations": { + "description": "b" + }, + "checks": [], + "literal": 1 + }, + "references": {} + } ) assertFromJsonSchema( { @@ -2055,21 +4157,66 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Union", - types: [ - { _tag: "Literal", literal: 1 }, - { _tag: "Literal", literal: 2 } + "representation": { + "_tag": "Union", + "checks": [], + "types": [ + { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + { + "_tag": "Literal", + "checks": [], + "literal": 2 + } ], - mode: "anyOf" - } - }, - `Schema.Literals([1, 2])` + "mode": "anyOf" + }, + "references": {} + } ) }) }) describe("type: boolean", () => { + it("boolean & boolean", () => { + assertFromJsonSchema( + { + schema: { + type: "boolean", + allOf: [{ type: "boolean" }] + } + }, + { + "representation": { + "_tag": "Boolean", + "checks": [] + }, + "references": {} + } + ) + }) + + it("boolean & non-boolean literal", () => { + assertFromJsonSchema( + { + schema: { + type: "boolean", + allOf: [{ const: 1 }] + } + }, + { + "representation": { + "_tag": "Never", + "checks": [] + }, + "references": {} + } + ) + }) + it("& boolean enum", () => { assertFromJsonSchema( { @@ -2081,12 +4228,13 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: true - } - }, - `Schema.Literal(true)` + "representation": { + "_tag": "Literal", + "checks": [], + "literal": true + }, + "references": {} + } ) assertFromJsonSchema( { @@ -2099,13 +4247,16 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Literal", - literal: true, - annotations: { description: "b" } - } - }, - `Schema.Literal(true).annotate({ "description": "b" })` + "representation": { + "_tag": "Literal", + "annotations": { + "description": "b" + }, + "checks": [], + "literal": true + }, + "references": {} + } ) }) }) @@ -2115,17 +4266,15 @@ describe("fromJsonSchemaDocument", () => { a: JsonSchema.JsonSchema, b: JsonSchema.JsonSchema, expected: Parameters[1], - runtime: string, valid: ReadonlyArray, invalid: ReadonlyArray ) { for (const [schema, member] of [[a, b], [b, a]]) { const document = assertFromJsonSchema( { schema: { ...schema, allOf: [member] } }, - expected, - runtime + expected ) - const is = Schema.is(SchemaRepresentation.toSchema(document)) + const is = Schema.is(document) for (const value of valid) { assertTrue(is(value)) } @@ -2147,14 +4296,44 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [json()], - checks: [{ _tag: "Filter", meta: { _tag: "isUnique" } }] - } - }, - `Schema.Array(Schema.Json).check(Schema.isUnique())` + "representation": { + "_tag": "Arrays", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isUnique", + "payload": null + }, + "annotations": { + "expected": "an array with unique items", + "arbitrary": { + "constraint": { + "unique": true + } + } + }, + "aborted": false + } + ], + "elements": [], + "rest": [ + { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value" + }, + "typeParameters": [], + "checks": [] + } + ] + }, + "references": {} + } ) }) @@ -2173,17 +4352,35 @@ describe("fromJsonSchemaDocument", () => { items: { type: "string" } }, { - representation: { - _tag: "Arrays", - elements: [ - { isOptional: false, type: { _tag: "String", checks: [] } }, - { isOptional: false, type: { _tag: "Literal", literal: "tail" } } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + }, + { + "isOptional": false, + "type": { + "_tag": "Literal", + "checks": [], + "literal": "tail" + } + } ], - rest: [{ _tag: "String", checks: [] }], - checks: [] - } + "rest": [ + { + "_tag": "String", + "checks": [] + } + ] + }, + "references": {} }, - `Schema.TupleWithRest(Schema.Tuple([Schema.String, Schema.Literal("tail")]), [Schema.String])`, [["head", "tail"], ["head", "tail", "more"]], [["head"], ["head", "other"], ["head", "tail", 1]] ) @@ -2204,14 +4401,22 @@ describe("fromJsonSchemaDocument", () => { maxItems: 2 }, { - representation: { - _tag: "Arrays", - elements: [{ isOptional: false, type: { _tag: "String", checks: [] } }], - rest: [], - checks: [] - } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + } + ], + "rest": [] + }, + "references": {} }, - `Schema.Tuple([Schema.String])`, [["head"]], [[], ["head", 1]] ) @@ -2231,8 +4436,13 @@ describe("fromJsonSchemaDocument", () => { minItems: 2, maxItems: 2 }, - { representation: { _tag: "Never" } }, - `Schema.Never`, + { + "representation": { + "_tag": "Never", + "checks": [] + }, + "references": {} + }, [], [[], ["head"], ["head", 1]] ) @@ -2253,14 +4463,22 @@ describe("fromJsonSchemaDocument", () => { maxItems: 1 }, { - representation: { - _tag: "Arrays", - elements: [{ isOptional: false, type: { _tag: "String", checks: [] } }], - rest: [], - checks: [] - } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + } + ], + "rest": [] + }, + "references": {} }, - `Schema.Tuple([Schema.String])`, [["head"]], [[], ["head", 1]] ) @@ -2281,14 +4499,22 @@ describe("fromJsonSchemaDocument", () => { items: { type: "number" } }, { - representation: { - _tag: "Arrays", - elements: [{ isOptional: false, type: { _tag: "String", checks: [] } }], - rest: [], - checks: [] - } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [] + } + } + ], + "rest": [] + }, + "references": {} }, - `Schema.Tuple([Schema.String])`, [["head"]], [[], ["head", "tail"], ["head", 1]] ) @@ -2307,8 +4533,13 @@ describe("fromJsonSchemaDocument", () => { minItems: 1, maxItems: 1 }, - { representation: { _tag: "Never" } }, - `Schema.Never`, + { + "representation": { + "_tag": "Never", + "checks": [] + }, + "references": {} + }, [], [[], [0]] ) @@ -2326,14 +4557,14 @@ describe("fromJsonSchemaDocument", () => { maxItems: 1 }, { - representation: { - _tag: "Arrays", - elements: [], - rest: [], - checks: [] - } + "representation": { + "_tag": "Arrays", + "checks": [], + "elements": [], + "rest": [] + }, + "references": {} }, - `Schema.Tuple([])`, [[]], [[0]] ) @@ -2353,20 +4584,259 @@ describe("fromJsonSchemaDocument", () => { maxItems: 1 }, { - representation: { - _tag: "Arrays", - elements: [{ isOptional: false, type: { _tag: "Literal", literal: 2 } }], - rest: [], - checks: [{ _tag: "Filter", meta: { _tag: "isMinLength", minLength: 1 } }] - } + "representation": { + "_tag": "Arrays", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ], + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "Literal", + "checks": [], + "literal": 2 + } + } + ], + "rest": [] + }, + "references": {} }, - `Schema.Tuple([Schema.Literal(2)]).check(Schema.isMinLength(1))`, [[2]], [[], [0]] ) }) }) + it("short-circuits Never and handles primitive intersections", () => { + for ( + const schema of [ + { allOf: [false, { type: "string" }] }, + { allOf: [{ type: "array" }, { type: "string" }] }, + { allOf: [{ type: "object" }, { type: "string" }] }, + { allOf: [{ type: "null" }, { type: "string" }] }, + { allOf: [{ const: 1 }, { const: 2 }] } + ] + ) { + assertFromJsonSchema({ schema }, { + "representation": { + "_tag": "Never", + "checks": [] + }, + "references": {} + }) + } + + assertFromJsonSchema( + { schema: { allOf: [{ type: "null" }, { type: "null" }] } }, + { + "representation": { + "_tag": "Null", + "checks": [] + }, + "references": {} + } + ) + assertFromJsonSchema( + { schema: { allOf: [{ const: 1 }, { const: 1 }] } }, + { + "representation": { + "_tag": "Literal", + "checks": [], + "literal": 1 + }, + "references": {} + } + ) + for ( + const allOf of [ + [{ type: "boolean" }, { const: true }], + [{ const: true }, { type: "boolean" }] + ] + ) { + assertFromJsonSchema( + { schema: { allOf } }, + { + "representation": { + "_tag": "Literal", + "checks": [], + "literal": true + }, + "references": {} + } + ) + } + }) + + it("combines references and annotated stable wrappers on either side", () => { + const definition: JsonSchema.JsonSchema = { type: "string", minLength: 1 } + assertFromJsonSchema( + { + schema: { + type: "string", + allOf: [{ $ref: "#/$defs/A" }], + $defs: { A: definition } + } + }, + { + "representation": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + }, + "annotations": { + "expected": "a value with a length of at least 1", + "~structural": true, + "arbitrary": { + "constraint": { + "minLength": 1 + } + } + }, + "aborted": false + } + ] + }, + "references": {} + } + ) + + for ( + const schema of [ + { + type: "string", + allOf: [{ $ref: "#/$defs/A", description: "annotated" }], + $defs: { A: definition } + }, + { + $ref: "#/$defs/A", + description: "annotated", + allOf: [{ type: "string" }], + $defs: { A: definition } + } + ] + ) { + const document = fromJsonSchemaRepresentation(JsonSchema.fromSchemaDraft2020_12(schema)) + strictEqual(document.representation._tag, "String") + if (document.representation._tag === "String") { + strictEqual(document.representation.annotations?.description, "annotated") + } + } + + const aliases = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + type: "string", + allOf: [{ $ref: "#/$defs/A" }], + $defs: { + A: { $ref: "#/$defs/B", description: "alias" }, + B: { type: "string" } + } + }) + ) + strictEqual(aliases.representation._tag, "String") + if (aliases.representation._tag === "String") { + strictEqual(aliases.representation.annotations?.description, "alias") + } + }) + + it("merges string annotations, overlapping properties and index signatures", () => { + const string = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [ + { type: "string", contentMediaType: "application/json" }, + { type: "string", contentSchema: { type: "number" } } + ] + }) + ) + strictEqual(string.representation._tag, "String") + if (string.representation._tag === "String") { + deepStrictEqual(string.representation.annotations, { + contentMediaType: "application/json", + contentSchema: { type: "number" } + }) + } + + const object = toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + properties: { a: { type: "string" } }, + required: ["a"], + allOf: [{ + type: "object", + additionalProperties: false, + properties: { a: { type: "string", minLength: 2 } }, + required: ["a"] + }] + }) + ) + const isObject = Schema.is(object) + assertTrue(isObject({ a: "ab" })) + assertFalse(isObject({ a: "a" })) + + const optionalObject = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + properties: { a: { type: "string" } }, + allOf: [{ + type: "object", + additionalProperties: false, + properties: { a: { minLength: 1 } } + }] + }) + ) + strictEqual(optionalObject.representation._tag, "Objects") + if (optionalObject.representation._tag === "Objects") { + strictEqual(optionalObject.representation.propertySignatures[0].isOptional, true) + } + + const indexes = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + patternProperties: { "^a": { type: "string" } }, + allOf: [ + { type: "object", additionalProperties: true }, + { + type: "object", + additionalProperties: false, + patternProperties: { "^b": { type: "number" } } + } + ] + }) + ) + strictEqual(indexes.representation._tag, "Objects") + if (indexes.representation._tag === "Objects") { + strictEqual(indexes.representation.indexSignatures.length, 3) + } + }) + describe("type: object", () => { it("add properties", () => { assertFromJsonSchema( @@ -2380,21 +4850,24 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [ + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [ { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: true, - isMutable: false + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": true, + "isMutable": false } ], - indexSignatures: [], - checks: [] - } - }, - `Schema.Struct({ "a": Schema.optionalKey(Schema.String) })` + "indexSignatures": [] + }, + "references": {} + } ) }) @@ -2409,16 +4882,25 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [], - indexSignatures: [ - { parameter: { _tag: "String", checks: [] }, type: { _tag: "Boolean" } } - ], - checks: [] - } - }, - `Schema.Record(Schema.String, Schema.Boolean)` + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Boolean", + "checks": [] + } + } + ] + }, + "references": {} + } ) }) }) @@ -2446,21 +4928,24 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: { - _tag: "Objects", - propertySignatures: [ + "representation": { + "_tag": "Objects", + "checks": [], + "propertySignatures": [ { - name: "a", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false + "name": "a", + "type": { + "_tag": "String", + "checks": [] + }, + "isOptional": false, + "isMutable": false } ], - indexSignatures: [], - checks: [] - } - }, - `Schema.Struct({ "a": Schema.String })` + "indexSignatures": [] + }, + "references": {} + } ) }) @@ -2481,12 +4966,22 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: json({ - title: "a", - description: "b" - }) - }, - `Schema.Json.annotate({ "title": "a", "description": "b" })` + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value", + "title": "a", + "description": "b" + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) }) @@ -2510,12 +5005,22 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: json({ - title: "a", - default: "c" - }) - }, - `Schema.Json.annotate({ "title": "a", "default": "c" })` + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value", + "title": "a", + "default": "c" + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) }) @@ -2530,14 +5035,26 @@ describe("fromJsonSchemaDocument", () => { } }, { - representation: json({ - title: "a", - description: "b", - default: "c", - examples: ["d"] - }) - }, - `Schema.Json.annotate({ "title": "a", "description": "b", "default": "c", "examples": ["d"] })` + "representation": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + }, + "annotations": { + "expected": "JSON value", + "title": "a", + "description": "b", + "default": "c", + "examples": [ + "d" + ] + }, + "typeParameters": [], + "checks": [] + }, + "references": {} + } ) }) }) diff --git a/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts index 9ce54f2563a..eb1e2f79d3b 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts @@ -1,10 +1,63 @@ +import { assert } from "@effect/vitest" import { SchemaRepresentation } from "effect" import { describe, it } from "vitest" import { deepStrictEqual, throws } from "../../utils/assert.ts" -describe("fromJsonSchemaMultiDocument", () => { +describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { + it("preserves an onEnter exception by identity", () => { + const cause = new Error("boom") + + throws( + () => + SchemaRepresentation.fromJsonSchemaMultiDocument({ + dialect: "draft-2020-12", + schemas: [{ type: "string" }], + definitions: {} + }, { + onEnter: () => { + throw cause + } + }), + (error: unknown) => { + assert.strictEqual(error, cause) + return undefined + } + ) + }) + + it("preserves contentSchema as an annotation without traversing it", () => { + const document = SchemaRepresentation.fromSchemaMultiDocument( + SchemaRepresentation.fromJsonSchemaMultiDocument({ + dialect: "draft-2020-12", + schemas: [{ + type: "string", + contentMediaType: "application/json", + contentSchema: { $ref: "#/$defs/Payload" } + }], + definitions: { + Payload: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + additionalProperties: false + } + } + }) + ) + + const content = document.representations[0] + assert.strictEqual(content._tag, "String") + assert.deepStrictEqual(Object.keys(document.references), ["Payload"]) + if (content._tag === "String") { + assert.deepStrictEqual(content.annotations, { + contentMediaType: "application/json", + contentSchema: { $ref: "#/$defs/Payload" } + }) + } + }) + it("preserves root order and shares definitions", () => { - const document = SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ dialect: "draft-2020-12", schemas: [ { $ref: "#/$defs/A" }, @@ -15,30 +68,53 @@ describe("fromJsonSchemaMultiDocument", () => { definitions: { A: { type: "string", minLength: 1 } } - }) + })) - const definition = { - _tag: "String" as const, - checks: [{ _tag: "Filter" as const, meta: { _tag: "isMinLength" as const, minLength: 1 } }] - } - deepStrictEqual(document, { + deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [ { _tag: "Reference", $ref: "A" }, - { ...definition, annotations: { description: "second" } }, + { + _tag: "Suspend", + checks: [], + annotations: { description: "second" }, + thunk: { _tag: "Reference", $ref: "A" } + }, { _tag: "Arrays", elements: [], rest: [{ _tag: "Reference", $ref: "A" }], checks: [] }, - { ...definition, annotations: { description: "fourth" } } + { + _tag: "Suspend", + checks: [], + annotations: { description: "fourth" }, + thunk: { _tag: "Reference", $ref: "A" } + } ], - references: { A: definition } + references: { + A: { + _tag: "String", + checks: [{ + _tag: "Filter", + representation: { + id: "effect/schema/isMinLength", + payload: { minLength: 1 } + }, + annotations: { + expected: "a value with a length of at least 1", + "~structural": true, + arbitrary: { constraint: { minLength: 1 } } + }, + aborted: false + }] + } + } }) }) it("resolves alias chains when combining a reference", () => { - const document = SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ dialect: "draft-2020-12", schemas: [{ $ref: "#/$defs/A", description: "root" }], definitions: { @@ -46,43 +122,62 @@ describe("fromJsonSchemaMultiDocument", () => { B: { $ref: "#/$defs/C" }, C: { type: "number" } } - }) + })) - deepStrictEqual(document, { + deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [{ - _tag: "Number", - checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }], - annotations: { description: "root" } + _tag: "Suspend", + checks: [], + annotations: { description: "root" }, + thunk: { _tag: "Reference", $ref: "A" } }], references: { A: { _tag: "Reference", $ref: "B" }, B: { _tag: "Reference", $ref: "C" }, C: { _tag: "Number", - checks: [{ _tag: "Filter", meta: { _tag: "isFinite" } }] + checks: [{ + _tag: "Filter", + representation: { id: "effect/schema/isFinite", payload: null }, + annotations: { + expected: "a finite number", + arbitrary: { constraint: { noInfinity: true, noNaN: true } } + }, + aborted: false + }] } } }) }) it("tracks recursive definitions independently", () => { - const document = SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ dialect: "draft-2020-12", schemas: [{ $ref: "#/$defs/A" }, { $ref: "#/$defs/B" }], definitions: { A: { $ref: "#/$defs/A" }, B: { $ref: "#/$defs/B" } } - }) + })) - deepStrictEqual(document, { + deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [ { _tag: "Reference", $ref: "A" }, { _tag: "Reference", $ref: "B" } ], references: { - A: { _tag: "Suspend", thunk: { _tag: "Reference", $ref: "A" }, checks: [] }, - B: { _tag: "Suspend", thunk: { _tag: "Reference", $ref: "B" }, checks: [] } + A: { + _tag: "Suspend", + annotations: { identifier: "A" }, + checks: [], + thunk: { _tag: "Reference", $ref: "A" } + }, + B: { + _tag: "Suspend", + annotations: { identifier: "B" }, + checks: [], + thunk: { _tag: "Reference", $ref: "B" } + } } }) }) @@ -95,7 +190,7 @@ describe("fromJsonSchemaMultiDocument", () => { schemas: [{ $ref: "#/$defs/Missing", description: "resolve" }], definitions: {} }), - "Reference Missing not found" + "Invalid reference Missing\n at [\"schemas\"][0][\"$ref\"]" ) }) @@ -110,7 +205,7 @@ describe("fromJsonSchemaMultiDocument", () => { B: { $ref: "#/$defs/A" } } }), - "Circular reference detected: A" + "Invalid reference A\n at [\"schemas\"][0][\"$ref\"]" ) }) }) diff --git a/packages/effect/test/schema/representation/fromRepresentation.test.ts b/packages/effect/test/schema/representation/fromRepresentation.test.ts new file mode 100644 index 00000000000..76c44f3d4be --- /dev/null +++ b/packages/effect/test/schema/representation/fromRepresentation.test.ts @@ -0,0 +1,406 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +const filterId = "acme/schema/minLength" + +const minLengthReviver: SchemaRepresentation.FilterReviver<{ readonly minimum: number }> = { + id: filterId, + payloadSchema: Schema.Struct({ minimum: Schema.Number }), + revive: ({ annotations, payload }) => minLengthCheck(payload.minimum, annotations) +} + +function minLengthCheck(minimum: number, annotations?: Schema.Annotations.Filter) { + return Schema.makeFilter((value) => value.length >= minimum, { + representation: { id: filterId, payload: { minimum } }, + ...annotations + }) +} + +function revive( + schema: Schema.Top, + revivers: ReadonlyArray = [] +): Schema.Top { + return SchemaRepresentation.fromRepresentation( + SchemaRepresentation.fromJson(SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast))), + { revivers } + ) +} + +function assertRepresentationRoundtrip( + schema: Schema.Top, + revivers: ReadonlyArray = [] +): Schema.Top { + const expected = SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)) + const revived = SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(expected), { revivers }) + assert.deepStrictEqual(SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(revived.ast)), expected) + return revived +} + +function errorFrom(run: () => unknown): Error { + let result: Error | undefined + throws(run, (error: unknown) => { + assert.instanceOf(error, Error) + result = error + return undefined + }) + assert.isDefined(result) + return result +} + +function filterJson(): Schema.Json { + return SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation(Schema.String.check(minLengthCheck(2)).ast) + ) +} + +describe("SchemaRepresentation.fromRepresentation", () => { + it("revives Null", () => { + assertRepresentationRoundtrip(Schema.Null) + }) + + it("revives Undefined", () => { + assertRepresentationRoundtrip(Schema.Undefined) + }) + + it("revives Void", () => { + assertRepresentationRoundtrip(Schema.Void) + }) + + it("revives Never", () => { + assertRepresentationRoundtrip(Schema.Never) + }) + + it("revives Unknown", () => { + assertRepresentationRoundtrip(Schema.Unknown) + }) + + it("revives Any", () => { + assertRepresentationRoundtrip(Schema.Any) + }) + + it("revives String", () => { + assertRepresentationRoundtrip(Schema.String) + }) + + it("revives Number", () => { + assertRepresentationRoundtrip(Schema.Number) + }) + + it("revives Boolean", () => { + assertRepresentationRoundtrip(Schema.Boolean) + }) + + it("revives BigInt", () => { + assertRepresentationRoundtrip(Schema.BigInt) + }) + + it("revives Symbol", () => { + assertRepresentationRoundtrip(Schema.Symbol) + }) + + it("revives ObjectKeyword", () => { + assertRepresentationRoundtrip(Schema.ObjectKeyword) + }) + + it("revives Literal", () => { + const schema = assertRepresentationRoundtrip(Schema.Literal("value")) + assert.isTrue(Schema.is(schema)("value")) + assert.isFalse(Schema.is(schema)("other")) + }) + + it("revives UniqueSymbol", () => { + const symbol = Symbol.for("acme/schema/symbol") + const schema = assertRepresentationRoundtrip(Schema.UniqueSymbol(symbol)) + assert.isTrue(Schema.is(schema)(symbol)) + assert.isFalse(Schema.is(schema)(Symbol.for("acme/schema/other"))) + }) + + it("revives Enum", () => { + const schema = assertRepresentationRoundtrip(Schema.Enum({ A: "a", One: 1 })) + assert.isTrue(Schema.is(schema)("a")) + assert.isTrue(Schema.is(schema)(1)) + assert.isFalse(Schema.is(schema)("other")) + }) + + it("revives TemplateLiteral", () => { + const schema = assertRepresentationRoundtrip(Schema.TemplateLiteral(["prefix-", Schema.String])) + assert.isTrue(Schema.is(schema)("prefix-value")) + assert.isFalse(Schema.is(schema)("value")) + }) + + it("revives Tuple", () => { + assertRepresentationRoundtrip(Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)])) + }) + + it("revives Array", () => { + assertRepresentationRoundtrip(Schema.Array(Schema.String)) + }) + + it("revives TupleWithRest", () => { + assertRepresentationRoundtrip( + Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) + ) + }) + + it("revives Struct", () => { + assertRepresentationRoundtrip(Schema.Struct({ + required: Schema.String, + optional: Schema.optionalKey(Schema.Number), + mutable: Schema.mutableKey(Schema.Boolean) + })) + }) + + it("revives Record", () => { + assertRepresentationRoundtrip(Schema.Record(Schema.String, Schema.Number)) + }) + + it("revives StructWithRest", () => { + assertRepresentationRoundtrip( + Schema.StructWithRest(Schema.Struct({ value: Schema.Number }), [Schema.Record(Schema.Symbol, Schema.String)]) + ) + }) + + it("revives Union", () => { + const schema = assertRepresentationRoundtrip(Schema.Union([Schema.String, Schema.Number])) + assert.isTrue(Schema.is(schema)("value")) + assert.isTrue(Schema.is(schema)(1)) + assert.isFalse(Schema.is(schema)(true)) + }) + + it("revives an empty Union as Never", () => { + const schema = SchemaRepresentation.fromRepresentation({ + representation: { _tag: "Union", types: [], mode: "anyOf", checks: [] }, + references: {} + }, { revivers: [] }) + assert.isFalse(Schema.is(schema)(undefined)) + assert.isFalse(Schema.is(schema)(null)) + }) + + it("revives Suspend", () => { + interface Category { + readonly name: string + readonly children: ReadonlyArray + } + const Category: Schema.Codec = Schema.Struct({ + name: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => Category)) + }).annotate({ identifier: "Category" }) + const schema = revive(Category) as Schema.Codec + assert.deepStrictEqual( + Schema.decodeUnknownSync(schema)({ + name: "root", + children: [{ name: "child", children: [] }] + }), + { + name: "root", + children: [{ name: "child", children: [] }] + } + ) + assert.strictEqual(SchemaRepresentation.toRepresentation(schema.ast).representation._tag, "Reference") + }) + + it("restores node annotations", () => { + const schema = revive(Schema.String.annotate({ title: "Name" })) + assert.strictEqual(schema.ast.annotations?.title, "Name") + }) + + it("restores tuple element annotations", () => { + const schema = revive(Schema.Tuple([Schema.String.annotateKey({ description: "element" })])) + const representation = SchemaRepresentation.toRepresentation(schema.ast).representation + assert.strictEqual(representation._tag, "Arrays") + if (representation._tag === "Arrays") { + assert.strictEqual(representation.elements[0].annotations?.description, "element") + } + }) + + it("restores property annotations", () => { + const schema = revive(Schema.Struct({ value: Schema.String.annotateKey({ description: "property" }) })) + const representation = SchemaRepresentation.toRepresentation(schema.ast).representation + assert.strictEqual(representation._tag, "Objects") + if (representation._tag === "Objects") { + assert.strictEqual(representation.propertySignatures[0].annotations?.description, "property") + } + }) + + it("restores brands", () => { + assertRepresentationRoundtrip(Schema.String.pipe(Schema.brand("A"), Schema.brand("B"))) + }) + + it("restores a node representation annotation without schema dependencies", () => { + assertRepresentationRoundtrip(Schema.String.annotate({ + representation: { id: "acme/schema/String", payload: null } + })) + }) + + it("restores a node representation annotation with schema dependencies", () => { + assertRepresentationRoundtrip(Schema.String.annotate({ + representation: { + id: "acme/schema/String", + payload: null, + schemas: [Schema.Number.ast] + } + })) + }) + + it("revives a Filter", () => { + const schema = assertRepresentationRoundtrip( + Schema.String.check(minLengthCheck(2, { description: "at least two" }).abort()), + [minLengthReviver] + ) + assert.strictEqual(Schema.decodeUnknownResult(schema as Schema.Codec)("a")._tag, "Failure") + assert.strictEqual(schema.ast.checks?.[0]._tag, "Filter") + assert.isTrue(schema.ast.checks?.[0]._tag === "Filter" && schema.ast.checks[0].aborted) + assert.strictEqual(schema.ast.checks?.[0].annotations?.description, "at least two") + }) + + it("revives a FilterGroup without an identity from its children", () => { + const group = Schema.makeFilterGroup([minLengthCheck(2), minLengthCheck(3)], { description: "both" }) + const schema = assertRepresentationRoundtrip(Schema.String.check(group), [minLengthReviver]) + assert.strictEqual(Schema.decodeUnknownResult(schema as Schema.Codec)("ab")._tag, "Failure") + assert.strictEqual(schema.ast.checks?.[0].annotations?.description, "both") + }) + + it("uses an identified FilterGroup reviver instead of its persisted children", () => { + const groupId = "acme/schema/group" + const document = SchemaRepresentation.fromJson({ + representation: { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + representation: { id: groupId, payload: null }, + checks: [{ + _tag: "Filter", + representation: { id: filterId, payload: { minimum: 1 } }, + aborted: false + }] + }] + }, + references: {} + }) + const reviver: SchemaRepresentation.FilterGroupReviver = { + id: groupId, + payloadSchema: Schema.Null, + revive: () => Schema.makeFilterGroup([Schema.makeFilter((value) => value !== "blocked")]) + } + const schema = SchemaRepresentation.fromRepresentation(document, { revivers: [reviver] }) as Schema.Codec + assert.strictEqual(Schema.decodeUnknownSync(schema)("allowed"), "allowed") + assert.strictEqual(Schema.decodeUnknownResult(schema)("blocked")._tag, "Failure") + }) + + it("revives a Declaration", () => { + const id = "acme/schema/Box" + const Box = Schema.declare<{ readonly value: string }>( + (input): input is { readonly value: string } => + typeof input === "object" && input !== null && typeof (input as any).value === "string", + { representation: { id, payload: { label: "Box" } } } + ) + const reviver: SchemaRepresentation.DeclarationReviver<{ readonly label: string }> = { + id, + payloadSchema: Schema.Struct({ label: Schema.String }), + revive: ({ annotations, payload }) => + Schema.declare<{ readonly value: string }>( + (input): input is { readonly value: string } => + typeof input === "object" && input !== null && typeof (input as any).value === "string", + { ...annotations, representation: { id, payload } } + ) + } + const schema = assertRepresentationRoundtrip(Box, [reviver]) as Schema.Codec + assert.deepStrictEqual(Schema.decodeUnknownSync(schema)({ value: "ok" }), { value: "ok" }) + }) + + it("reports a missing reviver", () => { + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(filterJson()), { revivers: [] }) + ) + .message, + `Missing reviver for ${filterId}\n at ["representation"]["checks"][0]["representation"]` + ) + }) + + it("rejects duplicate reviver IDs", () => { + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation({ + representation: { _tag: "String", checks: [] }, + references: {} + }, { revivers: [minLengthReviver, minLengthReviver] }) + ).message, + `Duplicate reviver for ${filterId}\n at ["revivers"][1]["id"]` + ) + }) + + it("rejects an invalid reviver payload", () => { + const json = filterJson() as any + json.representation.checks[0].representation.payload = { minimum: "two" } + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), { + revivers: [minLengthReviver] + }) + ).message, + `Invalid representation payload for ${filterId}\n at ["representation"]["checks"][0]["representation"]["payload"]` + ) + }) + + it("preserves a reviver exception by identity", () => { + const cause = new Error("boom") + const reviver = { + ...minLengthReviver, + revive: () => { + throw cause + } + } + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(filterJson()), { + revivers: [reviver] + }) + ), + cause + ) + }) + + it("requires a representation identity on a Filter", () => { + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation( + { + representation: { _tag: "String", checks: [{ _tag: "Filter", aborted: false }] }, + references: {} + }, + { revivers: [] } + ) + ).message, + `Missing representation annotation\n at ["representation"]["checks"][0]["representation"]` + ) + }) + + it("requires a representation identity on a Declaration", () => { + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation( + { + representation: { _tag: "Declaration", typeParameters: [], checks: [] }, + references: {} + }, + { revivers: [] } + ) + ).message, + `Missing representation annotation\n at ["representation"]["representation"]` + ) + }) + + it("reports an invalid reference", () => { + assert.strictEqual( + errorFrom(() => + SchemaRepresentation.fromRepresentation({ + representation: { _tag: "Reference", $ref: "Missing" }, + references: {} + }, { revivers: [] }) + ).message, + `Invalid reference Missing\n at ["representation"]["$ref"]` + ) + }) +}) diff --git a/packages/effect/test/schema/representation/fromRepresentations.test.ts b/packages/effect/test/schema/representation/fromRepresentations.test.ts new file mode 100644 index 00000000000..1968c66ec70 --- /dev/null +++ b/packages/effect/test/schema/representation/fromRepresentations.test.ts @@ -0,0 +1,90 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" + +function decode(schema: Schema.Top, input: unknown): unknown { + return Schema.decodeUnknownSync(schema as Schema.Codec)(input) +} + +describe("SchemaRepresentation.fromRepresentations", () => { + it("preserves root order", () => { + const document = SchemaRepresentation.fromRepresentations({ + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Boolean", checks: [] }, + { _tag: "Number", checks: [] } + ], + references: {} + }, { revivers: [] }) + + assert.strictEqual(decode(document.schemas[0], "value"), "value") + assert.strictEqual(decode(document.schemas[1], true), true) + assert.strictEqual(decode(document.schemas[2], 1), 1) + }) + + it("revives unreachable definitions", () => { + const document = SchemaRepresentation.fromRepresentations({ + representations: [{ _tag: "String", checks: [] }], + references: { Unused: { _tag: "Number", checks: [] } } + }, { revivers: [] }) + + assert.deepStrictEqual(Object.keys(document.definitions), ["Unused"]) + assert.strictEqual(decode(document.definitions.Unused, 1), 1) + }) + + it("preserves aliases as distinct reference wrappers", () => { + const document = SchemaRepresentation.fromRepresentations({ + representations: [{ _tag: "Reference", $ref: "Alias" }], + references: { + Value: { _tag: "String", checks: [] }, + Alias: { _tag: "Reference", $ref: "Value" } + } + }, { revivers: [] }) + + assert.notStrictEqual(document.definitions.Value, document.definitions.Alias) + assert.strictEqual(document.definitions.Value.ast._tag, "Suspend") + assert.strictEqual(document.definitions.Alias.ast._tag, "Suspend") + assert.strictEqual(decode(document.schemas[0], "value"), "value") + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(document.schemas[0].ast).representation, { + _tag: "Reference", + $ref: "Alias" + }) + }) + + it("revives recursive definitions", () => { + const document = SchemaRepresentation.fromRepresentations({ + representations: [{ _tag: "Reference", $ref: "Recursive" }], + references: { + Recursive: { + _tag: "Objects", + checks: [], + propertySignatures: [ + { + name: "value", + type: { _tag: "Number", checks: [] }, + isOptional: false, + isMutable: false + }, + { + name: "next", + type: { _tag: "Reference", $ref: "Recursive" }, + isOptional: true, + isMutable: false + } + ], + indexSignatures: [] + } + } + }, { revivers: [] }) + + assert.deepStrictEqual( + decode(document.schemas[0], { + value: 1, + next: { value: 2 } + }), + { + value: 1, + next: { value: 2 } + } + ) + }) +}) diff --git a/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts new file mode 100644 index 00000000000..042dd389e49 --- /dev/null +++ b/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts @@ -0,0 +1,36 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.fromSchemaMultiDocument", () => { + it("preserves root order and lowers direct definitions", () => { + assert.deepStrictEqual( + SchemaRepresentation.fromSchemaMultiDocument({ + schemas: [Schema.String, Schema.Boolean], + definitions: { Value: Schema.Number } + }), + { + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Boolean", checks: [] } + ], + references: { Value: { _tag: "Number", checks: [] } } + } + ) + }) + + it("keeps distinct keys for definitions sharing the same schema", () => { + assert.deepStrictEqual( + SchemaRepresentation.fromSchemaMultiDocument({ + schemas: [Schema.String], + definitions: { A: Schema.Number, B: Schema.Number } + }), + { + representations: [{ _tag: "String", checks: [] }], + references: { + A: { _tag: "Reference", $ref: "B" }, + B: { _tag: "Number", checks: [] } + } + } + ) + }) +}) diff --git a/packages/effect/test/schema/representation/makeCode.test.ts b/packages/effect/test/schema/representation/makeCode.test.ts new file mode 100644 index 00000000000..7e030a6b7ca --- /dev/null +++ b/packages/effect/test/schema/representation/makeCode.test.ts @@ -0,0 +1,11 @@ +import { assert, describe, it } from "@effect/vitest" +import { SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.makeCode", () => { + it("constructs runtime and type source", () => { + assert.deepStrictEqual(SchemaRepresentation.makeCode("Schema.String", "string"), { + runtime: "Schema.String", + Type: "string" + }) + }) +}) diff --git a/packages/effect/test/schema/representation/schemaJsonSchemaConsumer.test.ts b/packages/effect/test/schema/representation/schemaJsonSchemaConsumer.test.ts new file mode 100644 index 00000000000..57330535761 --- /dev/null +++ b/packages/effect/test/schema/representation/schemaJsonSchemaConsumer.test.ts @@ -0,0 +1,115 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" + +describe("Schema JSON Schema consumer", () => { + it("keeps toRepresentation on the type side and projects the encoded side for JSON Schema", () => { + const representation = Schema.toRepresentation(Schema.FiniteFromString) + assert.strictEqual(representation.representation._tag, "Number") + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(Schema.FiniteFromString), { + dialect: "draft-2020-12", + schema: { type: "string" }, + definitions: {} + }) + }) + + it("projects encoded tuple elements for JSON Schema", () => { + assert.deepStrictEqual(Schema.toJsonSchemaDocument(Schema.Tuple([Schema.NumberFromString])).schema, { + type: "array", + prefixItems: [{ type: "string" }], + minItems: 1, + maxItems: 1 + }) + }) + + it("preserves output, references and generation options", () => { + const shared = Schema.String.check(Schema.isMinLength(2)).annotate({ + identifier: "Shared", + description: "shared text", + "x-consumer": "kept" + }) + const schema = Schema.Struct({ + first: shared, + second: shared, + count: Schema.FiniteFromString + }).annotate({ description: "root" }) + const options: Schema.ToJsonSchemaOptions = { + additionalProperties: true, + generateDescriptions: true, + includeAnnotationKey: (key) => key === "x-consumer" + } + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(schema, options), { + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + first: { $ref: "#/$defs/Shared" }, + second: { $ref: "#/$defs/Shared" }, + count: { + type: "string", + description: "a string that will be decoded as a finite number" + } + }, + required: ["first", "second", "count"], + additionalProperties: true, + description: "root" + }, + definitions: { + Shared: { + type: "string", + allOf: [{ + minLength: 2, + description: "shared text", + "x-consumer": "kept" + }] + } + } + }) + }) + + it("uses custom compiler annotations without a central built-in switch", () => { + const custom = Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/schema/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(custom), { + dialect: "draft-2020-12", + schema: { + type: "string", + allOf: [{ minLength: 2 }] + }, + definitions: {} + }) + }) + + it("emits JSON content media types after encoded projection", () => { + const schema = Schema.fromJsonString(Schema.Struct({ + value: Schema.FiniteFromString + })) + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(schema).schema, { + type: "string", + contentMediaType: "application/json" + }) + }) + + it("approximates declarations without a JSON codec", () => { + const schema = Schema.declare((input): input is string => typeof input === "string", { + representation: { + id: "test/schema/opaqueString", + payload: null + } + }) + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(schema), { + dialect: "draft-2020-12", + schema: {}, + definitions: {} + }) + }) +}) diff --git a/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts b/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts new file mode 100644 index 00000000000..18d064933c4 --- /dev/null +++ b/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts @@ -0,0 +1,600 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" +import { assertInclude, throws } from "../../utils/assert.ts" + +function expectError(thunk: () => void, expected: string | Error): void { + if (typeof expected === "string") { + throws(thunk, expected) + } else { + throws(thunk, (error: unknown) => { + assert.strictEqual(error, expected) + return undefined + }) + } +} + +const StringRepresentation: SchemaRepresentation.Representation = { + _tag: "String", + checks: [] +} + +const NumberRepresentation: SchemaRepresentation.Representation = { + _tag: "Number", + checks: [] +} + +const EmptyUnionRepresentation: SchemaRepresentation.Representation = { + _tag: "Union", + types: [], + mode: "anyOf", + checks: [] +} + +describe("SchemaRepresentation.toCodeDocument annotations", () => { + it("compiles an empty union as Never", () => { + assert.deepStrictEqual( + SchemaRepresentation.toCodeDocument({ + representations: [EmptyUnionRepresentation], + references: {} + }).codes, + [{ runtime: "Schema.Never", Type: "never" }] + ) + }) + + it("compiles the isPattern and Option vertical slice", () => { + const document = SchemaRepresentation.toRepresentations([ + Schema.String.check(Schema.isPattern(/^a+$/)).ast, + Schema.Option(Schema.String).ast + ]) + const output = SchemaRepresentation.toCodeDocument(document) + + assert.deepStrictEqual(output.codes, [ + { + runtime: + `Schema.String.check(Schema.isPattern(new RegExp("^a+$")).annotate({ "expected": "a string matching the RegExp ^a+$" }))`, + Type: "string" + }, + { + runtime: `Schema.Option(Schema.String).annotate({ "expected": "Option" })`, + Type: "Option.Option" + } + ]) + assert.deepStrictEqual(output.artifacts, [{ + _tag: "Import", + importDeclaration: `import * as Option from "effect/Option"` + }]) + }) + + it("passes compiled dependencies to checks and deduplicates imports", () => { + const check = (name: string): SchemaRepresentation.Filter => ({ + _tag: "Filter", + aborted: false, + representation: { + id: `acme/schema/${name}`, + payload: null, + schemas: [StringRepresentation] + }, + annotations: { + toCode: ({ schemas }: SchemaRepresentation.Generation.CheckInput) => ({ + runtime: `Custom.${name}(${schemas[0].runtime})`, + importDeclarations: [`import * as Custom from "acme/Custom"`] + }) + } + }) + const document: SchemaRepresentation.MultiDocument = { + representations: [{ _tag: "String", checks: [check("first"), check("second")] }], + references: {} + } + + const output = SchemaRepresentation.toCodeDocument(document) + assert.strictEqual( + output.codes[0].runtime, + "Schema.String.check(Custom.first(Schema.String)).check(Custom.second(Schema.String))" + ) + assert.deepStrictEqual(output.artifacts, [{ + _tag: "Import", + importDeclaration: `import * as Custom from "acme/Custom"` + }]) + }) + + it("emits supported annotation trees atomically", () => { + const document = SchemaRepresentation.toRepresentations([ + Schema.String.annotate({ + emitted: { + bigint: 1n, + symbol: Symbol.for("shared"), + negativeZero: -0, + nan: NaN, + positive: Infinity, + negative: -Infinity + }, + omitted: { value: 1, callback: () => 2 } + }).ast + ]) + + const runtime = SchemaRepresentation.toCodeDocument(document).codes[0].runtime + assertInclude(runtime, `"bigint": 1n`) + assertInclude(runtime, `"symbol": Symbol.for("shared")`) + assertInclude(runtime, `"negativeZero": -0`) + assertInclude(runtime, `"nan": NaN`) + assertInclude(runtime, `"positive": Infinity`) + assertInclude(runtime, `"negative": -Infinity`) + assert.isFalse(runtime.includes("omitted")) + assert.isFalse(runtime.includes("callback")) + }) + + it("preserves fallback identifiers", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [{ + _tag: "String", + checks: [], + annotations: { "~identifier": "Person" } + }], + references: {} + }) + + assertInclude(output.codes[0].runtime, `.annotate({ "~identifier": "Person" })`) + }) + + it("emits tuple element and property annotations", () => { + const document: SchemaRepresentation.MultiDocument = { + representations: [ + { + _tag: "Arrays", + elements: [{ + isOptional: false, + type: StringRepresentation, + annotations: { + element: { value: 1 }, + omitted: { callback: () => 1 } + } + }], + rest: [], + checks: [] + }, + { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: NumberRepresentation, + isOptional: false, + isMutable: false, + annotations: { property: true } + }], + indexSignatures: [], + checks: [] + } + ], + references: {} + } + + assert.deepStrictEqual(SchemaRepresentation.toCodeDocument(document).codes, [ + { + runtime: `Schema.Tuple([Schema.String.annotateKey({ "element": { "value": 1 } })])`, + Type: "readonly [string]" + }, + { + runtime: `Schema.Struct({ "value": Schema.Number.annotateKey({ "property": true }) })`, + Type: `{ readonly "value": number }` + } + ]) + }) + + it("uses group overrides without visiting children and preserves abort", () => { + let visits = 0 + const child: SchemaRepresentation.Filter = { + _tag: "Filter", + aborted: true, + annotations: { + toCode: () => { + visits++ + return { runtime: "Custom.child()" } + } + } + } + const document: SchemaRepresentation.MultiDocument = { + representations: [ + { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + checks: [child], + annotations: { toCode: () => ({ runtime: "Custom.group()" }) } + }] + }, + { + _tag: "String", + checks: [{ _tag: "FilterGroup", checks: [child] }] + } + ], + references: {} + } + + const output = SchemaRepresentation.toCodeDocument(document) + assert.strictEqual(visits, 1) + assert.strictEqual(output.codes[0].runtime, "Schema.String.check(Custom.group())") + assert.strictEqual( + output.codes[1].runtime, + "Schema.String.check(Schema.makeFilterGroup([Custom.child().abort()]))" + ) + }) + + it("passes type parameters to declaration callbacks", () => { + const declaration: SchemaRepresentation.Representation = { + _tag: "Declaration", + typeParameters: [StringRepresentation], + checks: [], + representation: { + id: "acme/schema/Box", + payload: null + }, + annotations: { + toCode: ({ typeParameters }: SchemaRepresentation.Generation.DeclarationInput) => ({ + runtime: `Custom.box(${typeParameters[0].runtime})`, + Type: `Custom.Box<${typeParameters[0].Type}>`, + importDeclarations: [`import * as Custom from "acme/Custom"`] + }) + } + } + const output = SchemaRepresentation.toCodeDocument({ + representations: [declaration], + references: {} + }) + + assert.deepStrictEqual(output.codes, [{ + runtime: "Custom.box(Schema.String)", + Type: "Custom.Box" + }]) + }) + + it("reports missing toCode callbacks and preserves callback exceptions", () => { + const missing: SchemaRepresentation.MultiDocument = { + representations: [{ + _tag: "String", + checks: [{ _tag: "Filter", aborted: false }] + }], + references: {} + } + expectError( + () => SchemaRepresentation.toCodeDocument(missing), + `Missing toCode callback\n at ["representations"][0]["checks"][0]["annotations"]["toCode"]` + ) + + const cause = new Error("toCode callback") + const throwing: SchemaRepresentation.MultiDocument = { + representations: [{ + _tag: "String", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + toCode: () => { + throw cause + } + } + }] + }], + references: {} + } + expectError( + () => SchemaRepresentation.toCodeDocument(throwing), + cause + ) + }) + + it("generates content media types, optional pre-rest elements and numeric properties", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [ + { + _tag: "String", + annotations: { contentMediaType: "text/plain" }, + checks: [] + }, + { + _tag: "Arrays", + elements: [{ + type: StringRepresentation, + isOptional: true + }], + rest: [NumberRepresentation], + checks: [] + }, + { + _tag: "Objects", + propertySignatures: [{ + name: 1, + type: { _tag: "Boolean", checks: [] }, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + } + ], + references: {} + }) + + assert.deepStrictEqual(output.codes, [ + { + runtime: `Schema.String.annotate({ "contentMediaType": "text/plain" })`, + Type: "string" + }, + { + runtime: `Schema.TupleWithRest(Schema.Tuple([Schema.optionalKey(Schema.String)]), [Schema.Number])`, + Type: `readonly [string?, ...Array]` + }, + { + runtime: `Schema.Struct({ 1: Schema.Boolean })`, + Type: `{ readonly 1: boolean }` + } + ]) + assert.deepStrictEqual(output.artifacts, []) + }) + + it("emits references that are not reachable from a root", () => { + const document: SchemaRepresentation.MultiDocument = { + representations: [StringRepresentation], + references: { + Unused: NumberRepresentation + } + } + assert.deepStrictEqual(SchemaRepresentation.toCodeDocument(document).references, { + nonRecursives: [{ + $ref: "Unused", + code: { runtime: "Schema.Number", Type: "number" } + }], + recursives: {} + }) + }) + + it("capitalizes a lowercase reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { abc: NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "Abc") + }) + + it("preserves a lowercase identifier annotation", () => { + const schema = Schema.Struct({ a: Schema.String }).annotate({ identifier: "hello" }) + const output = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.toMultiDocument(Schema.toRepresentation(schema)) + ) + assert.deepStrictEqual(output.references.nonRecursives[0], { + $ref: "Hello", + code: { + runtime: `Schema.Struct({ "a": Schema.String }).annotate({ "identifier": "hello" })`, + Type: `{ readonly "a": string }` + } + }) + }) + + it("preserves an uppercase reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { Abc: NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "Abc") + }) + + it("prefixes a numeric reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { "1a": NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "_1a") + }) + + it("replaces punctuation in a reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { "a-b": NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "A_b") + }) + + it("replaces non-ASCII characters in a reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { café: NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "Caf_") + }) + + it("replaces an emoji reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { "🤖": NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "_") + }) + + it("uses an underscore for an empty reference identifier", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { "": NumberRepresentation } + }) + assert.strictEqual(output.references.nonRecursives[0].$ref, "_") + }) + + it("makes colliding sanitized reference identifiers unique", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { + "a-b": NumberRepresentation, + a_b: StringRepresentation + } + }) + assert.deepStrictEqual( + output.references.nonRecursives.map((reference) => reference.$ref), + ["A_b", "A_b1"] + ) + }) + + it("orders definitions after dependencies found in every representation position", () => { + const reference = ($ref: string): SchemaRepresentation.Reference => ({ _tag: "Reference", $ref }) + const filter: SchemaRepresentation.Filter = { + _tag: "Filter", + aborted: false, + representation: { id: "acme/schema/filter", payload: null, schemas: [reference("C")] }, + annotations: { + toCode: () => ({ runtime: "Schema.makeFilter(() => true)" }) + } + } + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { + A: StringRepresentation, + B: NumberRepresentation, + C: { _tag: "Boolean", checks: [] }, + D: { + _tag: "Declaration", + typeParameters: [reference("A")], + representation: { id: "acme/schema/declaration", payload: null }, + annotations: { + toCode: () => ({ runtime: "Schema.String", Type: "string" }) + }, + checks: [{ _tag: "FilterGroup", checks: [filter] }] + }, + E: { _tag: "TemplateLiteral", parts: [reference("D")], checks: [] }, + F: { _tag: "Union", types: [reference("E"), reference("A")], mode: "anyOf", checks: [] }, + G: { + _tag: "Arrays", + elements: [{ type: reference("F"), isOptional: false }], + rest: [reference("A")], + checks: [] + }, + H: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: reference("G"), + isOptional: false, + isMutable: false + }], + indexSignatures: [{ parameter: reference("A"), type: reference("B") }], + checks: [] + }, + I: { _tag: "Suspend", thunk: reference("H"), checks: [] } + } + }) + + assert.deepStrictEqual( + output.references.nonRecursives.map((entry) => entry.$ref), + ["A", "B", "C", "D", "E", "F", "G", "H", "I"] + ) + }) + + it("orders a shared dependency referenced from multiple representation positions once", () => { + const shared: SchemaRepresentation.Reference = { _tag: "Reference", $ref: "A" } + const output = SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { + A: StringRepresentation, + B: { + _tag: "Arrays", + elements: [{ type: shared, isOptional: false }], + rest: [shared], + checks: [] + }, + C: { + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ parameter: shared, type: shared }], + checks: [] + } + } + }) + + assert.deepStrictEqual( + output.references.nonRecursives.map((entry) => entry.$ref), + ["A", "B", "C"] + ) + }) + + it("generates a StructWithRest without fixed properties", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [{ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [ + { parameter: StringRepresentation, type: NumberRepresentation }, + { parameter: { _tag: "Symbol", checks: [] }, type: { _tag: "Boolean", checks: [] } } + ], + checks: [] + }], + references: {} + }) + + assert.strictEqual(output.codes[0].Type, `{ readonly [x: string]: number, readonly [x: symbol]: boolean }`) + }) + + it("generates a single-literal Union as Literal", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [{ + _tag: "Union", + types: [{ _tag: "Literal", literal: "a", checks: [] }], + mode: "anyOf", + checks: [] + }], + references: {} + }) + + assert.deepStrictEqual(output.codes[0], { runtime: `Schema.Literal("a")`, Type: `"a"` }) + }) + + it("emits every member of a mutually recursive reference cycle", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [{ _tag: "Reference", $ref: "A" }], + references: { + A: { _tag: "Reference", $ref: "B" }, + B: { _tag: "Reference", $ref: "A" } + } + }) + + assert.deepStrictEqual(output.references, { + nonRecursives: [], + recursives: { + A: { runtime: "Schema.suspend((): Schema.Codec => B)", Type: "B" }, + B: { runtime: "Schema.suspend((): Schema.Codec => A)", Type: "A" } + } + }) + }) + + it("emits a non-recursive definition that depends on a recursive definition", () => { + const output = SchemaRepresentation.toCodeDocument({ + representations: [{ _tag: "Reference", $ref: "B" }], + references: { + A: { _tag: "Reference", $ref: "A" }, + B: { _tag: "Reference", $ref: "A" } + } + }) + + assert.deepStrictEqual(output.references, { + nonRecursives: [{ $ref: "B", code: { runtime: "A", Type: "A" } }], + recursives: { A: { runtime: "Schema.suspend((): Schema.Codec => A)", Type: "A" } } + }) + }) + + it("reports missing references with their document path", () => { + const document: SchemaRepresentation.MultiDocument = { + representations: [{ _tag: "Reference", $ref: "Missing" }], + references: {} + } + expectError( + () => SchemaRepresentation.toCodeDocument(document), + `Invalid reference Missing\n at ["representations"][0]["$ref"]` + ) + }) + + it("reports a missing reference from a definition", () => { + expectError( + () => + SchemaRepresentation.toCodeDocument({ + representations: [StringRepresentation], + references: { Value: { _tag: "Reference", $ref: "Missing" } } + }), + `Invalid reference Missing\n at ["references"]["Value"]["$ref"]` + ) + }) +}) diff --git a/packages/effect/test/schema/representation/toCodeDocument.test.ts b/packages/effect/test/schema/representation/toCodeDocument.test.ts index d106152078f..02d0a8429c8 100644 --- a/packages/effect/test/schema/representation/toCodeDocument.test.ts +++ b/packages/effect/test/schema/representation/toCodeDocument.test.ts @@ -1,6 +1,6 @@ import { JsonSchema, Schema, SchemaRepresentation } from "effect" import { describe, it } from "vitest" -import { deepStrictEqual, strictEqual } from "../../utils/assert.ts" +import { deepStrictEqual, throws } from "../../utils/assert.ts" type Category = { readonly name: string @@ -36,56 +36,66 @@ describe("toCodeDocument", () => { function assertSchema(input: { readonly schema: Schema.Constraint - readonly reviver?: SchemaRepresentation.Reviver | undefined }, expected: Expected) { - const multiDocument = SchemaRepresentation.fromASTs([input.schema.ast]) - assertMultiDocument({ multiDocument }, expected) + const multiDocument = SchemaRepresentation.toRepresentations([input.schema.ast]) + assertMultiDocument(multiDocument, expected) } function assertJsonSchema(input: { readonly schema: JsonSchema.JsonSchema - readonly reviver?: SchemaRepresentation.Reviver | undefined }, expected: Expected) { - const multiDocument = SchemaRepresentation.toMultiDocument( - SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(input.schema), { - onEnter: (js) => { - if (js.type === "object" && js.additionalProperties === undefined) { - return { ...js, additionalProperties: false } - } - return js - } + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(input.schema), + { + onEnter: (js) => + js.type === "object" && js.additionalProperties === undefined + ? { ...js, additionalProperties: false } + : js + } + ) + assertMultiDocument(SchemaRepresentation.toRepresentations([schema.ast]), expected) + } + + function assertMultiDocument( + multiDocument: SchemaRepresentation.MultiDocument, + expected: Expected + ) { + const codeDocument = SchemaRepresentation.toCodeDocument(multiDocument) + deepStrictEqual( + canonicalizeGeneratedCode(codeDocument), + canonicalizeGeneratedCode({ + codes: Array.isArray(expected.codes) ? expected.codes : [expected.codes], + references: { + nonRecursives: expected.references?.nonRecursives ?? [], + recursives: expected.references?.recursives ?? {} + }, + artifacts: expected.artifacts ?? [] }) ) - assertMultiDocument({ multiDocument }, expected) } - function assertMultiDocument(input: { - readonly multiDocument: SchemaRepresentation.MultiDocument - readonly reviver?: SchemaRepresentation.Reviver | undefined - }, expected: Expected) { - const codeDocument = SchemaRepresentation.toCodeDocument(input.multiDocument, { reviver: input.reviver }) - deepStrictEqual(codeDocument, { - codes: Array.isArray(expected.codes) ? expected.codes : [expected.codes], - references: { - nonRecursives: expected.references?.nonRecursives ?? [], - recursives: expected.references?.recursives ?? {} - }, - artifacts: expected.artifacts ?? [] - }) + function canonicalizeGeneratedCode(input: unknown): unknown { + if (typeof input === "string") { + return input + .replaceAll(/"expected": "(?:\\.|[^"\\])*"(?:, )?/g, "") + .replaceAll(", }", " }") + .replaceAll(".annotate({ })", "") + } + if (Array.isArray(input)) return input.map(canonicalizeGeneratedCode) + if (typeof input !== "object" || input === null) return input + return Object.fromEntries( + Object.entries(input).map(([key, value]) => [key, canonicalizeGeneratedCode(value)]) + ) } const makeCode = SchemaRepresentation.makeCode - describe("options", () => { - it("reviver can override declaration code and recur into type parameters", () => { - }) - }) - describe("Declaration", () => { - it("declaration without typeConstructor annotation", () => { - assertSchema({ schema: Schema.instanceOf(URL) }, { - codes: makeCode("Schema.Null", "null") - }) + it("declaration without a toCode annotation", () => { + throws( + () => assertSchema({ schema: Schema.instanceOf(URL) }, { codes: makeCode("", "") }), + "Missing toCode callback\n at [\"representations\"][0][\"annotations\"][\"toCode\"]" + ) }) it("Error", () => { @@ -367,7 +377,7 @@ describe("toCodeDocument", () => { assertSchema( { schema: Schema.String.check(Schema.isMinLength(1, { description: "a" })) }, { - codes: makeCode(`Schema.String.check(Schema.isMinLength(1, { "description": "a" }))`, "string") + codes: makeCode(`Schema.String.check(Schema.isMinLength(1).annotate({ "description": "a" }))`, "string") } ) }) @@ -376,7 +386,7 @@ describe("toCodeDocument", () => { assertSchema( { schema: Schema.String.check(Schema.isMinLength(1)).annotate({ "description": "a" }) }, { - codes: makeCode(`Schema.String.check(Schema.isMinLength(1, { "description": "a" }))`, "string") + codes: makeCode(`Schema.String.check(Schema.isMinLength(1).annotate({ "description": "a" }))`, "string") } ) }) @@ -413,7 +423,7 @@ describe("toCodeDocument", () => { assertSchema( { schema: Schema.String.check(Schema.isGUID({ message: "message" })) }, { - codes: makeCode(`Schema.String.check(Schema.isGUID({ "message": "message" }))`, "string") + codes: makeCode(`Schema.String.check(Schema.isGUID().annotate({ "message": "message" }))`, "string") } ) }) @@ -561,7 +571,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Symbol", identifier: "_symbol", - generation: makeCode(`Symbol("a")`, `typeof _symbol`) + code: makeCode(`Symbol("a")`, `typeof _symbol`) }] } ) @@ -572,7 +582,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Symbol", identifier: "_symbol", - generation: makeCode(`Symbol()`, `typeof _symbol`) + code: makeCode(`Symbol()`, `typeof _symbol`) }] } ) @@ -586,7 +596,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Symbol", identifier: "_symbol", - generation: makeCode(`Symbol.for("a")`, `typeof _symbol`) + code: makeCode(`Symbol.for("a")`, `typeof _symbol`) }] } ) @@ -600,7 +610,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Symbol", identifier: "_symbol", - generation: makeCode(`Symbol.for("a")`, `typeof _symbol`) + code: makeCode(`Symbol.for("a")`, `typeof _symbol`) }] } ) @@ -621,7 +631,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "A": "a", "B": "b" }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "A" = "a", "B" = "b" }`, `typeof _Enum`) }] } ) @@ -640,7 +650,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "A": "a", "B": "b" }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "A" = "a", "B" = "b" }`, `typeof _Enum`) }] } ) @@ -659,7 +669,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "One": 1, "Two": 2 }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "One" = 1, "Two" = 2 }`, `typeof _Enum`) }] } ) @@ -678,7 +688,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "One": 1, "Two": 2 }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "One" = 1, "Two" = 2 }`, `typeof _Enum`) }] } ) @@ -697,7 +707,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "A": "a", "One": 1 }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "A" = "a", "One" = 1 }`, `typeof _Enum`) }] } ) @@ -716,7 +726,7 @@ describe("toCodeDocument", () => { artifacts: [{ _tag: "Enum", identifier: "_Enum", - generation: makeCode(`enum _Enum { "A": "a", "One": 1 }`, `typeof _Enum`) + code: makeCode(`enum _Enum { "A" = "a", "One" = 1 }`, `typeof _Enum`) }] } ) @@ -964,8 +974,8 @@ describe("toCodeDocument", () => { }, { codes: makeCode( - `Schema.TemplateLiteral([Schema.Literals(["a", "b"]), Schema.String, Schema.Union([Schema.BigInt, Schema.Number])])`, - "`a${string}${bigint}` | `a${string}${number}` | `b${string}${bigint}` | `b${string}${number}`" + `Schema.TemplateLiteral([Schema.Literals(["a", "b"]), Schema.String, Schema.Union([Schema.Number, Schema.BigInt])])`, + "`a${string}${number}` | `a${string}${bigint}` | `b${string}${number}` | `b${string}${bigint}`" ) } ) @@ -1204,12 +1214,12 @@ describe("toCodeDocument", () => { { codes: makeCode( `Schema.Struct({ [_symbol]: Schema.String })`, - `{ readonly [typeof _symbol]: string }` + `{ readonly [_symbol]: string }` ), artifacts: [{ _tag: "Symbol", identifier: "_symbol", - generation: makeCode(`Symbol.for("a")`, `typeof _symbol`) + code: makeCode(`Symbol.for("a")`, `typeof _symbol`) }] } ) @@ -1342,6 +1352,40 @@ describe("toCodeDocument", () => { }) describe("suspend", () => { + it("implicit recursive reference", () => { + assertMultiDocument({ + representations: [{ _tag: "Reference", $ref: "Category" }], + references: { + Category: { + _tag: "Objects", + propertySignatures: [{ + name: "children", + type: { + _tag: "Arrays", + elements: [], + rest: [{ _tag: "Reference", $ref: "Category" }], + checks: [] + }, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + } + } + }, { + codes: makeCode("Category", "Category"), + references: { + recursives: { + Category: makeCode( + `Schema.Struct({ "children": Schema.Array(Schema.suspend((): Schema.Codec => Category)) })`, + `{ readonly "children": ReadonlyArray }` + ) + } + } + }) + }) + it("non-recursive", () => { assertSchema( { @@ -1411,7 +1455,7 @@ describe("toCodeDocument", () => { references: { recursives: { A: makeCode( - `Schema.Struct({ "a": Schema.optionalKey(Suspend_) }).annotate({ "identifier": "A" })`, + `Schema.Struct({ "a": Schema.optionalKey(Schema.suspend((): Schema.Codec => Suspend_)) }).annotate({ "identifier": "A" })`, `{ readonly "a"?: Suspend_ }` ), Suspend_: makeCode( @@ -1440,7 +1484,7 @@ describe("toCodeDocument", () => { `Objects_` ), Objects_: makeCode( - `Schema.Struct({ "a": Schema.optionalKey(A) })`, + `Schema.Struct({ "a": Schema.optionalKey(Schema.suspend((): Schema.Codec => A)) })`, `{ readonly "a"?: A }` ) } @@ -1599,7 +1643,7 @@ describe("toCodeDocument", () => { { schema: Schema.Date.check(Schema.isBetweenDate({ minimum: new Date(0), maximum: new Date(1) })) }, { codes: makeCode( - `Schema.Date.check(Schema.isBetweenDate({ minimum: new Date(0), maximum: new Date(1), exclusiveMinimum: undefined, exclusiveMaximum: undefined))`, + `Schema.Date.check(Schema.isBetweenDate({ minimum: new Date(0), maximum: new Date(1), exclusiveMinimum: undefined, exclusiveMaximum: undefined }))`, "globalThis.Date" ) } @@ -1683,7 +1727,16 @@ describe("toCodeDocument", () => { } } }, { - codes: makeCode(`Schema.String`, "string") + codes: makeCode(`A`, "A"), + references: { + nonRecursives: [{ + $ref: "A", + code: makeCode( + `Schema.suspend((): Schema.Codec => Schema.String).annotate({ "identifier": "A" })`, + "string" + ) + }] + } }) }) @@ -1724,7 +1777,7 @@ describe("toCodeDocument", () => { { $ref: "A", code: makeCode( - `Schema.Struct({ "b": Schema.Number.check(Schema.isFinite()), "a": Schema.String })`, + `Schema.suspend((): Schema.Codec<{ readonly "b": number, readonly "a": string }> => Schema.Struct({ "b": Schema.Number.check(Schema.isFinite()), "a": Schema.String })).annotate({ "identifier": "A" })`, `{ readonly "b": number, readonly "a": string }` ) } @@ -1734,356 +1787,3 @@ describe("toCodeDocument", () => { }) }) }) - -describe("sanitizeJavaScriptIdentifier", () => { - const sanitizeJavaScriptIdentifier = SchemaRepresentation.sanitizeJavaScriptIdentifier - - it("returns '_' for empty input", () => { - strictEqual(sanitizeJavaScriptIdentifier(""), "_") - }) - - it("returns input when already a valid uppercase-start identifier", () => { - strictEqual(sanitizeJavaScriptIdentifier("Abc"), "Abc") - strictEqual(sanitizeJavaScriptIdentifier("_"), "_") - strictEqual(sanitizeJavaScriptIdentifier("$"), "$") - strictEqual(sanitizeJavaScriptIdentifier("$a_b9"), "$a_b9") - strictEqual(sanitizeJavaScriptIdentifier("A1b2"), "A1b2") - }) - - it("uppercases a leading ASCII letter", () => { - strictEqual(sanitizeJavaScriptIdentifier("abc"), "Abc") - strictEqual(sanitizeJavaScriptIdentifier("a0"), "A0") - strictEqual(sanitizeJavaScriptIdentifier("a1b2c3"), "A1b2c3") - strictEqual(sanitizeJavaScriptIdentifier("class"), "Class") - }) - - it("prefixes '_' when starting with a digit", () => { - strictEqual(sanitizeJavaScriptIdentifier("1"), "_1") - strictEqual(sanitizeJavaScriptIdentifier("1a"), "_1a") - strictEqual(sanitizeJavaScriptIdentifier("9lives"), "_9lives") - }) - - it("replaces invalid leading characters with '_'", () => { - strictEqual(sanitizeJavaScriptIdentifier(" abc"), "_abc") - strictEqual(sanitizeJavaScriptIdentifier("-a"), "_a") - strictEqual(sanitizeJavaScriptIdentifier(".a"), "_a") - strictEqual(sanitizeJavaScriptIdentifier(" a"), "_a") - strictEqual(sanitizeJavaScriptIdentifier("\ta"), "_a") - }) - - it("replaces invalid characters with '_'", () => { - strictEqual(sanitizeJavaScriptIdentifier("a-b"), "A_b") - strictEqual(sanitizeJavaScriptIdentifier("a b"), "A_b") - strictEqual(sanitizeJavaScriptIdentifier("a.b"), "A_b") - strictEqual(sanitizeJavaScriptIdentifier("a/b"), "A_b") - }) - - it("replaces multiple invalid characters with '_'", () => { - strictEqual(sanitizeJavaScriptIdentifier("a-b c"), "A_b_c") - strictEqual(sanitizeJavaScriptIdentifier("a..b"), "A__b") - strictEqual(sanitizeJavaScriptIdentifier("a--b"), "A__b") - strictEqual(sanitizeJavaScriptIdentifier("a b\tc"), "A_b_c") - }) - - it("replaces non-ascii characters with '_' under ASCII rules", () => { - strictEqual(sanitizeJavaScriptIdentifier("café"), "Caf_") - strictEqual(sanitizeJavaScriptIdentifier("你好"), "__") - strictEqual(sanitizeJavaScriptIdentifier("🤖"), "_") - strictEqual(sanitizeJavaScriptIdentifier("a🤖b"), "A_b") - }) - - it("allows '$' and '_' anywhere", () => { - strictEqual(sanitizeJavaScriptIdentifier("a$b"), "A$b") - strictEqual(sanitizeJavaScriptIdentifier("a_b"), "A_b") - strictEqual(sanitizeJavaScriptIdentifier("$a_b9"), "$a_b9") - }) - - it("keeps already-sanitized results stable (idempotent)", () => { - const cases = [ - "", - "abc", - "_", - "$", - "a1b2", - "a-b", - "a b", - "1a", - "-a", - "class", - "café", - "a🤖b" - ] as const - - for (const input of cases) { - const once = sanitizeJavaScriptIdentifier(input) - const twice = sanitizeJavaScriptIdentifier(once) - strictEqual(twice, once) - } - }) - - it("preserves length when only replacements are needed", () => { - strictEqual(sanitizeJavaScriptIdentifier("a-b").length, "a-b".length) - strictEqual(sanitizeJavaScriptIdentifier("a b").length, "a b".length) - strictEqual(sanitizeJavaScriptIdentifier("..").length, "..".length) - }) - - it("increases length only when prefixing is required", () => { - strictEqual(sanitizeJavaScriptIdentifier("1a"), "_1a") - strictEqual(sanitizeJavaScriptIdentifier("1a").length, "1a".length + 1) - }) -}) - -describe("topologicalSort", () => { - function assertTopologicalSort( - definitions: Record, - expected: SchemaRepresentation.TopologicalSort - ) { - deepStrictEqual(SchemaRepresentation.topologicalSort(definitions), expected) - } - - it("empty definitions", () => { - assertTopologicalSort( - {}, - { nonRecursives: [], recursives: {} } - ) - }) - - it("single definition with no dependencies", () => { - assertTopologicalSort( - { - A: { _tag: "String", checks: [] } - }, - { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } } - ], - recursives: {} - } - ) - }) - - it("multiple independent definitions", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { _tag: "Number", checks: [] }, - C: { _tag: "Boolean" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { $ref: "B", representation: { _tag: "Number", checks: [] } }, - { $ref: "C", representation: { _tag: "Boolean" } } - ], - recursives: {} - }) - }) - - it("A -> B -> C", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "B" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } }, - { $ref: "C", representation: { _tag: "Reference", $ref: "B" } } - ], - recursives: {} - }) - }) - - it("A -> B, A -> C", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } }, - { $ref: "C", representation: { _tag: "Reference", $ref: "A" } } - ], - recursives: {} - }) - }) - - it("A -> B -> C, A -> D", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "B" }, - D: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } }, - { $ref: "D", representation: { _tag: "Reference", $ref: "A" } }, - { $ref: "C", representation: { _tag: "Reference", $ref: "B" } } - ], - recursives: {} - }) - }) - - it("self-referential definition (A -> A)", () => { - assertTopologicalSort({ - A: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [], - recursives: { - A: { _tag: "Reference", $ref: "A" } - } - }) - }) - - it("mutual recursion (A -> B -> A)", () => { - assertTopologicalSort({ - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [], - recursives: { - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "A" } - } - }) - }) - - it("complex cycle (A -> B -> C -> A)", () => { - assertTopologicalSort({ - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "C" }, - C: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [], - recursives: { - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "C" }, - C: { _tag: "Reference", $ref: "A" } - } - }) - }) - - it("mixed recursive and non-recursive definitions", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "C" }, - D: { _tag: "Reference", $ref: "E" }, - E: { _tag: "Reference", $ref: "D" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } } - ], - recursives: { - C: { _tag: "Reference", $ref: "C" }, - D: { _tag: "Reference", $ref: "E" }, - E: { _tag: "Reference", $ref: "D" } - } - }) - }) - - it("nested $ref in object properties", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "Reference", $ref: "A" }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { - $ref: "B", - representation: { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "Reference", $ref: "A" }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - } - } - ], - recursives: {} - }) - }) - - it("nested $ref in array rest", () => { - assertTopologicalSort({ - A: { _tag: "String", checks: [] }, - B: { - _tag: "Arrays", - elements: [], - rest: [{ _tag: "Reference", $ref: "A" }], - checks: [] - } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "String", checks: [] } }, - { - $ref: "B", - representation: { _tag: "Arrays", elements: [], rest: [{ _tag: "Reference", $ref: "A" }], checks: [] } - } - ], - recursives: {} - }) - }) - - it("external $ref (not in definitions) should be ignored", () => { - assertTopologicalSort({ - A: { _tag: "Reference", $ref: "#/definitions/External" }, - B: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [ - { $ref: "A", representation: { _tag: "Reference", $ref: "#/definitions/External" } }, - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } } - ], - recursives: {} - }) - }) - - it("multiple cycles with independent definitions", () => { - assertTopologicalSort({ - Independent: { _tag: "String", checks: [] }, - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "D" }, - D: { _tag: "Reference", $ref: "C" } - }, { - nonRecursives: [ - { $ref: "Independent", representation: { _tag: "String", checks: [] } } - ], - recursives: { - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Reference", $ref: "A" }, - C: { _tag: "Reference", $ref: "D" }, - D: { _tag: "Reference", $ref: "C" } - } - }) - }) - - it("definition depending on recursive definition", () => { - assertTopologicalSort({ - A: { _tag: "Reference", $ref: "A" }, - B: { _tag: "Reference", $ref: "A" } - }, { - nonRecursives: [ - { $ref: "B", representation: { _tag: "Reference", $ref: "A" } } - ], - recursives: { - A: { _tag: "Reference", $ref: "A" } - } - }) - }) -}) diff --git a/packages/effect/test/schema/representation/toJson.test.ts b/packages/effect/test/schema/representation/toJson.test.ts new file mode 100644 index 00000000000..e085addb3a7 --- /dev/null +++ b/packages/effect/test/schema/representation/toJson.test.ts @@ -0,0 +1,393 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaAST, SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +describe("SchemaRepresentation.toJson", () => { + it("rejects invalid documents", () => { + throws( + () => + SchemaRepresentation.toJson({ + representation: { _tag: "Reference", $ref: "" }, + references: {} + }), + `Expected a value with a length of at least 1, got ""\n at ["representation"]["$ref"]` + ) + }) + + it("requires representation when persisting a Filter", () => { + throws( + () => + SchemaRepresentation.toJson({ + representation: { _tag: "String", checks: [{ _tag: "Filter", aborted: false }] }, + references: {} + }), + `Missing key\n at ["representation"]["checks"][0]["representation"]` + ) + }) + + it("removes live callbacks from a custom filter", () => { + const filter = Schema.makeFilter(() => true, { + description: "custom", + callback: () => "live", + representation: { + id: "acme/schema/custom", + payload: { minimum: 1 }, + schemas: [Schema.Number.ast] + }, + toCode: () => ({ runtime: "Custom" }), + toJsonSchema: () => ({ minLength: 1 }) + }).abort() + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast)), + { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + representation: { + id: "acme/schema/custom", + payload: { minimum: 1 }, + schemas: [{ _tag: "Number", checks: [] }] + }, + annotations: { + description: "custom" + }, + aborted: true + }] + }, + references: {} + } + ) + }) + + it("removes live callbacks from a custom declaration", () => { + const schema = Schema.declare((input): input is string => typeof input === "string", { + description: "custom", + representation: { id: "acme/schema/custom", payload: null }, + toCode: () => ({ runtime: "Custom", Type: "string" }), + toJsonSchema: () => ({ type: "string" }) + }) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)), + { + representation: { + _tag: "Declaration", + representation: { id: "acme/schema/custom", payload: null }, + annotations: { + description: "custom" + }, + typeParameters: [], + checks: [] + }, + references: {} + } + ) + }) + + it("preserves JSON annotations", () => { + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ values: ["1", "Symbol(a)", "NaN"] }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { values: ["1", "Symbol(a)", "NaN"] }, + checks: [] + }, + references: {} + }) + }) + + it("preserves shared JSON annotation values", () => { + const shared = { value: "shared" } + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ value: { left: shared, right: shared } }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { + value: { + left: { value: "shared" }, + right: { value: "shared" } + } + }, + checks: [] + }, + references: {} + }) + }) + + it("omits a cyclic annotation atomically", () => { + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ cyclic, title: "kept" }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { title: "kept" }, + checks: [] + }, + references: {} + }) + }) + + it("omits a sparse-array annotation atomically", () => { + const sparse = new Array(1) + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ sparse, title: "kept" }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { title: "kept" }, + checks: [] + }, + references: {} + }) + }) + + it("omits an annotation containing bigint atomically", () => { + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ invalid: { value: 1n }, title: "kept" }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { title: "kept" }, + checks: [] + }, + references: {} + }) + }) + + it("omits an annotation containing undefined atomically", () => { + const document = SchemaRepresentation.toRepresentation( + Schema.String.annotate({ invalid: { value: undefined }, title: "kept" }).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { title: "kept" }, + checks: [] + }, + references: {} + }) + }) + + it("encodes annotation accessors", () => { + const accessor = {} + Object.defineProperty(accessor, "value", { + enumerable: true, + get() { + return "value" + } + }) + const document = SchemaRepresentation.toRepresentation(Schema.String.annotate({ accessor }).ast) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { accessor: { value: "value" } }, + checks: [] + }, + references: {} + }) + }) + + it("preserves filter groups without an identity", () => { + const first = Schema.makeFilter(() => true, { + representation: { id: "acme/schema/first", payload: null } + }) + const second = Schema.makeFilter(() => true, { + representation: { id: "acme/schema/second", payload: null } + }).abort() + const group = Schema.makeFilterGroup([first, second], { description: "both" }) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.String.check(group).ast)), + { + representation: { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + annotations: { description: "both" }, + checks: [ + { + _tag: "Filter", + representation: { id: "acme/schema/first", payload: null }, + aborted: false + }, + { + _tag: "Filter", + representation: { id: "acme/schema/second", payload: null }, + aborted: true + } + ] + }] + }, + references: {} + } + ) + }) + + it("preserves tuple element annotations independently", () => { + const schema = Schema.Tuple([ + Schema.String.annotateKey({ description: "element", callback: () => "live" }) + ]) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)), + { + representation: { + _tag: "Arrays", + elements: [{ + type: { _tag: "String", checks: [] }, + isOptional: false, + annotations: { description: "element" } + }], + rest: [], + checks: [] + }, + references: {} + } + ) + }) + + it("preserves property annotations independently", () => { + const schema = Schema.Struct({ + value: Schema.String.annotateKey({ description: "property", callback: () => "live" }) + }) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)), + { + representation: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false, + annotations: { description: "property" } + }], + indexSignatures: [], + checks: [] + }, + references: {} + } + ) + }) + + it("encodes bigint structural values", () => { + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.Literal(1n).ast)), + { + representation: { + _tag: "Literal", + literal: "1", + checks: [] + }, + references: {} + } + ) + }) + + it("encodes negative zero structural values", () => { + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.Literal(-0).ast)), + { + representation: { + _tag: "Literal", + literal: -0, + checks: [] + }, + references: {} + } + ) + }) + + it("preserves string literals resembling non-finite numbers", () => { + for (const literal of ["NaN", "Infinity", "-Infinity"]) { + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.Literal(literal).ast)), + { + representation: { _tag: "Literal", literal, checks: [] }, + references: {} + } + ) + } + }) + + it("encodes global symbols", () => { + assert.deepStrictEqual( + SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation(Schema.UniqueSymbol(Symbol.for("acme/schema/key")).ast) + ), + { + representation: { + _tag: "UniqueSymbol", + symbol: "Symbol(acme/schema/key)", + checks: [] + }, + references: {} + } + ) + }) + + it("rejects local symbols", () => { + throws( + () => + SchemaRepresentation.toJson( + SchemaRepresentation.toRepresentation(Schema.UniqueSymbol(Symbol("local")).ast) + ), + `cannot serialize to string, Symbol is not registered\n at ["representation"]["symbol"]` + ) + }) + + it("encodes recursive references", () => { + let schema: Schema.Codec + schema = Schema.suspend((): Schema.Codec => schema) + + assert.deepStrictEqual( + SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)), + { + representation: { _tag: "Reference", $ref: "Suspend_" }, + references: { + Suspend_: { + _tag: "Suspend", + thunk: { _tag: "Reference", $ref: "Suspend_" }, + checks: [] + } + } + } + ) + }) + + it("encodes fromJsonString annotations", () => { + const schema = SchemaAST.toEncoded(Schema.fromJsonString(Schema.Struct({ value: Schema.Number })).ast) + const document = SchemaRepresentation.toRepresentation(schema) + + assert.deepStrictEqual(SchemaRepresentation.toJson(document), { + representation: { + _tag: "String", + annotations: { + contentMediaType: "application/json", + expected: "a string that will be decoded as JSON" + }, + checks: [] + }, + references: {} + }) + }) +}) diff --git a/packages/effect/test/schema/representation/toJsonMultiDocument.test.ts b/packages/effect/test/schema/representation/toJsonMultiDocument.test.ts new file mode 100644 index 00000000000..349f89d514e --- /dev/null +++ b/packages/effect/test/schema/representation/toJsonMultiDocument.test.ts @@ -0,0 +1,40 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.toJsonMultiDocument", () => { + it("encodes every root in order", () => { + const document = SchemaRepresentation.toRepresentations([ + Schema.String.ast, + Schema.Number.ast, + Schema.Literal(1n).ast + ]) + + assert.deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Number", checks: [] }, + { _tag: "Literal", literal: "1", checks: [] } + ], + references: {} + }) + }) + + it("encodes shared references once", () => { + const shared = Schema.String.annotate({ identifier: "Shared" }) + const document = SchemaRepresentation.toRepresentations([shared.ast, shared.ast]) + + assert.deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { + representations: [ + { _tag: "Reference", $ref: "Shared" }, + { _tag: "Reference", $ref: "Shared" } + ], + references: { + Shared: { + _tag: "String", + annotations: { identifier: "Shared" }, + checks: [] + } + } + }) + }) +}) diff --git a/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts new file mode 100644 index 00000000000..666f7398e06 --- /dev/null +++ b/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts @@ -0,0 +1,795 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaAST, SchemaRepresentation } from "effect" +import { throws } from "../../utils/assert.ts" + +function expectError(thunk: () => void, expected: string | Error): void { + if (typeof expected === "string") { + throws(thunk, expected) + } else { + throws(thunk, (error: unknown) => { + assert.strictEqual(error, expected) + return undefined + }) + } +} + +const StringRepresentation: SchemaRepresentation.Representation = { + _tag: "String", + checks: [] +} + +const NumberRepresentation: SchemaRepresentation.Representation = { + _tag: "Number", + checks: [] +} + +const EmptyUnionRepresentation: SchemaRepresentation.Representation = { + _tag: "Union", + types: [], + mode: "anyOf", + checks: [] +} + +describe("SchemaRepresentation.toJsonSchemaDocument annotations", () => { + function compile(representation: SchemaRepresentation.Representation) { + return SchemaRepresentation.toJsonSchemaDocument({ representation, references: {} }).schema + } + + it("compiles Any", () => { + assert.deepStrictEqual(compile({ _tag: "Any", checks: [] }), {}) + }) + + it("compiles Unknown", () => { + assert.deepStrictEqual(compile({ _tag: "Unknown", checks: [] }), {}) + }) + + it("compiles ObjectKeyword", () => { + assert.deepStrictEqual(compile({ _tag: "ObjectKeyword", checks: [] }), { + anyOf: [{ type: "object" }, { type: "array" }] + }) + }) + + it("compiles Void", () => { + assert.deepStrictEqual(compile({ _tag: "Void", checks: [] }), { type: "null" }) + }) + + it("compiles Undefined", () => { + assert.deepStrictEqual(compile({ _tag: "Undefined", checks: [] }), { type: "null" }) + }) + + it("compiles BigInt", () => { + assert.deepStrictEqual(compile({ _tag: "BigInt", checks: [] }), { + type: "string", + allOf: [{ pattern: "^-?\\d+$" }] + }) + }) + + it("compiles Symbol", () => { + assert.deepStrictEqual(compile({ _tag: "Symbol", checks: [] }), { + type: "string", + allOf: [{ pattern: "^Symbol\\((.*)\\)$" }] + }) + }) + + it("compiles UniqueSymbol", () => { + assert.deepStrictEqual(compile({ _tag: "UniqueSymbol", symbol: Symbol.for("value"), checks: [] }), { + type: "string", + allOf: [{ pattern: "^Symbol\\((.*)\\)$" }] + }) + }) + + it("compiles Null", () => { + assert.deepStrictEqual(compile({ _tag: "Null", checks: [] }), { type: "null" }) + }) + + it("compiles Never", () => { + assert.deepStrictEqual(compile({ _tag: "Never", checks: [] }), { not: {} }) + }) + + it("compiles Suspend", () => { + assert.deepStrictEqual( + compile({ + _tag: "Suspend", + thunk: StringRepresentation, + checks: [] + }), + { type: "string" } + ) + }) + + it("compiles a bigint Literal", () => { + assert.deepStrictEqual(compile({ _tag: "Literal", literal: 1n, checks: [] }), { + type: "string", + enum: ["1"] + }) + }) + + it("compiles a string Literal", () => { + assert.deepStrictEqual(compile({ _tag: "Literal", literal: "a", checks: [] }), { + type: "string", + enum: ["a"] + }) + }) + + it("compiles an empty Enum", () => { + assert.deepStrictEqual(compile({ _tag: "Enum", enums: [], checks: [] }), { not: {} }) + }) + + it("compiles an Enum", () => { + assert.deepStrictEqual(compile({ _tag: "Enum", enums: [["A", "a"], ["One", 1]], checks: [] }), { + anyOf: [ + { type: "string", enum: ["a"], title: "A" }, + { type: "number", enum: [1], title: "One" } + ] + }) + }) + + it("compiles a TemplateLiteral", () => { + assert.deepStrictEqual( + compile({ + _tag: "TemplateLiteral", + parts: [{ _tag: "Literal", literal: "prefix-", checks: [] }, StringRepresentation], + checks: [] + }), + { type: "string", pattern: "^prefix-[\\s\\S]*?$" } + ) + }) + + it("compiles optional tuple elements", () => { + assert.deepStrictEqual( + compile({ + _tag: "Arrays", + elements: [ + { type: StringRepresentation, isOptional: false }, + { type: NumberRepresentation, isOptional: true } + ], + rest: [], + checks: [] + }), + { + type: "array", + prefixItems: [{ type: "string" }, { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["NaN"] }, + { type: "string", enum: ["Infinity"] }, + { type: "string", enum: ["-Infinity"] } + ] + }], + maxItems: 2, + minItems: 1 + } + ) + }) + + it("rejects multiple tuple rest elements", () => { + expectError( + () => + compile({ + _tag: "Arrays", + elements: [], + rest: [StringRepresentation, NumberRepresentation], + checks: [] + }), + `Invalid schema representation document\n at ["representation"]["rest"]` + ) + }) + + it("rejects a symbol object property", () => { + expectError( + () => + compile({ + _tag: "Objects", + propertySignatures: [{ + name: Symbol.for("value"), + type: StringRepresentation, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + }), + `Invalid schema representation document\n at ["representation"]["propertySignatures"][0]["name"]` + ) + }) + + it("compiles a Never index-signature value as false", () => { + assert.deepStrictEqual( + compile({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ parameter: StringRepresentation, type: { _tag: "Never", checks: [] } }], + checks: [] + }), + { type: "object", additionalProperties: false } + ) + }) + + it("removes an unconstrained index-signature value", () => { + assert.deepStrictEqual( + compile({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ parameter: StringRepresentation, type: { _tag: "Unknown", checks: [] } }], + checks: [] + }), + { type: "object" } + ) + }) + + it("compacts a union of same-type literals", () => { + assert.deepStrictEqual( + compile({ + _tag: "Union", + types: [ + { _tag: "Literal", literal: "a", checks: [] }, + { _tag: "Literal", literal: "b", checks: [] } + ], + mode: "anyOf", + checks: [] + }), + { type: "string", enum: ["a", "b"] } + ) + }) + + it("does not compact a union of mixed-type literals", () => { + assert.deepStrictEqual( + compile({ + _tag: "Union", + types: [ + { _tag: "Literal", literal: "a", checks: [] }, + { _tag: "Literal", literal: 1, checks: [] } + ], + mode: "anyOf", + checks: [] + }), + { + anyOf: [{ type: "string", enum: ["a"] }, { type: "number", enum: [1] }] + } + ) + }) + + it("emits every supported standard annotation", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + annotations: { + title: "Title", + description: "Description", + default: "default", + examples: ["a", "b"], + readOnly: true, + writeOnly: false + }, + checks: [] + }), + { + type: "string", + title: "Title", + description: "Description", + default: "default", + examples: ["a", "b"], + readOnly: true, + writeOnly: false + } + ) + }) + + it("uses a check fragment when the base JSON Schema is empty", () => { + assert.deepStrictEqual( + compile({ + _tag: "Unknown", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { toJsonSchema: () => ({ description: "checked" }) } + }] + }), + { description: "checked" } + ) + }) + + it("appends a check to an existing allOf", () => { + assert.deepStrictEqual( + compile({ + _tag: "BigInt", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { toJsonSchema: () => ({ description: "checked" }) } + }] + }), + { + type: "string", + allOf: [{ pattern: "^-?\\d+$" }, { description: "checked" }] + } + ) + }) + + it("lets a check refine number to integer", () => { + assert.deepStrictEqual( + compile({ + _tag: "Number", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { toJsonSchema: () => ({ type: "integer" }) } + }] + }), + { type: "integer" } + ) + }) + + it("compiles a callback-free FilterGroup without ordinary annotations", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [{ + _tag: "FilterGroup", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { toJsonSchema: () => ({ minLength: 1 }) } + }] + }] + }), + { type: "string", allOf: [{ minLength: 1 }] } + ) + }) + + it("compiles tuple element annotations", () => { + assert.deepStrictEqual( + compile({ + _tag: "Arrays", + elements: [{ + type: StringRepresentation, + isOptional: false, + annotations: { description: "element" } + }], + rest: [], + checks: [] + }), + { + type: "array", + prefixItems: [{ type: "string", allOf: [{ description: "element" }] }], + maxItems: 1, + minItems: 1 + } + ) + }) + + it("omits minItems when every tuple element is optional", () => { + assert.deepStrictEqual( + compile({ + _tag: "Arrays", + elements: [{ type: StringRepresentation, isOptional: true }], + rest: [], + checks: [] + }), + { + type: "array", + prefixItems: [{ type: "string" }], + maxItems: 1 + } + ) + }) + + it("compiles a constrained tuple rest", () => { + assert.deepStrictEqual( + compile({ + _tag: "Arrays", + elements: [], + rest: [StringRepresentation], + checks: [] + }), + { type: "array", items: { type: "string" } } + ) + }) + + it("compiles property annotations", () => { + assert.deepStrictEqual( + compile({ + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: StringRepresentation, + isOptional: true, + isMutable: false, + annotations: { description: "property" } + }], + indexSignatures: [], + checks: [] + }), + { + type: "object", + properties: { value: { type: "string", allOf: [{ description: "property" }] } }, + additionalProperties: false + } + ) + }) + + it("compiles a single-member Union without compacting it", () => { + assert.deepStrictEqual( + compile({ + _tag: "Union", + types: [StringRepresentation], + mode: "anyOf", + checks: [] + }), + { anyOf: [{ type: "string" }] } + ) + }) + + it("rejects an unsupported index-signature parameter", () => { + expectError( + () => + compile({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ parameter: NumberRepresentation, type: StringRepresentation }], + checks: [] + }), + `Invalid schema representation document\n at ["representation"]["indexSignatures"][0]["parameter"]` + ) + }) + + it("compiles an empty union as Never", () => { + assert.deepStrictEqual( + SchemaRepresentation.toJsonSchemaDocument({ + representation: EmptyUnionRepresentation, + references: {} + }).schema, + { not: {} } + ) + }) + + it("removes items when an empty tuple has an open rest", () => { + assert.deepStrictEqual( + SchemaRepresentation.toJsonSchemaDocument({ + representation: { + _tag: "Arrays", + elements: [], + rest: [{ _tag: "Unknown", checks: [] }], + checks: [] + }, + references: {} + }).schema, + { type: "array" } + ) + }) + + it("passes the type produced by isInt to following check callbacks", () => { + let receivedType: unknown + const dependent = Schema.makeFilter(() => true, { + toJsonSchema: ({ type }) => { + receivedType = type + return { minimum: 0 } + } + }) + const document = SchemaRepresentation.toRepresentation( + Schema.Number.check(Schema.isInt(), dependent).ast + ) + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { + type: "integer", + allOf: [{ minimum: 0 }] + }) + assert.strictEqual(receivedType, "integer") + }) + + it("compiles the isPattern vertical slice", () => { + const pattern = SchemaRepresentation.toJsonSchemaDocument( + SchemaRepresentation.toRepresentation(Schema.String.check(Schema.isPattern(/^[a-z]+$/i)).ast) + ) + assert.deepStrictEqual(pattern, { + dialect: "draft-2020-12", + schema: { + type: "string", + allOf: [{ pattern: "^[a-z]+$" }] + }, + definitions: {} + }) + }) + + it("treats an empty override as authoritative and ignores a leaf without a callback", () => { + let visits = 0 + const document: SchemaRepresentation.Document = { + representation: { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + annotations: { toJsonSchema: () => ({}) }, + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + description: "ignored", + toJsonSchema: () => { + visits++ + return { minLength: 1 } + } + } + }] + }, { + _tag: "Filter", + aborted: false, + annotations: { description: "no callback" } + }] + }, + references: {} + } + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { type: "string" }) + assert.strictEqual(visits, 0) + }) + + it("compiles representation.schemas before invoking a callback", () => { + const document: SchemaRepresentation.Document = { + representation: { + _tag: "Objects", + propertySignatures: [], + indexSignatures: [], + checks: [{ + _tag: "Filter", + aborted: false, + representation: { + id: "acme/schema/propertyNames", + payload: null, + schemas: [StringRepresentation] + }, + annotations: { + toJsonSchema: ({ schemas }: SchemaRepresentation.ToJsonSchema.CheckInput) => ({ + propertyNames: schemas[0] + }) + } + }] + }, + references: {} + } + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { + anyOf: [{ type: "object" }, { type: "array" }], + allOf: [{ propertyNames: { type: "string" } }] + }) + }) + + it("approximates declarations", () => { + const document: SchemaRepresentation.Document = { + representation: { + _tag: "Declaration", + typeParameters: [StringRepresentation], + checks: [], + representation: { + id: "acme/schema/Box", + payload: null + } + }, + references: {} + } + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, {}) + }) + + it("compiles every member of a union index-signature parameter", () => { + const template = SchemaRepresentation.toRepresentation( + Schema.TemplateLiteral(["a", Schema.String]).ast + ).representation + const pattern = SchemaRepresentation.toRepresentation( + Schema.String.check(Schema.isPattern(/^b/)).ast + ).representation + const document: SchemaRepresentation.Document = { + representation: { + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ + parameter: { + _tag: "Union", + types: [template, pattern], + mode: "anyOf", + checks: [] + }, + type: StringRepresentation + }], + checks: [] + }, + references: {} + } + + const schema = SchemaRepresentation.toJsonSchemaDocument(document).schema + assert.deepStrictEqual(Object.keys(schema.patternProperties ?? {}), ["^a[\\s\\S]*?$", "^b"]) + }) + + it("ignores boolean members while collecting index-signature patterns", () => { + const document: SchemaRepresentation.Document = { + representation: { + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ + parameter: { + _tag: "String", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + toJsonSchema: () => ({ allOf: [false, { pattern: "^a" }] }) + } + }] + }, + type: StringRepresentation + }], + checks: [] + }, + references: {} + } + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { + type: "object", + patternProperties: { "^a": { type: "string" } } + }) + }) + + it("compiles all supported template-literal parts and rejects other nodes", () => { + const representation: SchemaRepresentation.Representation = { + _tag: "TemplateLiteral", + parts: [ + { _tag: "Literal", literal: "p", checks: [] }, + NumberRepresentation, + { + _tag: "TemplateLiteral", + parts: [ + { _tag: "Literal", literal: "x", checks: [] }, + StringRepresentation + ], + checks: [] + }, + { + _tag: "Union", + types: [ + { _tag: "Literal", literal: "a", checks: [] }, + { _tag: "Literal", literal: "b", checks: [] } + ], + mode: "anyOf", + checks: [] + } + ], + checks: [] + } + + assert.deepStrictEqual( + SchemaRepresentation.toJsonSchemaDocument({ representation, references: {} }).schema, + { + type: "string", + pattern: `^p${SchemaAST.FINITE_PATTERN}x${SchemaAST.STRING_PATTERN}a|b$` + } + ) + + expectError( + () => + SchemaRepresentation.toJsonSchemaDocument({ + representation: { + _tag: "TemplateLiteral", + parts: [{ _tag: "Boolean", checks: [] }], + checks: [] + }, + references: {} + }), + "Invalid schema representation document" + ) + }) + + it("extracts nested number types without losing other allOf members", () => { + const output = SchemaRepresentation.toJsonSchemaDocument({ + representation: { + _tag: "Number", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + toJsonSchema: () => ({ + allOf: [ + { + description: "nested", + allOf: [{ type: "number" }, { minimum: 1 }] + }, + { type: "integer", maximum: 10 }, + { title: "kept" } + ] + }) + } + }] + }, + references: {} + }) + + assert.deepStrictEqual(output.schema, { + type: "integer", + allOf: [ + { description: "nested", allOf: [{ minimum: 1 }] }, + { maximum: 10 }, + { title: "kept" } + ] + }) + + assert.deepStrictEqual( + SchemaRepresentation.toJsonSchemaDocument({ + representation: { + _tag: "Number", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + toJsonSchema: () => ({ allOf: [{ type: "number" }, { type: "number" }] }) + } + }] + }, + references: {} + }).schema, + { type: "number" } + ) + }) + + it("never exposes compiler capabilities as extension annotations", () => { + const document: SchemaRepresentation.Document = { + representation: { + _tag: "String", + annotations: { + description: "text", + identifier: "id", + representation: { id: "acme/schema/String", payload: null }, + toCode: () => ({ runtime: "ignored" }), + toJsonSchema: () => ({ title: "ignored" }), + "x-custom": { enabled: true }, + "x-invalid": () => "ignored" + }, + checks: [] + }, + references: {} + } + + assert.deepStrictEqual( + SchemaRepresentation.toJsonSchemaDocument(document, { + includeAnnotationKey: () => true + }).schema, + { + type: "string", + description: "text", + identifier: "id", + "x-custom": { enabled: true } + } + ) + }) + + it("captures exceptions from JSON Schema callbacks", () => { + const cause = new Error("json schema callback") + const document: SchemaRepresentation.Document = { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + aborted: false, + annotations: { + toJsonSchema: () => { + throw cause + } + } + }] + }, + references: {} + } + expectError( + () => SchemaRepresentation.toJsonSchemaDocument(document), + cause + ) + }) + + it("reports missing references with their document path", () => { + const document: SchemaRepresentation.Document = { + representation: { _tag: "Reference", $ref: "Missing" }, + references: {} + } + expectError( + () => SchemaRepresentation.toJsonSchemaDocument(document), + `Invalid reference Missing\n at ["representation"]["$ref"]` + ) + }) +}) diff --git a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts index 2e12cb328f2..e34cba0b2cd 100644 --- a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts +++ b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts @@ -1,15 +1,31 @@ +import { assert, describe, it } from "@effect/vitest" import { Schema, SchemaRepresentation } from "effect" -import { describe, it } from "vitest" -import { deepStrictEqual } from "../../utils/assert.ts" +import { throws } from "../../utils/assert.ts" -describe("toJsonSchemaMultiDocument", () => { +function expectError(thunk: () => void, expected: string | Error): void { + if (typeof expected === "string") { + throws(thunk, expected) + } else { + throws(thunk, (error: unknown) => { + assert.strictEqual(error, expected) + return undefined + }) + } +} + +const StringRepresentation: SchemaRepresentation.Representation = { + _tag: "String", + checks: [] +} + +describe("SchemaRepresentation.toJsonSchemaMultiDocument", () => { it("should handle multiple schemas", () => { const A = Schema.String.annotate({ identifier: "id", description: "a" }) const B = Schema.String.annotate({ identifier: "id", description: "b" }) const C = Schema.Tuple([A, B]) - const multiDocument = SchemaRepresentation.fromASTs([A.ast, B.ast, C.ast]) + const multiDocument = SchemaRepresentation.toRepresentations([A.ast, B.ast, C.ast]) const jsonMultiDocument = SchemaRepresentation.toJsonSchemaMultiDocument(multiDocument) - deepStrictEqual(jsonMultiDocument, { + assert.deepStrictEqual(jsonMultiDocument, { dialect: "draft-2020-12", schemas: [ { "$ref": "#/$defs/id" }, @@ -36,4 +52,129 @@ describe("toJsonSchemaMultiDocument", () => { } }) }) + it("emits standard annotations and oneOf unions", () => { + const output = SchemaRepresentation.toJsonSchemaMultiDocument({ + representations: [ + { + _tag: "String", + annotations: { + format: "email", + contentEncoding: "base64", + contentMediaType: "application/json", + contentSchema: { type: "string" } + }, + checks: [] + }, + { + _tag: "Union", + types: [StringRepresentation, { _tag: "Boolean", checks: [] }], + mode: "oneOf", + checks: [] + } + ], + references: {} + }) + + assert.deepStrictEqual(output.schemas, [ + { + type: "string", + format: "email", + contentEncoding: "base64", + contentMediaType: "application/json", + contentSchema: { type: "string" } + }, + { oneOf: [{ type: "string" }, { type: "boolean" }] } + ]) + }) + + it("uses group overrides without visiting children and otherwise falls back to allOf", () => { + let visits = 0 + const child: SchemaRepresentation.Filter = { + _tag: "Filter", + aborted: false, + annotations: { + toJsonSchema: () => { + visits++ + return { minLength: 1 } + } + } + } + const override: SchemaRepresentation.FilterGroup = { + _tag: "FilterGroup", + checks: [child], + annotations: { + description: "override", + toJsonSchema: () => ({ format: "custom" }) + } + } + const fallback: SchemaRepresentation.FilterGroup = { + _tag: "FilterGroup", + checks: [child, { _tag: "Filter", aborted: false }], + annotations: { description: "fallback" } + } + const document: SchemaRepresentation.MultiDocument = { + representations: [ + { _tag: "String", checks: [override] }, + { _tag: "String", checks: [fallback] } + ], + references: {} + } + + const output = SchemaRepresentation.toJsonSchemaMultiDocument(document) + assert.strictEqual(visits, 1) + assert.deepStrictEqual(output.schemas, [ + { + type: "string", + allOf: [{ format: "custom", description: "override" }] + }, + { + type: "string", + allOf: [{ allOf: [{ minLength: 1 }], description: "fallback" }] + } + ]) + }) + + it("resolves referenced index-signature parameters and stops cycles", () => { + const record = ( + parameter: SchemaRepresentation.Representation + ): SchemaRepresentation.Representation => ({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [{ parameter, type: StringRepresentation }], + checks: [] + }) + const pattern = SchemaRepresentation.toRepresentation( + Schema.String.check(Schema.isPattern(/^a/)).ast + ).representation + const output = SchemaRepresentation.toJsonSchemaMultiDocument({ + representations: [ + record({ _tag: "Reference", $ref: "Pattern" }), + record({ _tag: "Reference", $ref: "Cycle" }) + ], + references: { + Pattern: pattern, + Cycle: { _tag: "Reference", $ref: "Cycle" } + } + }) + + assert.deepStrictEqual(output.schemas, [ + { + type: "object", + patternProperties: { "^a": { type: "string" } } + }, + { + type: "object", + additionalProperties: { type: "string" } + } + ]) + + expectError( + () => + SchemaRepresentation.toJsonSchemaDocument({ + representation: record({ _tag: "Reference", $ref: "Missing" }), + references: {} + }), + `Invalid reference Missing\n at ["representation"]["indexSignatures"][0]["parameter"]["$ref"]` + ) + }) }) diff --git a/packages/effect/test/schema/representation/toMultiDocument.test.ts b/packages/effect/test/schema/representation/toMultiDocument.test.ts new file mode 100644 index 00000000000..d3b1c388e3a --- /dev/null +++ b/packages/effect/test/schema/representation/toMultiDocument.test.ts @@ -0,0 +1,18 @@ +import { assert, describe, it } from "@effect/vitest" +import { SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.toMultiDocument", () => { + it("wraps the root representation and preserves references", () => { + const reference = { _tag: "String" as const, checks: [] } + assert.deepStrictEqual( + SchemaRepresentation.toMultiDocument({ + representation: { _tag: "Reference", $ref: "Value" }, + references: { Value: reference } + }), + { + representations: [{ _tag: "Reference", $ref: "Value" }], + references: { Value: reference } + } + ) + }) +}) diff --git a/packages/effect/test/schema/representation/toRepresentation.test.ts b/packages/effect/test/schema/representation/toRepresentation.test.ts new file mode 100644 index 00000000000..981cc574ee4 --- /dev/null +++ b/packages/effect/test/schema/representation/toRepresentation.test.ts @@ -0,0 +1,748 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaAST, SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.toRepresentation", () => { + it("converts Null", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Null.ast), { + representation: { _tag: "Null", checks: [] }, + references: {} + }) + }) + + it("converts Undefined", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Undefined.ast), { + representation: { _tag: "Undefined", checks: [] }, + references: {} + }) + }) + + it("converts Void", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Void.ast), { + representation: { _tag: "Void", checks: [] }, + references: {} + }) + }) + + it("converts Never", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Never.ast), { + representation: { _tag: "Never", checks: [] }, + references: {} + }) + }) + + it("converts Unknown", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Unknown.ast), { + representation: { _tag: "Unknown", checks: [] }, + references: {} + }) + }) + + it("converts Any", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Any.ast), { + representation: { _tag: "Any", checks: [] }, + references: {} + }) + }) + + it("converts String", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.String.ast), { + representation: { _tag: "String", checks: [] }, + references: {} + }) + }) + + it("converts Number", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Number.ast), { + representation: { _tag: "Number", checks: [] }, + references: {} + }) + }) + + it("converts Boolean", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Boolean.ast), { + representation: { _tag: "Boolean", checks: [] }, + references: {} + }) + }) + + it("converts BigInt", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.BigInt.ast), { + representation: { _tag: "BigInt", checks: [] }, + references: {} + }) + }) + + it("converts Symbol", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Symbol.ast), { + representation: { _tag: "Symbol", checks: [] }, + references: {} + }) + }) + + it("converts ObjectKeyword", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.ObjectKeyword.ast), { + representation: { _tag: "ObjectKeyword", checks: [] }, + references: {} + }) + }) + + it("converts a literal", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Literal("value").ast), { + representation: { _tag: "Literal", literal: "value", checks: [] }, + references: {} + }) + }) + + it("converts a global unique symbol", () => { + const symbol = Symbol.for("value") + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.UniqueSymbol(symbol).ast), { + representation: { _tag: "UniqueSymbol", symbol, checks: [] }, + references: {} + }) + }) + + it("converts an enum", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Enum({ A: "a", One: 1 }).ast), { + representation: { + _tag: "Enum", + enums: [["A", "a"], ["One", 1]], + checks: [] + }, + references: {} + }) + }) + + it("converts a template literal", () => { + assert.deepStrictEqual( + SchemaRepresentation.toRepresentation(Schema.TemplateLiteral(["prefix-", Schema.String, Schema.Number]).ast), + { + representation: { + _tag: "TemplateLiteral", + parts: [ + { _tag: "Literal", literal: "prefix-", checks: [] }, + { _tag: "String", checks: [] }, + { _tag: "Number", checks: [] } + ], + checks: [] + }, + references: {} + } + ) + }) + + it("converts tuple elements and rest", () => { + const schema = Schema.TupleWithRest( + Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)]), + [Schema.Boolean] + ) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "Arrays", + elements: [ + { type: { _tag: "String", checks: [] }, isOptional: false }, + { type: { _tag: "Number", checks: [] }, isOptional: true } + ], + rest: [{ _tag: "Boolean", checks: [] }], + checks: [] + }, + references: {} + }) + }) + + it("converts object properties and index signatures", () => { + const schema = Schema.StructWithRest( + Schema.Struct({ + required: Schema.String, + optional: Schema.optionalKey(Schema.Number), + mutable: Schema.mutableKey(Schema.Boolean) + }), + [Schema.Record(Schema.Symbol, Schema.BigInt)] + ) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "Objects", + propertySignatures: [ + { + name: "required", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false + }, + { + name: "optional", + type: { _tag: "Number", checks: [] }, + isOptional: true, + isMutable: false + }, + { + name: "mutable", + type: { _tag: "Boolean", checks: [] }, + isOptional: false, + isMutable: true + } + ], + indexSignatures: [{ + parameter: { _tag: "Symbol", checks: [] }, + type: { _tag: "BigInt", checks: [] } + }], + checks: [] + }, + references: {} + }) + }) + + it("converts a union preserving member order", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.Union([Schema.String, Schema.BigInt]).ast), { + representation: { + _tag: "Union", + types: [ + { _tag: "String", checks: [] }, + { _tag: "BigInt", checks: [] } + ], + mode: "anyOf", + checks: [] + }, + references: {} + }) + }) + + it("uses the type side of a transformation", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.NumberFromString.ast), { + representation: { _tag: "Number", checks: [] }, + references: {} + }) + }) + + it("uses the encoded side only when the caller projects it", () => { + assert.deepStrictEqual( + SchemaRepresentation.toRepresentation(SchemaAST.toEncoded(Schema.NumberFromString.ast)), + { + representation: { + _tag: "String", + annotations: { expected: "a string that will be decoded as a number" }, + checks: [] + }, + references: {} + } + ) + }) + + it("preserves brands", () => { + assert.deepStrictEqual( + SchemaRepresentation.toRepresentation(Schema.String.pipe(Schema.brand("A"), Schema.brand("B")).ast), + { + representation: { + _tag: "String", + annotations: { brands: ["A", "B"] }, + checks: [] + }, + references: {} + } + ) + }) + + it("preserves declaration code callbacks", () => { + const toCode: SchemaRepresentation.Generation.Declaration = () => ({ runtime: "Custom", Type: "string" }) + const schema = Schema.declare((input): input is string => typeof input === "string", { + representation: { + id: "acme/schema/Custom", + payload: null + }, + toCode + }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "Declaration", + typeParameters: [], + checks: [], + representation: { + id: "acme/schema/Custom", + payload: null + }, + annotations: { + toCode + } + }, + references: {} + }) + }) + + it("converts declaration type parameters", () => { + const representation = SchemaRepresentation.toRepresentation(Schema.Option(Schema.Number).ast).representation + + assert.strictEqual(representation._tag, "Declaration") + if (representation._tag !== "Declaration") return + assert.deepStrictEqual(representation.typeParameters, [{ _tag: "Number", checks: [] }]) + }) + + it("preserves custom filter callbacks, dependencies and aborted state", () => { + const toCode: SchemaRepresentation.Generation.Check = () => ({ runtime: "Custom" }) + const toJsonSchema: SchemaRepresentation.ToJsonSchema.Check = () => ({ minLength: 1 }) + const marker = () => "marker" + const filter = Schema.makeFilter(() => true, { + representation: { + id: "acme/schema/Custom", + payload: { minimum: 1 }, + schemas: [Schema.Number.ast] + }, + toCode, + toJsonSchema, + marker + }).abort() + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast), { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + representation: { + id: "acme/schema/Custom", + payload: { minimum: 1 }, + schemas: [{ _tag: "Number", checks: [] }] + }, + annotations: { + toCode, + toJsonSchema, + marker + }, + aborted: true + }] + }, + references: {} + }) + }) + + it("preserves filters without persistence metadata", () => { + const filter = Schema.makeFilter(() => true, { expected: "custom" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast), { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + annotations: { expected: "custom" }, + aborted: false + }] + }, + references: {} + }) + }) + + it("preserves a filter without annotations", () => { + const filter = Schema.makeFilter(() => true) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast), { + representation: { + _tag: "String", + checks: [{ _tag: "Filter", aborted: false }] + }, + references: {} + }) + }) + + it("preserves filter groups", () => { + const first = Schema.makeFilter(() => true, { expected: "first" }) + const second = Schema.makeFilter(() => true, { expected: "second" }) + const group = Schema.makeFilterGroup([first, second], { description: "group" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.String.check(group).ast), { + representation: { + _tag: "String", + checks: [{ + _tag: "FilterGroup", + annotations: { description: "group" }, + checks: [ + { _tag: "Filter", annotations: { expected: "first" }, aborted: false }, + { _tag: "Filter", annotations: { expected: "second" }, aborted: false } + ] + }] + }, + references: {} + }) + }) + + it("converts representation dependencies of built-in filters", () => { + const schema = Schema.Record(Schema.String, Schema.Number).check( + Schema.isPropertyNames(Schema.String.check(Schema.isPattern(/^[A-Z]/))) + ) + const representation = SchemaRepresentation.toRepresentation(schema.ast).representation + + assert.strictEqual(representation._tag, "Objects") + if (representation._tag !== "Objects") return + const check = representation.checks[0] + assert.strictEqual(check._tag, "Filter") + if (check._tag !== "Filter") return + const dependency = check.representation?.schemas?.[0] + assert.isDefined(dependency) + assert.deepStrictEqual( + SchemaRepresentation.toJson({ representation: dependency, references: {} }), + { + representation: { + _tag: "String", + checks: [{ + _tag: "Filter", + representation: { + id: "effect/schema/isPattern", + payload: { source: "^[A-Z]", flags: "" } + }, + annotations: { + arbitrary: { constraint: { patterns: ["^[A-Z]"] } }, + expected: "a string matching the RegExp ^[A-Z]" + }, + aborted: false + }] + }, + references: {} + } + ) + }) + + it("extracts shared representation dependencies of filters", () => { + const shared = Schema.Struct({ value: Schema.String }) + const filter = Schema.makeFilter(() => true, { + representation: { + id: "acme/schema/Custom", + payload: null, + schemas: [shared.ast, shared.ast] + } + }) + const document = SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast) + const representation = document.representation + + assert.strictEqual(representation._tag, "String") + if (representation._tag !== "String") return + assert.deepStrictEqual(representation.checks[0].representation?.schemas, [ + { _tag: "Reference", $ref: "Objects_" }, + { _tag: "Reference", $ref: "Objects_" } + ]) + assert.deepStrictEqual(Object.keys(document.references), ["Objects_"]) + }) + + it("converts checks on arrays", () => { + const representation = SchemaRepresentation.toRepresentation( + Schema.Array(Schema.String).check(Schema.isMinLength(1)).ast + ).representation + + assert.strictEqual(representation._tag, "Arrays") + if (representation._tag !== "Arrays") return + assert.strictEqual(representation.checks.length, 1) + assert.deepStrictEqual(representation.checks[0].representation, { + id: "effect/schema/isMinLength", + payload: { minLength: 1 } + }) + }) + + it("converts checks on objects", () => { + const representation = SchemaRepresentation.toRepresentation( + Schema.Record(Schema.String, Schema.Number).check(Schema.isMinProperties(1)).ast + ).representation + + assert.strictEqual(representation._tag, "Objects") + if (representation._tag !== "Objects") return + assert.strictEqual(representation.checks.length, 1) + assert.deepStrictEqual(representation.checks[0].representation, { + id: "effect/schema/isMinProperties", + payload: { minProperties: 1 } + }) + }) + + it("preserves fromJsonString annotations", () => { + const schema = SchemaAST.toEncoded(Schema.fromJsonString(Schema.Struct({ value: Schema.Number })).ast) + const document = SchemaRepresentation.toRepresentation(schema) + + assert.deepStrictEqual(document, { + representation: { + _tag: "String", + annotations: { + contentMediaType: "application/json", + expected: "a string that will be decoded as JSON" + }, + checks: [] + }, + references: {} + }) + }) + + it("preserves tuple element annotations", () => { + const marker = () => "element" + const schema = Schema.Tuple([Schema.String.annotateKey({ description: "element", marker })]) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "Arrays", + elements: [{ + type: { _tag: "String", checks: [] }, + isOptional: false, + annotations: { description: "element", marker } + }], + rest: [], + checks: [] + }, + references: {} + }) + }) + + it("preserves property annotations", () => { + const marker = () => "property" + const schema = Schema.Struct({ + value: Schema.String.annotateKey({ description: "property", marker }) + }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false, + annotations: { description: "property", marker } + }], + indexSignatures: [], + checks: [] + }, + references: {} + }) + }) + + it("extracts shared Objects, Arrays, and Union schemas into references", () => { + const object = Schema.Struct({ value: Schema.String }) + const array = Schema.Array(Schema.Number) + const union = Schema.Union([Schema.Struct({ value: Schema.String }), Schema.Null]) + const document = SchemaRepresentation.toRepresentation( + Schema.Tuple([object, object, array, array, union, union]).ast + ) + + assert.deepStrictEqual(document, { + representation: { + _tag: "Arrays", + elements: [ + { type: { _tag: "Reference", $ref: "Objects_" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Objects_" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Arrays_" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Arrays_" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Union_" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Union_" }, isOptional: false } + ], + rest: [], + checks: [] + }, + references: { + Objects_: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + }, + Arrays_: { + _tag: "Arrays", + elements: [], + rest: [{ _tag: "Number", checks: [] }], + checks: [] + }, + Union_: { + _tag: "Union", + types: [ + { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + }, + { _tag: "Null", checks: [] } + ], + mode: "anyOf", + checks: [] + } + } + }) + }) + + it("does not extract shared unions of leaf schemas", () => { + const union = Schema.Union([Schema.String, Schema.Number]) + const document = SchemaRepresentation.toRepresentation(Schema.Tuple([union, union]).ast) + + assert.deepStrictEqual(document.references, {}) + assert.strictEqual(document.representation._tag, "Arrays") + if (document.representation._tag === "Arrays") { + assert.deepStrictEqual(document.representation.elements.map((element) => element.type._tag), ["Union", "Union"]) + } + }) + + it("does not extract structurally equivalent schemas with distinct ASTs", () => { + const first = Schema.Struct({ value: Schema.String }) + const second = Schema.Struct({ value: Schema.String }) + const document = SchemaRepresentation.toRepresentation(Schema.Tuple([first, second]).ast) + + assert.deepStrictEqual(document.references, {}) + }) + + it("does not extract a child solely because its shared parent is reused", () => { + const child = Schema.Struct({ value: Schema.String }) + const parent = Schema.Struct({ child }) + const document = SchemaRepresentation.toRepresentation(Schema.Tuple([parent, parent]).ast) + + assert.deepStrictEqual(Object.keys(document.references), ["Objects_"]) + }) + + it("does not extract shared trivial or Suspend schemas", () => { + const suspend = Schema.suspend(() => Schema.String) + const document = SchemaRepresentation.toRepresentation( + Schema.Tuple([Schema.String, Schema.String, suspend, suspend]).ast + ) + + assert.deepStrictEqual(document.references, {}) + }) + + it("extracts a named schema into references", () => { + const schema = Schema.String.annotate({ identifier: "Value", description: "value" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { _tag: "Reference", $ref: "Value" }, + references: { + Value: { + _tag: "String", + annotations: { identifier: "Value", description: "value" }, + checks: [] + } + } + }) + }) + + it("converts a non-recursive suspend", () => { + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Schema.suspend(() => Schema.String).ast), { + representation: { + _tag: "Suspend", + thunk: { _tag: "String", checks: [] }, + checks: [] + }, + references: {} + }) + }) + + it("uses an outer identifier for a recursive schema", () => { + interface Node { + readonly next?: Node + } + const Node = Schema.Struct({ + next: Schema.optionalKey(Schema.suspend((): Schema.Codec => Node)) + }).annotate({ identifier: "Node" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(Node.ast), { + representation: { _tag: "Reference", $ref: "Node" }, + references: { + Node: { + _tag: "Objects", + annotations: { identifier: "Node" }, + propertySignatures: [{ + name: "next", + type: { + _tag: "Suspend", + thunk: { _tag: "Reference", $ref: "Node" }, + checks: [] + }, + isOptional: true, + isMutable: false + }], + indexSignatures: [], + checks: [] + } + } + }) + }) + + it("assigns distinct references to recursive schemas with duplicate identifiers", () => { + interface First { + readonly next?: First + } + const First = Schema.Struct({ + next: Schema.optionalKey(Schema.suspend((): Schema.Codec => First)) + }).annotate({ identifier: "Node" }) + interface Second { + readonly next?: Second + } + const Second = Schema.Struct({ + next: Schema.optionalKey(Schema.suspend((): Schema.Codec => Second)) + }).annotate({ identifier: "Node" }) + + const document = SchemaRepresentation.toRepresentation(Schema.Tuple([First, Second]).ast) + assert.deepStrictEqual(document.representation, { + _tag: "Arrays", + elements: [ + { type: { _tag: "Reference", $ref: "Node" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "Node1" }, isOptional: false } + ], + rest: [], + checks: [] + }) + assert.deepStrictEqual(Object.keys(document.references), ["Node", "Node1"]) + }) + + it("uses a fallback identifier for encoded representations", () => { + const schema = Schema.String.annotate({ "~identifier": "Person" }) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { _tag: "Reference", $ref: "PersonJsonEncoding" }, + references: { + PersonJsonEncoding: { + _tag: "String", + checks: [], + annotations: { "~identifier": "Person" } + } + } + }) + }) + + it("prefers an identifier over a fallback identifier", () => { + const schema = Schema.String.annotate({ identifier: "EncodedPerson", "~identifier": "Person" }) + const document = SchemaRepresentation.toRepresentation(schema.ast) + + assert.deepStrictEqual(document.representation, { _tag: "Reference", $ref: "EncodedPerson" }) + assert.deepStrictEqual(Object.keys(document.references), ["EncodedPerson"]) + }) + + it("reuses the class reference", () => { + class User extends Schema.Class("User")({ name: Schema.String }) {} + + const document = SchemaRepresentation.toRepresentation(Schema.Tuple([User, User]).ast) + assert.deepStrictEqual(document.representation, { + _tag: "Arrays", + elements: [ + { type: { _tag: "Reference", $ref: "User" }, isOptional: false }, + { type: { _tag: "Reference", $ref: "User" }, isOptional: false } + ], + rest: [], + checks: [] + }) + assert.deepStrictEqual(Object.keys(document.references), ["User"]) + }) + + it("extracts anonymous recursion into references", () => { + let schema: Schema.Codec + schema = Schema.suspend((): Schema.Codec => schema) + + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schema.ast), { + representation: { _tag: "Reference", $ref: "Suspend_" }, + references: { + Suspend_: { + _tag: "Suspend", + thunk: { _tag: "Reference", $ref: "Suspend_" }, + checks: [] + } + } + }) + }) +}) diff --git a/packages/effect/test/schema/representation/toRepresentations.test.ts b/packages/effect/test/schema/representation/toRepresentations.test.ts new file mode 100644 index 00000000000..d87ff191d2a --- /dev/null +++ b/packages/effect/test/schema/representation/toRepresentations.test.ts @@ -0,0 +1,110 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaRepresentation } from "effect" + +describe("SchemaRepresentation.toRepresentations", () => { + it("preserves root order", () => { + const document = SchemaRepresentation.toRepresentations([ + Schema.String.ast, + Schema.Number.ast, + Schema.Boolean.ast + ]) + + assert.deepStrictEqual(document, { + representations: [ + { _tag: "String", checks: [] }, + { _tag: "Number", checks: [] }, + { _tag: "Boolean", checks: [] } + ], + references: {} + }) + }) + + it("shares a named reference between roots", () => { + const shared = Schema.String.annotate({ identifier: "Shared" }) + const document = SchemaRepresentation.toRepresentations([shared.ast, shared.ast]) + + assert.deepStrictEqual(document, { + representations: [ + { _tag: "Reference", $ref: "Shared" }, + { _tag: "Reference", $ref: "Shared" } + ], + references: { + Shared: { + _tag: "String", + annotations: { identifier: "Shared" }, + checks: [] + } + } + }) + }) + + it("shares an anonymous non-trivial schema between roots", () => { + const shared = Schema.Struct({ value: Schema.String }) + const document = SchemaRepresentation.toRepresentations([shared.ast, shared.ast]) + + assert.deepStrictEqual(document, { + representations: [ + { _tag: "Reference", $ref: "Objects_" }, + { _tag: "Reference", $ref: "Objects_" } + ], + references: { + Objects_: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "String", checks: [] }, + isOptional: false, + isMutable: false + }], + indexSignatures: [], + checks: [] + } + } + }) + }) + + it("assigns distinct references to different schemas with the same identifier", () => { + const first = Schema.String.annotate({ identifier: "Value", description: "first" }) + const second = Schema.Number.annotate({ identifier: "Value", description: "second" }) + const document = SchemaRepresentation.toRepresentations([first.ast, second.ast]) + + assert.deepStrictEqual(document, { + representations: [ + { _tag: "Reference", $ref: "Value" }, + { _tag: "Reference", $ref: "Value1" } + ], + references: { + Value: { + _tag: "String", + annotations: { identifier: "Value", description: "first" }, + checks: [] + }, + Value1: { + _tag: "Number", + annotations: { identifier: "Value", description: "second" }, + checks: [] + } + } + }) + }) + + it("reuses a reference for equivalent schemas with the same identifier", () => { + const first = Schema.String.annotate({ identifier: "Value" }) + const second = Schema.String.annotate({ identifier: "Value" }) + const document = SchemaRepresentation.toRepresentations([first.ast, second.ast]) + + assert.deepStrictEqual(document, { + representations: [ + { _tag: "Reference", $ref: "Value" }, + { _tag: "Reference", $ref: "Value" } + ], + references: { + Value: { + _tag: "String", + annotations: { identifier: "Value" }, + checks: [] + } + } + }) + }) +}) diff --git a/packages/effect/test/schema/representation/toSchema.test.ts b/packages/effect/test/schema/representation/toSchema.test.ts deleted file mode 100644 index c935bcedf81..00000000000 --- a/packages/effect/test/schema/representation/toSchema.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { Redacted, Schema, SchemaRepresentation } from "effect" -import { describe, it } from "vitest" -import { deepStrictEqual, strictEqual } from "../../utils/assert.ts" - -describe("toSchema", () => { - function assertToSchemaRoundtrip(input: { - schema: Schema.Top - readonly reviver?: SchemaRepresentation.Reviver | undefined - }, runtime: string) { - const document = SchemaRepresentation.fromAST(input.schema.ast) - const roundtrip = SchemaRepresentation.fromAST( - SchemaRepresentation.toSchema(document, { reviver: input.reviver }).ast - ) - deepStrictEqual(roundtrip, document) - const codeDocument = SchemaRepresentation.toCodeDocument(SchemaRepresentation.toMultiDocument(roundtrip)) - strictEqual(codeDocument.codes[0].runtime, runtime) - } - - describe("String", () => { - it("String", () => { - assertToSchemaRoundtrip( - { schema: Schema.String }, - `Schema.String` - ) - }) - - it("String & check", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isMinLength(1)) }, - `Schema.String.check(Schema.isMinLength(1))` - ) - }) - - describe("checks", () => { - it("isTrimmed", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isTrimmed()) }, - `Schema.String.check(Schema.isTrimmed())` - ) - }) - - it("isULID", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isULID()) }, - `Schema.String.check(Schema.isULID())` - ) - }) - - it("isGUID", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isGUID()) }, - `Schema.String.check(Schema.isGUID())` - ) - }) - }) - }) - - it("Struct", () => { - assertToSchemaRoundtrip( - { schema: Schema.Struct({}) }, - `Schema.Struct({ })` - ) - assertToSchemaRoundtrip( - { schema: Schema.Struct({ a: Schema.String }) }, - `Schema.Struct({ "a": Schema.String })` - ) - assertToSchemaRoundtrip( - { schema: Schema.Struct({ [Symbol.for("a")]: Schema.String }) }, - `Schema.Struct({ [_symbol]: Schema.String })` - ) - assertToSchemaRoundtrip( - { schema: Schema.Struct({ a: Schema.optionalKey(Schema.String) }) }, - `Schema.Struct({ "a": Schema.optionalKey(Schema.String) })` - ) - assertToSchemaRoundtrip( - { schema: Schema.Struct({ a: Schema.mutableKey(Schema.String) }) }, - `Schema.Struct({ "a": Schema.mutableKey(Schema.String) })` - ) - assertToSchemaRoundtrip( - { schema: Schema.Struct({ a: Schema.optionalKey(Schema.mutableKey(Schema.String)) }) }, - `Schema.Struct({ "a": Schema.optionalKey(Schema.mutableKey(Schema.String)) })` - ) - }) - - it("Record", () => { - assertToSchemaRoundtrip( - { schema: Schema.Record(Schema.String, Schema.Number) }, - `Schema.Record(Schema.String, Schema.Number)` - ) - assertToSchemaRoundtrip( - { schema: Schema.Record(Schema.Symbol, Schema.Number) }, - `Schema.Record(Schema.Symbol, Schema.Number)` - ) - }) - - it("StructWithRest", () => { - assertToSchemaRoundtrip( - { - schema: Schema.StructWithRest(Schema.Struct({ a: Schema.Number }), [ - Schema.Record(Schema.String, Schema.Number) - ]) - }, - `Schema.StructWithRest(Schema.Struct({ "a": Schema.Number }), [Schema.Record(Schema.String, Schema.Number)])` - ) - }) - - it("Tuple", () => { - assertToSchemaRoundtrip( - { schema: Schema.Tuple([]) }, - `Schema.Tuple([])` - ) - assertToSchemaRoundtrip( - { schema: Schema.Tuple([Schema.String, Schema.Number]) }, - `Schema.Tuple([Schema.String, Schema.Number])` - ) - assertToSchemaRoundtrip( - { schema: Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)]) }, - `Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)])` - ) - }) - - it("Array", () => { - assertToSchemaRoundtrip( - { schema: Schema.Array(Schema.String) }, - `Schema.Array(Schema.String)` - ) - }) - - it("TupleWithRest", () => { - assertToSchemaRoundtrip( - { schema: Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number]) }, - `Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number])` - ) - assertToSchemaRoundtrip( - { schema: Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) }, - `Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean])` - ) - }) - - it("Suspend", () => { - type Category = { - readonly name: string - readonly children: ReadonlyArray - } - - const OuterCategory = Schema.Struct({ - name: Schema.String, - children: Schema.Array(Schema.suspend((): Schema.Codec => OuterCategory)) - }).annotate({ identifier: "Category" }) - - assertToSchemaRoundtrip( - { schema: OuterCategory }, - `Category` - ) - }) - - describe("brand", () => { - it("brand", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.pipe(Schema.brand("a")) }, - `Schema.String.pipe(Schema.brand("a"))` - ) - }) - - it("brand & brand", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.pipe(Schema.brand("a"), Schema.brand("b")) }, - `Schema.String.pipe(Schema.brand("a"), Schema.brand("b"))` - ) - }) - - it("check & brand", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("b")) }, - `Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("b"))` - ) - }) - - it("brand & check & brand", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.pipe(Schema.brand("a")).check(Schema.isMinLength(1)).pipe(Schema.brand("b")) }, - `Schema.String.pipe(Schema.brand("a")).check(Schema.isMinLength(1)).pipe(Schema.brand("b"))` - ) - }) - - it("check & brand & check", () => { - assertToSchemaRoundtrip( - { schema: Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("b")).check(Schema.isMaxLength(2)) }, - `Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("b")).check(Schema.isMaxLength(2))` - ) - }) - }) - - describe("toSchemaDefaultReviver", () => { - function assertToSchemaWithReviver(schema: Schema.Top, runtime: string) { - assertToSchemaRoundtrip({ schema, reviver: SchemaRepresentation.toSchemaDefaultReviver }, runtime) - } - - it("Option", () => { - assertToSchemaWithReviver( - Schema.Option(Schema.String), - `Schema.Option(Schema.String)` - ) - assertToSchemaWithReviver( - Schema.Option(Schema.URL), - `Schema.Option(Schema.URL)` - ) - }) - - it("Result", () => { - assertToSchemaWithReviver( - Schema.Result(Schema.String, Schema.Number), - `Schema.Result(Schema.String, Schema.Number)` - ) - }) - - it("Json", () => { - assertToSchemaWithReviver( - Schema.Json, - `Schema.Json` - ) - }) - - it("MutableJson", () => { - assertToSchemaWithReviver( - Schema.MutableJson, - `Schema.MutableJson` - ) - }) - - it("Redacted", () => { - assertToSchemaWithReviver( - Schema.Redacted(Schema.String), - `Schema.Redacted(Schema.String)` - ) - }) - - it("Redacted options", () => { - const schema = Schema.Redacted(Schema.String, { - label: "password", - disallowJsonEncode: true - }) - const document = SchemaRepresentation.fromAST(schema.ast) - const roundtrip = SchemaRepresentation.toSchema(document, { - reviver: SchemaRepresentation.toSchemaDefaultReviver - }) - const encode = Schema.encodeUnknownExit(Schema.toCodecJson(roundtrip)) - - strictEqual( - String(encode(Redacted.make("secret", { label: "password" }))), - `Failure(Cause([Fail(SchemaError(Cannot serialize Redacted with label: "password"))]))` - ) - strictEqual( - String(encode(Redacted.make("secret", { label: "other" }))), - `Failure(Cause([Fail(SchemaError(Expected "password", got "other" - at ["label"]))]))` - ) - }) - - it("CauseReason", () => { - assertToSchemaWithReviver( - Schema.CauseReason(Schema.String, Schema.Number), - `Schema.CauseReason(Schema.String, Schema.Number)` - ) - }) - - it("Cause", () => { - assertToSchemaWithReviver( - Schema.Cause(Schema.String, Schema.Number), - `Schema.Cause(Schema.String, Schema.Number)` - ) - }) - - it("Exit", () => { - assertToSchemaWithReviver( - Schema.Exit(Schema.String, Schema.Number, Schema.Boolean), - `Schema.Exit(Schema.String, Schema.Number, Schema.Boolean)` - ) - }) - - it("ReadonlyMap", () => { - assertToSchemaWithReviver( - Schema.ReadonlyMap(Schema.String, Schema.Number), - `Schema.ReadonlyMap(Schema.String, Schema.Number)` - ) - }) - - it("HashMap", () => { - assertToSchemaWithReviver( - Schema.HashMap(Schema.String, Schema.Number), - `Schema.HashMap(Schema.String, Schema.Number)` - ) - }) - - it("Chunk", () => { - assertToSchemaWithReviver( - Schema.Chunk(Schema.String), - `Schema.Chunk(Schema.String)` - ) - }) - - it("ReadonlySet", () => { - assertToSchemaWithReviver( - Schema.ReadonlySet(Schema.String), - `Schema.ReadonlySet(Schema.String)` - ) - }) - - it("RegExp", () => { - assertToSchemaWithReviver( - Schema.RegExp, - `Schema.RegExp` - ) - }) - - it("URL", () => { - assertToSchemaWithReviver( - Schema.URL, - `Schema.URL` - ) - }) - - describe("Date", () => { - it("Date", () => { - assertToSchemaWithReviver( - Schema.Date, - `Schema.Date` - ) - }) - - describe("checks", () => { - it("isDateValid", () => { - assertToSchemaWithReviver( - Schema.Date.check(Schema.isDateValid()), - `Schema.Date.check(Schema.isDateValid())` - ) - }) - - it("isGreaterThanDate", () => { - assertToSchemaWithReviver( - Schema.Date.check(Schema.isGreaterThanDate(new Date(0))), - `Schema.Date.check(Schema.isGreaterThanDate(new Date(0)))` - ) - }) - - it("isGreaterThanOrEqualToDate", () => { - assertToSchemaWithReviver( - Schema.Date.check(Schema.isGreaterThanOrEqualToDate(new Date(0))), - `Schema.Date.check(Schema.isGreaterThanOrEqualToDate(new Date(0)))` - ) - }) - - it("isLessThanDate", () => { - assertToSchemaWithReviver( - Schema.Date.check(Schema.isLessThanDate(new Date(0))), - `Schema.Date.check(Schema.isLessThanDate(new Date(0)))` - ) - }) - - it("isLessThanOrEqualToDate", () => { - assertToSchemaWithReviver( - Schema.Date.check(Schema.isLessThanOrEqualToDate(new Date(0))), - `Schema.Date.check(Schema.isLessThanOrEqualToDate(new Date(0)))` - ) - }) - }) - }) - - it("Duration", () => { - assertToSchemaWithReviver( - Schema.Duration, - `Schema.Duration` - ) - }) - - it("FormData", () => { - assertToSchemaWithReviver( - Schema.FormData, - `Schema.FormData` - ) - }) - - it("URLSearchParams", () => { - assertToSchemaWithReviver( - Schema.URLSearchParams, - `Schema.URLSearchParams` - ) - }) - - it("Uint8Array", () => { - assertToSchemaWithReviver( - Schema.Uint8Array, - `Schema.Uint8Array` - ) - }) - - it("DateTime.Utc", () => { - assertToSchemaWithReviver( - Schema.DateTimeUtc, - `Schema.DateTimeUtc` - ) - }) - - it("Error", () => { - assertToSchemaWithReviver( - Schema.Error(), - `Schema.Error()` - ) - }) - - it("Error with stack", () => { - assertToSchemaWithReviver( - Schema.Error({ includeStack: true }), - `Schema.Error({"includeStack":true})` - ) - }) - - it("Error with excluded cause", () => { - assertToSchemaWithReviver( - Schema.Error({ excludeCause: true }), - `Schema.Error({"excludeCause":true})` - ) - }) - - it("Defect", () => { - assertToSchemaWithReviver( - Schema.Defect(), - `Schema.Json` - ) - }) - - it("HashSet", () => { - assertToSchemaWithReviver( - Schema.HashSet(Schema.String), - `Schema.HashSet(Schema.String)` - ) - }) - - it("BigDecimal", () => { - assertToSchemaWithReviver( - Schema.BigDecimal, - `Schema.BigDecimal` - ) - }) - - it("TimeZoneOffset", () => { - assertToSchemaWithReviver( - Schema.TimeZoneOffset, - `Schema.TimeZoneOffset` - ) - }) - - it("TimeZoneNamed", () => { - assertToSchemaWithReviver( - Schema.TimeZoneNamed, - `Schema.TimeZoneNamed` - ) - }) - - it("TimeZone", () => { - assertToSchemaWithReviver( - Schema.TimeZone, - `Schema.TimeZone` - ) - }) - - it("DateTimeZoned", () => { - assertToSchemaWithReviver( - Schema.DateTimeZoned, - `Schema.DateTimeZoned` - ) - }) - - describe("ReadonlySet", () => { - it("ReadonlySet(String)", () => { - assertToSchemaWithReviver( - Schema.ReadonlySet(Schema.String), - `Schema.ReadonlySet(Schema.String)` - ) - }) - - describe("checks", () => { - it("isMinSize", () => { - assertToSchemaWithReviver( - Schema.ReadonlySet(Schema.String).check(Schema.isMinSize(2)), - `Schema.ReadonlySet(Schema.String).check(Schema.isMinSize(2))` - ) - }) - - it("isMaxSize", () => { - assertToSchemaWithReviver( - Schema.ReadonlySet(Schema.String).check(Schema.isMaxSize(2)), - `Schema.ReadonlySet(Schema.String).check(Schema.isMaxSize(2))` - ) - }) - }) - - it("isSizeBetween", () => { - assertToSchemaWithReviver( - Schema.ReadonlySet(Schema.String).check(Schema.isSizeBetween(2, 2)), - `Schema.ReadonlySet(Schema.String).check(Schema.isSizeBetween(2, 2))` - ) - }) - }) - }) -}) diff --git a/packages/effect/test/schema/toArbitrary.test.ts b/packages/effect/test/schema/toArbitrary.test.ts index c55745aa943..0a366045db2 100644 --- a/packages/effect/test/schema/toArbitrary.test.ts +++ b/packages/effect/test/schema/toArbitrary.test.ts @@ -336,7 +336,7 @@ describe("Arbitrary generation", () => { FastCheck.assert( FastCheck.property(Schema.toArbitrary(schema), (o) => globalThis.Reflect.ownKeys(o).length >= 2 && - globalThis.Object.prototype.hasOwnProperty.call(o, key)), + globalThis.Object.hasOwn(o, key)), { numRuns: 100 } ) verifyGeneration(schema) @@ -989,7 +989,7 @@ describe("Arbitrary generation", () => { }).check(Schema.isMinProperties(1)) assertInvariant( schema, - (o) => globalThis.Object.keys(o).length >= 1 && globalThis.Object.prototype.hasOwnProperty.call(o, "a") + (o) => globalThis.Object.keys(o).length >= 1 && globalThis.Object.hasOwn(o, "a") ) }) diff --git a/packages/effect/test/schema/toCodec.test.ts b/packages/effect/test/schema/toCodec.test.ts index 8db97cdda5e..5b29f615f98 100644 --- a/packages/effect/test/schema/toCodec.test.ts +++ b/packages/effect/test/schema/toCodec.test.ts @@ -8,6 +8,7 @@ import { Redacted, Result, Schema, + SchemaAST, SchemaGetter, SchemaIssue, SchemaParser, @@ -18,6 +19,7 @@ import { describe, it } from "vitest" import { assertTrue, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" const isDeno = "Deno" in globalThis +const resolveIdentifierFallback = SchemaAST.resolveAt("~identifier") const FiniteFromDate = Schema.Date.pipe(Schema.decodeTo( Schema.Number, @@ -35,6 +37,68 @@ describe("Serializers", () => { strictEqual(serializer.schema, schema) }) + it("treats Json as canonical", () => { + strictEqual(Schema.toCodecJson(Schema.Json).ast, Schema.Json.ast) + strictEqual(Schema.toCodecJson(Schema.MutableJson).ast, Schema.MutableJson.ast) + }) + + describe("identifier preservation", () => { + it("annotates a new canonical encoding with the effective type-side identifier", () => { + const schema = Schema.Number.check(Schema.isGreaterThan(0)).annotate({ identifier: "Positive" }) + const encoded = SchemaAST.getLastEncoding(Schema.toCodecJson(schema).ast) + + strictEqual(resolveIdentifierFallback(encoded), "Positive") + }) + + it("annotates an existing canonical encoding", () => { + const schema = Schema.FiniteFromString.annotate({ identifier: "Finite" }) + const encoded = SchemaAST.getLastEncoding(Schema.toCodecJson(schema).ast) + + strictEqual(resolveIdentifierFallback(encoded), "Finite") + }) + + it("does not overwrite an encoded-side identifier", () => { + const schema = Schema.FiniteFromString.pipe( + Schema.annotateEncoded({ identifier: "EncodedFinite" }), + Schema.annotate({ identifier: "Finite" }) + ) + const encoded = SchemaAST.getLastEncoding(Schema.toCodecJson(schema).ast) + + strictEqual(SchemaAST.resolveIdentifier(encoded), "EncodedFinite") + strictEqual(resolveIdentifierFallback(encoded), undefined) + }) + + it("preserves the AST when the fallback identifier is unchanged", () => { + const schema = Schema.FiniteFromString.pipe( + Schema.annotateEncoded({ "~identifier": "Finite" }), + Schema.annotate({ identifier: "Finite" }) + ) + + strictEqual(Schema.toCodecJson(schema).ast, schema.ast) + }) + + it("overwrites a different fallback identifier", () => { + const schema = Schema.FiniteFromString.pipe( + Schema.annotateEncoded({ "~identifier": "Previous" }), + Schema.annotate({ identifier: "Finite" }) + ) + const encoded = SchemaAST.getLastEncoding(Schema.toCodecJson(schema).ast) + + strictEqual(resolveIdentifierFallback(encoded), "Finite") + }) + + it("propagates identifiers recursively", () => { + class Person extends Schema.Class("Person")({ name: Schema.String }) {} + const ast = Schema.toCodecJson(Schema.Struct({ person: Person })).ast + + strictEqual(ast._tag, "Objects") + if (ast._tag === "Objects") { + const encoded = SchemaAST.getLastEncoding(ast.propertySignatures[0].type) + strictEqual(resolveIdentifierFallback(encoded), "Person") + } + }) + }) + it("should reorder the types in the Union based on the encoded side", async () => { const schema = Schema.Union([ Schema.String, @@ -69,11 +133,11 @@ describe("Serializers", () => { const schema = Schema.instanceOf(URL) const asserts = new TestSchema.Asserts(Schema.toCodecJson(schema)) - const encoding = asserts.encoding() - await encoding.succeed(new URL("https://effect.website"), null) - - const decoding = asserts.decoding() - await decoding.fail("https://effect.website/", `Expected null, got "https://effect.website/"`) + await asserts.encoding().fail( + new URL("https://example.com"), + "Expected JSON value, got https://example.com/" + ) + await asserts.decoding().fail({}, "Expected , got {}") }) describe("instanceOf with annotation", () => { @@ -261,13 +325,13 @@ describe("Serializers", () => { await encoding.succeed({ a: "a", b: 1, c: true }) await encoding.succeed(["a", 1, true]) await encoding.fail("a", `Expected object | array | function, got "a"`) - await encoding.fail({ a: 1n }, `Expected JSON value, got {"a":1n}`) + await encoding.fail({ a: 1n }, `Expected JSON value, got 1n\n at ["a"]`) const decoding = asserts.decoding() await decoding.succeed({ a: "a", b: 1, c: true }) await decoding.succeed(["a", 1, true]) - await decoding.fail("a", `Expected object | array | function, got "a"`) - await decoding.fail({ a: 1n }, `Expected JSON value, got {"a":1n}`) + await decoding.fail("a", `Expected array | object, got "a"`) + await decoding.fail({ a: 1n }, `Expected JSON value, got 1n\n at ["a"]`) }) it("Undefined", async () => { @@ -306,6 +370,37 @@ describe("Serializers", () => { }) describe("Number", () => { + it("reuses the Finite AST in the canonical encoding", () => { + const encoded = SchemaAST.getLastEncoding(Schema.toCodecJson(Schema.Number).ast) + strictEqual(encoded._tag, "Union") + if (encoded._tag === "Union") { + strictEqual(encoded.types[0], SchemaAST.finite) + strictEqual(encoded.types[0], Schema.Finite.ast) + } + }) + + it("does not propagate constructor defaults to the canonical encoding", () => { + const schema = Schema.Struct({ + a: Schema.Number.pipe( + Schema.optionalKey, + Schema.mutableKey, + Schema.annotateKey({ description: "a" }), + Schema.withConstructorDefault(Effect.succeed(0)) + ) + }) + const ast = Schema.toCodecJson(schema).ast + strictEqual(ast._tag, "Objects") + if (ast._tag === "Objects") { + const type = ast.propertySignatures[0].type + assertTrue(type.context?.defaultValue !== undefined) + const encoded = SchemaAST.getLastEncoding(type) + strictEqual(encoded.context?.isOptional, true) + strictEqual(encoded.context?.isMutable, true) + strictEqual(encoded.context?.defaultValue, undefined) + deepStrictEqual(encoded.context?.annotations, { description: "a" }) + } + }) + it("Number", async () => { const schema = Schema.Number const asserts = new TestSchema.Asserts(Schema.toCodecJson(schema)) @@ -325,9 +420,9 @@ describe("Serializers", () => { await decoding.succeed("Infinity", Infinity) await decoding.succeed("-Infinity", -Infinity) await decoding.succeed("NaN", NaN) - await decoding.succeed(Infinity) - await decoding.succeed(-Infinity) - await decoding.succeed(NaN) + await decoding.fail(Infinity, "Expected a finite number, got Infinity") + await decoding.fail(-Infinity, "Expected a finite number, got -Infinity") + await decoding.fail(NaN, "Expected a finite number, got NaN") await decoding.fail(null, `Expected number | "Infinity" | "-Infinity" | "NaN", got null`) await decoding.fail("a", `Expected "Infinity" | "-Infinity" | "NaN", got "a"`) }) @@ -404,9 +499,9 @@ describe("Serializers", () => { await decoding.succeed("Infinity", Infinity) await decoding.fail("-Infinity", `Expected a value greater than or equal to 1, got -Infinity`) await decoding.fail("NaN", `Expected a value greater than or equal to 1, got NaN`) - await decoding.succeed(Infinity) - await decoding.fail(-Infinity, `Expected a value greater than or equal to 1, got -Infinity`) - await decoding.fail(NaN, `Expected a value greater than or equal to 1, got NaN`) + await decoding.fail(Infinity, "Expected a finite number, got Infinity") + await decoding.fail(-Infinity, "Expected a finite number, got -Infinity") + await decoding.fail(NaN, "Expected a finite number, got NaN") await decoding.fail(null, `Expected number | "Infinity" | "-Infinity" | "NaN", got null`) await decoding.fail("a", `Expected "Infinity" | "-Infinity" | "NaN", got "a"`) }) @@ -688,6 +783,17 @@ describe("Serializers", () => { ) }) + it("Struct with an explicitly encoded Symbol property name", async () => { + const field = Symbol.for("field") + const schema = Schema.Struct({ + [field]: Schema.String + }).pipe(Schema.encodeKeys({ [field]: "field" })) + const asserts = new TestSchema.Asserts(Schema.toCodecJson(schema)) + + await asserts.encoding().succeed({ [field]: "a" }, { field: "a" }) + await asserts.decoding().succeed({ field: "a" }, { [field]: "a" }) + }) + describe("Tuple", () => { it("Date", async () => { const schema = Schema.Tuple([Schema.Date]) @@ -1573,6 +1679,31 @@ describe("Serializers", () => { }) }) + describe("toCodecIso", () => { + it("does not propagate constructor defaults to the canonical encoding", () => { + const schema = Schema.Struct({ + a: Schema.URL.pipe( + Schema.overrideToCodecIso(Schema.String, SchemaTransformation.urlFromString), + Schema.optionalKey, + Schema.mutableKey, + Schema.annotateKey({ description: "a" }), + Schema.withConstructorDefault(Effect.succeed(new URL("https://example.com"))) + ) + }) + const ast = Schema.toCodecIso(schema).ast + strictEqual(ast._tag, "Objects") + if (ast._tag === "Objects") { + const type = ast.propertySignatures[0].type + assertTrue(type.context?.defaultValue !== undefined) + const encoded = SchemaAST.getLastEncoding(type) + strictEqual(encoded.context?.isOptional, true) + strictEqual(encoded.context?.isMutable, true) + strictEqual(encoded.context?.defaultValue, undefined) + deepStrictEqual(encoded.context?.annotations, { description: "a" }) + } + }) + }) + describe("toCodecStringTree", () => { it("exposes the source schema", () => { const schema = Schema.FiniteFromString @@ -1580,6 +1711,28 @@ describe("Serializers", () => { strictEqual(serializer.schema, schema) }) + it("does not propagate constructor defaults to the canonical encoding", () => { + const schema = Schema.Struct({ + a: Schema.Number.pipe( + Schema.optionalKey, + Schema.mutableKey, + Schema.annotateKey({ description: "a" }), + Schema.withConstructorDefault(Effect.succeed(0)) + ) + }) + const ast = Schema.toCodecStringTree(schema).ast + strictEqual(ast._tag, "Objects") + if (ast._tag === "Objects") { + const type = ast.propertySignatures[0].type + assertTrue(type.context?.defaultValue !== undefined) + const encoded = SchemaAST.getLastEncoding(type) + strictEqual(encoded.context?.isOptional, true) + strictEqual(encoded.context?.isMutable, true) + strictEqual(encoded.context?.defaultValue, undefined) + deepStrictEqual(encoded.context?.annotations, { description: "a" }) + } + }) + it("should reorder the types in the Union based on the encoded side", async () => { const schema = Schema.Union([ Schema.String, @@ -1625,6 +1778,11 @@ describe("Serializers", () => { const serializer = Schema.toCodecStringTree(schema) strictEqual(serializer.ast, Schema.toCodecStringTree(serializer).ast) }) + + it("Unknown", () => { + const serializer = Schema.toCodecStringTree(Schema.Unknown) + strictEqual(serializer.ast, Schema.toCodecStringTree(serializer).ast) + }) }) describe("schemas without encoding", () => { @@ -1641,15 +1799,31 @@ describe("Serializers", () => { }) }) - it("Declaration", async () => { + it("Declaration", () => { const schema = Schema.instanceOf(URL) - const asserts = new TestSchema.Asserts(Schema.toCodecStringTree(schema)) + throws( + () => Schema.toCodecStringTree(schema), + "Missing structural codec for StringTree" + ) + }) + + it("Json", async () => { + const asserts = new TestSchema.Asserts(Schema.toCodecStringTree(Schema.Json)) const encoding = asserts.encoding() - await encoding.succeed(new URL("https://effect.website"), undefined) + await encoding.succeed("a") + await encoding.succeed(["a"]) + await encoding.succeed({ a: "a" }) + await encoding.fail(1, `Expected StringTree, got 1`) + await encoding.fail(true, `Expected StringTree, got true`) + await encoding.fail(null, `Expected StringTree, got null`) + await encoding.fail({ a: 1 }, `Expected StringTree, got {"a":1}`) const decoding = asserts.decoding() - await decoding.fail("https://effect.website/", `Expected undefined, got "https://effect.website/"`) + await decoding.succeed("a") + await decoding.succeed(["a"]) + await decoding.succeed({ a: "a" }) + await decoding.fail(undefined, `Expected JSON value, got undefined`) }) it("Unknown", async () => { @@ -2153,6 +2327,17 @@ Expected "Infinity" | "-Infinity" | "NaN", got "a"` ) }) + it("Struct with an explicitly encoded Symbol property name", async () => { + const field = Symbol.for("field") + const schema = Schema.Struct({ + [field]: Schema.String + }).pipe(Schema.encodeKeys({ [field]: "field" })) + const asserts = new TestSchema.Asserts(Schema.toCodecStringTree(schema)) + + await asserts.encoding().succeed({ [field]: "a" }, { field: "a" }) + await asserts.decoding().succeed({ field: "a" }, { [field]: "a" }) + }) + describe("Tuple", () => { it("Date", async () => { const schema = Schema.Tuple([Schema.Date]) @@ -2407,12 +2592,10 @@ Expected "Infinity" | "-Infinity" | "NaN", got "a"` ) const decoding = asserts.decoding() - // Error: message only await decoding.succeed( { message: "a" }, new Error("a") ) - // Error: message and name await decoding.succeed( { name: "b", message: "a" }, (() => { @@ -2421,7 +2604,6 @@ Expected "Infinity" | "-Infinity" | "NaN", got "a"` return err })() ) - // Error: message, name, and stack await decoding.succeed( { name: "b", message: "a", stack: "c" }, (() => { @@ -2696,16 +2878,19 @@ Expected "Infinity" | "-Infinity" | "NaN", got "a"` } describe("Schemas without annotations", () => { - it("Declaration", async () => { - await assertXml(Schema.instanceOf(URL), new URL("https://effect.website"), "") + it("Declaration", () => { + throws( + () => Schema.toEncoderXml(Schema.instanceOf(URL)), + "Missing structural codec for StringTree" + ) }) it("Unknown", async () => { - await assertXml(Schema.Unknown, "value", "") + await assertXml(Schema.Unknown, "value", "value") }) it("ObjectKeyword", async () => { - await assertXml(Schema.ObjectKeyword, { a: "value" }, "") + await assertXml(Schema.ObjectKeyword, { a: "value" }, "\n value\n") }) }) diff --git a/packages/effect/test/schema/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/toJsonSchemaDocument.test.ts index dc8e145d8c9..510a7a4dba4 100644 --- a/packages/effect/test/schema/toJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/toJsonSchemaDocument.test.ts @@ -57,7 +57,7 @@ describe("toJsonSchemaDocument", () => { it("Tuple: unsupported post-rest elements", () => { assertUnsupportedSchema( Schema.TupleWithRest(Schema.Tuple([]), [Schema.Finite, Schema.String]), - "Generating a JSON Schema for post-rest elements is not supported" + `Invalid schema representation document\n at ["representation"]["rest"]` ) }) @@ -65,18 +65,96 @@ describe("toJsonSchemaDocument", () => { const a = Symbol.for("effect/Schema/test/a") assertUnsupportedSchema( Schema.Struct({ [a]: Schema.String }), - `Unsupported property signature name: Symbol(effect/Schema/test/a)` + "Objects property names must be strings" ) }) + }) - it("Record: unsupported index signature parameter", () => { - assertUnsupportedSchema( - Schema.Record(Schema.Symbol, Schema.Finite), - `Unsupported index signature parameter: Symbol` - ) + it("Record(Symbol, Finite)", () => { + assertJsonSchemaDocument(Schema.Record(Schema.Symbol, Schema.Finite), { + schema: { + type: "object", + patternProperties: { + "^Symbol\\((.*)\\)$": { type: "number" } + } + } }) }) + it("emits built-in JSON Schema annotations", () => { + assertJsonSchemaDocument( + Schema.String.annotate({ + description: "encoded payload", + contentMediaType: "application/json", + contentSchema: { type: "number" } + }), + { + schema: { + type: "string", + description: "encoded payload", + contentMediaType: "application/json", + contentSchema: { type: "number" } + } + } + ) + }) + + it("preserves shared non-trivial schemas with references", () => { + const shared = Schema.Struct({ value: Schema.String }) + + assertJsonSchemaDocument( + Schema.Struct({ left: shared, right: shared }), + { + schema: { + type: "object", + properties: { + left: { $ref: "#/$defs/Objects_" }, + right: { $ref: "#/$defs/Objects_" } + }, + required: ["left", "right"], + additionalProperties: false + }, + definitions: { + Objects_: { + type: "object", + properties: { + value: { type: "string" } + }, + required: ["value"], + additionalProperties: false + } + } + } + ) + }) + + it("inlines shared canonical unions of leaf schemas", () => { + assertJsonSchemaDocument( + Schema.Struct({ left: Schema.Number, right: Schema.Number }), + { + schema: { + type: "object", + properties: { + left: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ] + }, + right: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ] + } + }, + required: ["left", "right"], + additionalProperties: false + } + } + ) + }) + describe("options", () => { it("generateDescriptions: true", () => { assertJsonSchemaDocument( @@ -237,31 +315,6 @@ describe("toJsonSchemaDocument", () => { ) }) - it("does not overwrite generated contentSchema with the raw annotation", () => { - assertJsonSchemaDocument( - Schema.fromJsonString(Schema.Struct({ - a: Schema.String - })), - { - schema: { - "type": "string", - "contentMediaType": "application/json", - "contentSchema": { - "type": "object", - "properties": { - "a": { - "type": "string" - } - }, - "required": ["a"], - "additionalProperties": false - } - } - }, - { includeAnnotationKey: (key) => key === "contentSchema" } - ) - }) - it("passthroughs at property level in structs", () => { const schema = Schema.Struct({ name: Schema.String.annotate({ @@ -483,6 +536,12 @@ describe("toJsonSchemaDocument", () => { }) describe("Declaration", () => { + it("opaque Declaration", () => { + assertJsonSchemaDocument(Schema.instanceOf(URL), { + schema: {} + }) + }) + it("Date", () => { const schema = Schema.Date assertJsonSchemaDocument(schema, { @@ -496,10 +555,7 @@ describe("toJsonSchemaDocument", () => { const schema = Schema.DateValid assertJsonSchemaDocument(schema, { schema: { - "type": "string", - "allOf": [ - { "format": "date-time" } - ] + "type": "string" } }) }) @@ -704,9 +760,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument( schema.annotate({ description: "a" }), { - schema: { - "description": "a" - } + schema: {} } ) }) @@ -725,8 +779,7 @@ describe("toJsonSchemaDocument", () => { schema.annotate({ description: "a" }), { schema: { - "type": "null", - "description": "a" + "type": "null" } } ) @@ -746,8 +799,7 @@ describe("toJsonSchemaDocument", () => { schema.annotate({ description: "a" }), { schema: { - "type": "null", - "description": "a" + "type": "null" } } ) @@ -1373,9 +1425,7 @@ describe("toJsonSchemaDocument", () => { schema: { "anyOf": [ { "type": "number" }, - { "type": "string", "enum": ["NaN"] }, - { "type": "string", "enum": ["Infinity"] }, - { "type": "string", "enum": ["-Infinity"] } + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } ] } } @@ -1386,11 +1436,32 @@ describe("toJsonSchemaDocument", () => { schema: { "anyOf": [ { "type": "number" }, - { "type": "string", "enum": ["NaN"] }, - { "type": "string", "enum": ["Infinity"] }, - { "type": "string", "enum": ["-Infinity"] } - ], - "description": "a" + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } + ] + } + } + ) + }) + + it("Number & annotateKey", () => { + assertJsonSchemaDocument( + Schema.Struct({ + value: Schema.Number.annotateKey({ description: "the field" }) + }), + { + schema: { + type: "object", + properties: { + value: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ], + allOf: [{ description: "the field" }] + } + }, + required: ["value"], + additionalProperties: false } } ) @@ -1639,7 +1710,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument( schema, { - schema: { anyOf: [{ type: "object" }, { type: "array" }] } + schema: { anyOf: [{ type: "array" }, { type: "object" }] } } ) assertJsonSchemaDocument( @@ -1647,10 +1718,9 @@ describe("toJsonSchemaDocument", () => { { schema: { "anyOf": [ - { "type": "object" }, - { "type": "array" } - ], - "description": "a" + { "type": "array" }, + { "type": "object" } + ] } } ) @@ -1742,8 +1812,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "enum": ["1"], - "description": "a" + "enum": ["1"] } } ) @@ -3543,16 +3612,13 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "contentMediaType": "application/json", - "contentSchema": { - "type": "string" - } + "contentMediaType": "application/json" } } ) }) - it("preserves the content schema identifier", () => { + it("preserves the content schema identifier as a canonical reference", () => { const MyEvent = Schema.Struct({ value: Schema.String }).annotate({ identifier: "MyEvent" }) @@ -3561,27 +3627,12 @@ describe("toJsonSchemaDocument", () => { Schema.fromJsonString(MyEvent), { schema: { - "$ref": "#/$defs/MyEventJsonString" + "$ref": "#/$defs/MyEventJsonEncoding" }, definitions: { - "MyEvent": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - }, - "required": [ - "value" - ], - "additionalProperties": false - }, - "MyEventJsonString": { + "MyEventJsonEncoding": { "type": "string", - "contentMediaType": "application/json", - "contentSchema": { - "$ref": "#/$defs/MyEvent" - } + "contentMediaType": "application/json" } } } @@ -3603,54 +3654,9 @@ describe("toJsonSchemaDocument", () => { "$ref": "#/$defs/MyWireEvent" }, definitions: { - "MyEvent": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - }, - "required": [ - "value" - ], - "additionalProperties": false - }, "MyWireEvent": { "type": "string", - "contentMediaType": "application/json", - "contentSchema": { - "$ref": "#/$defs/MyEvent" - } - } - } - } - ) - }) - - it("nested fromJsonString", () => { - assertJsonSchemaDocument( - Schema.fromJsonString(Schema.Struct({ - a: Schema.fromJsonString(Schema.FiniteFromString) - })), - { - schema: { - "type": "string", - "contentMediaType": "application/json", - "contentSchema": { - "additionalProperties": false, - "properties": { - "a": { - "contentMediaType": "application/json", - "contentSchema": { - "type": "string" - }, - "type": "string" - } - }, - "required": [ - "a" - ], - "type": "object" + "contentMediaType": "application/json" } } } @@ -3658,7 +3664,7 @@ describe("toJsonSchemaDocument", () => { }) }) - it("Class", () => { + it("Class preserves its identifier as a canonical reference", () => { class A extends Schema.Class("A")({ a: Schema.String }) {} @@ -3666,10 +3672,10 @@ describe("toJsonSchemaDocument", () => { A, { schema: { - "$ref": "#/$defs/A" + "$ref": "#/$defs/AJsonEncoding" }, definitions: { - A: { + "AJsonEncoding": { "type": "object", "properties": { "a": { "type": "string" } @@ -3678,20 +3684,21 @@ describe("toJsonSchemaDocument", () => { "additionalProperties": false } } - } + }, + { includeAnnotationKey: () => true } ) }) - it("ErrorClass", () => { + it("ErrorClass preserves its identifier as a canonical reference", () => { class E extends Schema.ErrorClass("E")({ a: Schema.String }) {} assertJsonSchemaDocument(E, { schema: { - "$ref": "#/$defs/E" + "$ref": "#/$defs/EJsonEncoding" }, definitions: { - E: { + "EJsonEncoding": { "type": "object", "properties": { "a": { "type": "string" } diff --git a/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts b/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts index 85be5842f15..3f7e3c8fcf5 100644 --- a/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts +++ b/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts @@ -146,9 +146,7 @@ describe("toCodecAnthropic", () => { assertJsonSchema(Schema.Number, { "anyOf": [ { "type": "number" }, - { "type": "string", "enum": ["NaN"] }, - { "type": "string", "enum": ["Infinity"] }, - { "type": "string", "enum": ["-Infinity"] } + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } ] }) }) @@ -477,7 +475,7 @@ describe("toCodecAnthropic", () => { "required": ["name"], "additionalProperties": false, "$defs": { - "Person": { + "PersonJsonEncoding": { "type": "object", "properties": { "name": { "type": "string" } diff --git a/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts b/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts new file mode 100644 index 00000000000..912d7ce5951 --- /dev/null +++ b/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { toCodecAnthropic } from "effect/unstable/ai/AnthropicStructuredOutput" + +describe("AnthropicStructuredOutput representation v2", () => { + it("projects the encoded side before JSON Schema generation", () => { + assert.strictEqual(toCodecAnthropic(Schema.FiniteFromString).jsonSchema.type, "string") + }) + + it("uses custom JSON Schema compiler annotations", () => { + const schema = Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/ai/anthropic/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + + assert.deepStrictEqual(toCodecAnthropic(schema).jsonSchema, { + type: "string", + allOf: [{ minLength: 2 }] + }) + }) +}) diff --git a/packages/effect/test/unstable/ai/LanguageModel.test.ts b/packages/effect/test/unstable/ai/LanguageModel.test.ts index 7aaf55b9e66..b8587b61d70 100644 --- a/packages/effect/test/unstable/ai/LanguageModel.test.ts +++ b/packages/effect/test/unstable/ai/LanguageModel.test.ts @@ -179,7 +179,7 @@ describe("LanguageModel", () => { } })) - it("resolves top-level $ref for class schemas in defaultCodecTransformer", () => { + it("resolves the canonical top-level $ref for class schemas in defaultCodecTransformer", () => { class Person extends Schema.Class("Person")({ name: Schema.String }) {} @@ -196,7 +196,7 @@ describe("LanguageModel", () => { required: ["name"], additionalProperties: false, $defs: { - Person: { + "PersonJsonEncoding": { type: "object", properties: { name: { diff --git a/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts b/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts new file mode 100644 index 00000000000..69e5936f847 --- /dev/null +++ b/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { LanguageModel } from "effect/unstable/ai" + +describe("LanguageModel representation v2", () => { + it("projects the encoded side before JSON Schema generation", () => { + assert.strictEqual(LanguageModel.defaultCodecTransformer(Schema.FiniteFromString).jsonSchema.type, "string") + }) + + it("uses custom JSON Schema compiler annotations in the default transformer", () => { + const schema = Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/ai/language-model/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + + assert.deepStrictEqual(LanguageModel.defaultCodecTransformer(schema).jsonSchema, { + type: "string", + allOf: [{ minLength: 2 }] + }) + }) +}) diff --git a/packages/effect/test/unstable/ai/OpenAiStructuredOutput.test.ts b/packages/effect/test/unstable/ai/OpenAiStructuredOutput.test.ts index 6e0ab6e11eb..88e4522fd51 100644 --- a/packages/effect/test/unstable/ai/OpenAiStructuredOutput.test.ts +++ b/packages/effect/test/unstable/ai/OpenAiStructuredOutput.test.ts @@ -224,9 +224,7 @@ describe("toCodecOpenAI", () => { assertJsonSchema(Schema.Number, { "anyOf": [ { "type": "number" }, - { "type": "string", "enum": ["NaN"] }, - { "type": "string", "enum": ["Infinity"] }, - { "type": "string", "enum": ["-Infinity"] } + { "type": "string", "enum": ["Infinity", "-Infinity", "NaN"] } ] }) }) @@ -605,7 +603,7 @@ describe("toCodecOpenAI", () => { "required": ["name"], "additionalProperties": false, "$defs": { - "Person": { + "PersonJsonEncoding": { "type": "object", "properties": { "name": { "type": "string" } diff --git a/packages/effect/test/unstable/ai/OpenAiStructuredOutputRepresentation.test.ts b/packages/effect/test/unstable/ai/OpenAiStructuredOutputRepresentation.test.ts new file mode 100644 index 00000000000..b740b146396 --- /dev/null +++ b/packages/effect/test/unstable/ai/OpenAiStructuredOutputRepresentation.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput" + +describe("OpenAiStructuredOutput representation v2", () => { + it("projects the encoded side before JSON Schema generation", () => { + assert.strictEqual(toCodecOpenAI(Schema.FiniteFromString).jsonSchema.type, "string") + }) + + it("uses custom JSON Schema compiler annotations before provider rewrites", () => { + const schema = Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/ai/openai/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + + assert.deepStrictEqual(toCodecOpenAI(schema).jsonSchema, { + type: "string", + minLength: 2 + }) + }) +}) diff --git a/packages/effect/test/unstable/ai/ToolRepresentation.test.ts b/packages/effect/test/unstable/ai/ToolRepresentation.test.ts new file mode 100644 index 00000000000..2f81f7d0b73 --- /dev/null +++ b/packages/effect/test/unstable/ai/ToolRepresentation.test.ts @@ -0,0 +1,35 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { Tool } from "effect/unstable/ai" + +describe("Tool representation v2", () => { + it("projects the encoded side before JSON Schema generation", () => { + assert.strictEqual(Tool.getJsonSchemaFromSchema(Schema.FiniteFromString).type, "string") + }) + + it("uses custom JSON Schema compiler annotations for schema parameters", () => { + const schema = Schema.Struct({ + value: Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/ai/tool/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + }) + const tool = Tool.make("CustomCheck", { parameters: schema }) + + assert.deepStrictEqual(Tool.getJsonSchema(tool), Tool.getJsonSchemaFromSchema(schema)) + assert.deepStrictEqual(Tool.getJsonSchema(tool), { + type: "object", + properties: { + value: { + type: "string", + allOf: [{ minLength: 2 }] + } + }, + required: ["value"], + additionalProperties: false + }) + }) +}) diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 36a932d5e60..72e2f2e97fc 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -138,35 +138,6 @@ describe("OpenApi", () => { assert.property(streamExtension, "errorSchema") }) - it("preserves the data schema identifier for SSE streams", () => { - const Event = Schema.Struct({ - kind: Schema.String, - payload: Schema.String - }).annotate({ identifier: "MyEvent" }) - - const Api = HttpApi.make("Api").add( - HttpApiGroup.make("test").add( - HttpApiEndpoint.get("stream", "/stream", { - success: [HttpApiSchema.StreamSse({ data: Event })] - }) - ) - ) - - const spec = OpenApi.fromApi(Api) - const schemas = spec.components?.schemas - - // The decoded data schema keeps its identifier. - assert.deepStrictEqual(schemas?.MyEvent, { - type: "object", - properties: { - kind: { type: "string" }, - payload: { type: "string" } - }, - required: ["kind", "payload"], - additionalProperties: false - }) - }) - it("rejects duplicate method and path pairs", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( diff --git a/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts new file mode 100644 index 00000000000..9b99bc62b4c --- /dev/null +++ b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts @@ -0,0 +1,72 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +describe("OpenApi representation v2 consumer", () => { + it("projects request and response schemas to the encoded side", () => { + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.post("create", "/create", { + payload: Schema.FiniteFromString, + success: Schema.FiniteFromString + }) + ) + ) + + const spec = OpenApi.fromApi(Api) + + assert.deepStrictEqual( + spec.paths["/create"]?.post?.requestBody?.content["application/json"]?.schema, + { type: "string" } + ) + assert.deepStrictEqual( + spec.paths["/create"]?.post?.responses[200]?.content?.["application/json"]?.schema, + { type: "string" } + ) + }) + + it("uses custom JSON Schema compiler annotations", () => { + const CustomString = Schema.String.check(Schema.makeFilter((value) => value.length >= 2, { + representation: { + id: "test/openapi/minTwoCharacters", + payload: null + }, + toJsonSchema: () => ({ minLength: 2 }) + })) + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.post("create", "/create", { payload: CustomString }) + ) + ) + + assert.deepStrictEqual( + OpenApi.fromApi(Api).paths["/create"]?.post?.requestBody?.content["application/json"]?.schema, + { + type: "string", + allOf: [{ minLength: 2 }] + } + ) + }) + + it("shares definitions and caches by API identity", () => { + const Shared = Schema.Struct({ value: Schema.FiniteFromString }).annotate({ identifier: "Shared" }) + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.post("shared", "/shared", { + payload: Schema.Struct({ first: Shared, second: Shared }), + success: Shared + }) + ) + ) + + const first = OpenApi.fromApi(Api) + + assert.strictEqual(OpenApi.fromApi(Api), first) + assert.deepStrictEqual(first.components.schemas.Shared, { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false + }) + }) +}) diff --git a/packages/effect/typetest/schema/FromJsonSchema.tst.ts b/packages/effect/typetest/schema/FromJsonSchema.tst.ts new file mode 100644 index 00000000000..182a08a2d98 --- /dev/null +++ b/packages/effect/typetest/schema/FromJsonSchema.tst.ts @@ -0,0 +1,43 @@ +import { type JsonSchema, type Schema, SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("JSON Schema importer", () => { + it("exposes exact synchronous signatures", () => { + const fromDocument: ( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions + ) => Schema.Top = SchemaRepresentation.fromJsonSchemaDocument + const fromMultiDocument: ( + document: JsonSchema.MultiDocument<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions + ) => SchemaRepresentation.SchemaMultiDocument = SchemaRepresentation.fromJsonSchemaMultiDocument + const fromSchemaMultiDocument: ( + document: SchemaRepresentation.SchemaMultiDocument + ) => SchemaRepresentation.MultiDocument = SchemaRepresentation.fromSchemaMultiDocument + expect(fromDocument).type.toBe< + ( + document: JsonSchema.Document<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions + ) => Schema.Top + >() + expect(fromMultiDocument).type.toBe< + ( + document: JsonSchema.MultiDocument<"draft-2020-12">, + options?: SchemaRepresentation.FromJsonSchemaOptions + ) => SchemaRepresentation.SchemaMultiDocument + >() + expect(fromSchemaMultiDocument).type.toBe< + (document: SchemaRepresentation.SchemaMultiDocument) => SchemaRepresentation.MultiDocument + >() + }) + + it("keeps onEnter limited to JSON Schema nodes", () => { + const options: SchemaRepresentation.FromJsonSchemaOptions = { + onEnter: (schema) => ({ ...schema, description: "entered" }) + } + + expect(options.onEnter).type.toBe< + ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..6eb183e48e1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInAtomicDeclarationRevivers.tst.ts @@ -0,0 +1,21 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in atomic declaration revivers", () => { + it("composes every atomic declaration reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.DateReviver, + Schema.FileReviver, + Schema.FormDataReviver, + Schema.RegExpReviver, + Schema.Uint8ArrayReviver, + Schema.URLReviver, + Schema.URLSearchParamsReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.DateReviver).type.toBe>() + expect(Schema.FileReviver).type.toBe>() + expect(Schema.FormDataReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInBigDecimalDurationChunkDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInBigDecimalDurationChunkDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..3d3972be6ad --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInBigDecimalDurationChunkDeclarationRevivers.tst.ts @@ -0,0 +1,17 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in BigDecimal, Duration and Chunk declaration revivers", () => { + it("composes every declaration reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.BigDecimalReviver, + Schema.DurationReviver, + Schema.ChunkReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.BigDecimalReviver).type.toBe>() + expect(Schema.DurationReviver).type.toBe>() + expect(Schema.ChunkReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInBigIntRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInBigIntRevivers.tst.ts new file mode 100644 index 00000000000..f971ff6c5c3 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInBigIntRevivers.tst.ts @@ -0,0 +1,27 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in BigInt revivers", () => { + it("composes every BigInt check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isGreaterThanBigIntReviver, + Schema.isGreaterThanOrEqualToBigIntReviver, + Schema.isLessThanBigIntReviver, + Schema.isLessThanOrEqualToBigIntReviver, + Schema.isBetweenBigIntReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isGreaterThanBigIntReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly exclusiveMinimum: string }> + >() + expect(Schema.isBetweenBigIntReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly minimum: string + readonly maximum: string + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined + }> + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInCauseAndExitDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInCauseAndExitDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..f9ee5e1c525 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInCauseAndExitDeclarationRevivers.tst.ts @@ -0,0 +1,17 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in Cause and Exit declaration revivers", () => { + it("exposes exact null payload reviver types", () => { + const revivers: ReadonlyArray = [ + Schema.CauseReasonReviver, + Schema.CauseReviver, + Schema.ExitReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.CauseReasonReviver).type.toBe>() + expect(Schema.CauseReviver).type.toBe>() + expect(Schema.ExitReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInCollectionRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInCollectionRevivers.tst.ts new file mode 100644 index 00000000000..00e5e35630a --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInCollectionRevivers.tst.ts @@ -0,0 +1,25 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in collection revivers", () => { + it("composes every collection check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isMinSizeReviver, + Schema.isMaxSizeReviver, + Schema.isSizeBetweenReviver, + Schema.isUniqueReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isMinSizeReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly minSize: number }> + >() + expect(Schema.isSizeBetweenReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number + }> + >() + expect(Schema.isUniqueReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInDateRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInDateRevivers.tst.ts new file mode 100644 index 00000000000..b614a818532 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInDateRevivers.tst.ts @@ -0,0 +1,28 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in Date revivers", () => { + it("composes every Date check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isDateValidReviver, + Schema.isGreaterThanDateReviver, + Schema.isGreaterThanOrEqualToDateReviver, + Schema.isLessThanDateReviver, + Schema.isLessThanOrEqualToDateReviver, + Schema.isBetweenDateReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isGreaterThanDateReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly exclusiveMinimum: string }> + >() + expect(Schema.isBetweenDateReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly minimum: string + readonly maximum: string + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined + }> + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInDateTimeDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInDateTimeDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..a9d877f9edc --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInDateTimeDeclarationRevivers.tst.ts @@ -0,0 +1,21 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in DateTime declaration revivers", () => { + it("composes every DateTime declaration reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.TimeZoneReviver, + Schema.TimeZoneNamedReviver, + Schema.TimeZoneOffsetReviver, + Schema.DateTimeUtcReviver, + Schema.DateTimeZonedReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.TimeZoneReviver).type.toBe>() + expect(Schema.TimeZoneNamedReviver).type.toBe>() + expect(Schema.TimeZoneOffsetReviver).type.toBe>() + expect(Schema.DateTimeUtcReviver).type.toBe>() + expect(Schema.DateTimeZonedReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInErrorAndCollectionDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInErrorAndCollectionDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..23712cd1c51 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInErrorAndCollectionDeclarationRevivers.tst.ts @@ -0,0 +1,25 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in Error and collection declaration revivers", () => { + it("exposes exact payload and declaration reviver types", () => { + const revivers: ReadonlyArray = [ + Schema.ErrorReviver, + Schema.ReadonlyMapReviver, + Schema.ReadonlySetReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.ErrorReviver).type.toBe< + SchemaRepresentation.DeclarationReviver< + | null + | { + readonly includeStack?: true | undefined + readonly excludeCause?: true | undefined + } + > + >() + expect(Schema.ReadonlyMapReviver).type.toBe>() + expect(Schema.ReadonlySetReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInJsonAndHashDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInJsonAndHashDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..4afae11c8aa --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInJsonAndHashDeclarationRevivers.tst.ts @@ -0,0 +1,19 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in JSON and hash collection declaration revivers", () => { + it("exposes exact null payload reviver types", () => { + const revivers: ReadonlyArray = [ + Schema.JsonReviver, + Schema.MutableJsonReviver, + Schema.HashMapReviver, + Schema.HashSetReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.JsonReviver).type.toBe>() + expect(Schema.MutableJsonReviver).type.toBe>() + expect(Schema.HashMapReviver).type.toBe>() + expect(Schema.HashSetReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInNumberRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInNumberRevivers.tst.ts new file mode 100644 index 00000000000..4b8c6ae2d38 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInNumberRevivers.tst.ts @@ -0,0 +1,33 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in number revivers", () => { + it("composes every number check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isFiniteReviver, + Schema.isIntReviver, + Schema.isMultipleOfReviver, + Schema.isGreaterThanReviver, + Schema.isGreaterThanOrEqualToReviver, + Schema.isLessThanReviver, + Schema.isLessThanOrEqualToReviver, + Schema.isBetweenReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isMultipleOfReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly divisor: number }> + >() + expect(Schema.isGreaterThanReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly exclusiveMinimum: number }> + >() + expect(Schema.isBetweenReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number + readonly exclusiveMinimum?: true | undefined + readonly exclusiveMaximum?: true | undefined + }> + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInObjectRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInObjectRevivers.tst.ts new file mode 100644 index 00000000000..917ff9f33d1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInObjectRevivers.tst.ts @@ -0,0 +1,25 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in object revivers", () => { + it("composes every object check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isMinPropertiesReviver, + Schema.isMaxPropertiesReviver, + Schema.isPropertiesLengthBetweenReviver, + Schema.isPropertyNamesReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isMinPropertiesReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly minProperties: number }> + >() + expect(Schema.isPropertiesLengthBetweenReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly minimum: number + readonly maximum: number + }> + >() + expect(Schema.isPropertyNamesReviver).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInResultAndRedactedDeclarationRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInResultAndRedactedDeclarationRevivers.tst.ts new file mode 100644 index 00000000000..02662ca51fd --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInResultAndRedactedDeclarationRevivers.tst.ts @@ -0,0 +1,23 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in Result and Redacted declaration revivers", () => { + it("exposes exact payload and declaration reviver types", () => { + const revivers: ReadonlyArray = [ + Schema.ResultReviver, + Schema.RedactedReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.ResultReviver).type.toBe>() + expect(Schema.RedactedReviver).type.toBe< + SchemaRepresentation.DeclarationReviver< + | null + | { + readonly label?: string | undefined + readonly disallowJsonEncode?: true | undefined + } + > + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInRevivers.tst.ts new file mode 100644 index 00000000000..cd10d73a52d --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInRevivers.tst.ts @@ -0,0 +1,17 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in revivers", () => { + it("exports the isPattern and Option revivers with concrete payload types", () => { + expect(Schema.isPatternReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly source: string; readonly flags: string }> + >() + expect(Schema.OptionReviver).type.toBe>() + + const revivers: ReadonlyArray = [ + Schema.isPatternReviver, + Schema.OptionReviver + ] + expect(revivers).type.toBe>() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaBuiltInStringRevivers.tst.ts b/packages/effect/typetest/schema/SchemaBuiltInStringRevivers.tst.ts new file mode 100644 index 00000000000..768db4cd790 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaBuiltInStringRevivers.tst.ts @@ -0,0 +1,42 @@ +import { Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema built-in string revivers", () => { + it("composes every string check reviver without casts", () => { + const revivers: ReadonlyArray = [ + Schema.isStringFiniteReviver, + Schema.isStringBigIntReviver, + Schema.isStringSymbolReviver, + Schema.isMinLengthReviver, + Schema.isMaxLengthReviver, + Schema.isLengthBetweenReviver, + Schema.isPatternReviver, + Schema.isTrimmedReviver, + Schema.isUUIDReviver, + Schema.isGUIDReviver, + Schema.isULIDReviver, + Schema.isBase64Reviver, + Schema.isBase64UrlReviver, + Schema.isStartsWithReviver, + Schema.isEndsWithReviver, + Schema.isIncludesReviver, + Schema.isUppercasedReviver, + Schema.isLowercasedReviver, + Schema.isCapitalizedReviver, + Schema.isUncapitalizedReviver + ] + + expect(revivers).type.toBe>() + expect(Schema.isMinLengthReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly minLength: number }> + >() + expect(Schema.isLengthBetweenReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ readonly minimum: number; readonly maximum: number }> + >() + expect(Schema.isUUIDReviver).type.toBe< + SchemaRepresentation.FilterReviver<{ + readonly version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null + }> + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaJsonSchemaConsumer.tst.ts b/packages/effect/typetest/schema/SchemaJsonSchemaConsumer.tst.ts new file mode 100644 index 00000000000..fbf869cd13f --- /dev/null +++ b/packages/effect/typetest/schema/SchemaJsonSchemaConsumer.tst.ts @@ -0,0 +1,22 @@ +import { type JsonSchema, Schema, type SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Schema JSON Schema consumer", () => { + it("exposes exact synchronous high-level signatures", () => { + const toJsonSchema: ( + schema: Schema.Constraint, + options?: Schema.ToJsonSchemaOptions + ) => JsonSchema.Document<"draft-2020-12"> = Schema.toJsonSchemaDocument + + expect(Schema.toRepresentation(Schema.String)).type.toBe< + SchemaRepresentation.Document + >() + expect(Schema.toJsonSchemaDocument(Schema.String)).type.toBe>() + expect(toJsonSchema).type.toBe< + ( + schema: Schema.Constraint, + options?: Schema.ToJsonSchemaOptions + ) => JsonSchema.Document<"draft-2020-12"> + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaRepresentation.tst.ts b/packages/effect/typetest/schema/SchemaRepresentation.tst.ts new file mode 100644 index 00000000000..cd13278b2c6 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaRepresentation.tst.ts @@ -0,0 +1,55 @@ +import { type Schema, type SchemaAST, SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("SchemaRepresentation persisted wire", () => { + it("exposes exact construction signatures", () => { + expect(SchemaRepresentation.toRepresentation).type.toBe< + (ast: SchemaAST.AST) => SchemaRepresentation.Document + >() + expect(SchemaRepresentation.toRepresentations).type.toBe< + ( + asts: readonly [SchemaAST.AST, ...Array] + ) => SchemaRepresentation.MultiDocument + >() + expect(SchemaRepresentation.fromSchemaMultiDocument).type.toBe< + (document: SchemaRepresentation.SchemaMultiDocument) => SchemaRepresentation.MultiDocument + >() + }) + + it("keeps projection explicit for single and multi documents", () => { + expect(SchemaRepresentation.toJson).type.toBe< + (document: SchemaRepresentation.Document) => Schema.Json + >() + expect(SchemaRepresentation.toJsonMultiDocument).type.toBe< + (document: SchemaRepresentation.MultiDocument) => Schema.Json + >() + }) + + it("wraps a document as a multi-document", () => { + expect(SchemaRepresentation.toMultiDocument).type.toBe< + (document: SchemaRepresentation.Document) => SchemaRepresentation.MultiDocument + >() + }) + + it("constructs generated code", () => { + expect(SchemaRepresentation.makeCode).type.toBe< + (runtime: string, Type: string) => SchemaRepresentation.Code + >() + }) + + it("keeps representation metadata separate from annotations", () => { + const declaration = null as unknown as SchemaRepresentation.Declaration + const filter = null as unknown as SchemaRepresentation.Filter + const group = null as unknown as SchemaRepresentation.FilterGroup + + expect(declaration.representation).type.toBe< + SchemaRepresentation.RepresentationAnnotation | undefined + >() + expect(filter.representation).type.toBe< + SchemaRepresentation.CheckRepresentationAnnotation | undefined + >() + expect(group.representation).type.toBe< + SchemaRepresentation.CheckRepresentationAnnotation | undefined + >() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaRepresentationCompilers.tst.ts b/packages/effect/typetest/schema/SchemaRepresentationCompilers.tst.ts new file mode 100644 index 00000000000..a30c66a2474 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaRepresentationCompilers.tst.ts @@ -0,0 +1,49 @@ +import { type JsonSchema, Schema, SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("SchemaRepresentation compilers", () => { + it("exposes exact compiler signatures", () => { + expect(SchemaRepresentation.toJsonSchemaDocument).type.toBe< + ( + document: SchemaRepresentation.Document, + options?: Schema.ToJsonSchemaOptions + ) => JsonSchema.Document<"draft-2020-12"> + >() + expect(SchemaRepresentation.toJsonSchemaMultiDocument).type.toBe< + ( + document: SchemaRepresentation.MultiDocument, + options?: Schema.ToJsonSchemaOptions + ) => JsonSchema.MultiDocument<"draft-2020-12"> + >() + expect(SchemaRepresentation.toCodeDocument).type.toBe< + (document: SchemaRepresentation.MultiDocument) => SchemaRepresentation.CodeDocument + >() + }) + + it("distinguishes live compiler inputs and their outputs", () => { + const document = SchemaRepresentation.toRepresentation(Schema.String.ast) + const multiDocument = SchemaRepresentation.toRepresentations([Schema.String.ast]) + + expect(SchemaRepresentation.toJsonSchemaDocument(document)).type.toBe< + JsonSchema.Document<"draft-2020-12"> + >() + expect(SchemaRepresentation.toJsonSchemaMultiDocument(multiDocument)).type.toBe< + JsonSchema.MultiDocument<"draft-2020-12"> + >() + expect(SchemaRepresentation.toCodeDocument(multiDocument)).type.toBe< + SchemaRepresentation.CodeDocument + >() + }) + + it("exposes node code generation through toCode annotations", () => { + const declaration: Schema.Annotations.Declaration = { + toCode: () => ({ runtime: "Custom", Type: "unknown" }) + } + const filter: Schema.Annotations.Filter = { + toCode: () => ({ runtime: "Custom.check()" }) + } + + expect(declaration.toCode).type.toBe() + expect(filter.toCode).type.toBe() + }) +}) diff --git a/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts b/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts new file mode 100644 index 00000000000..e3ac80c1b65 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts @@ -0,0 +1,77 @@ +import { Schema, SchemaRepresentation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("SchemaRepresentation revivers", () => { + it("infers payload types from reviver constructors", () => { + const declaration = SchemaRepresentation.makeDeclarationReviver( + "acme/schema/Box", + Schema.Struct({ label: Schema.String }), + ({ payload }) => { + expect(payload).type.toBe<{ readonly label: string }>() + return Schema.String + } + ) + expect(declaration).type.toBe>() + + const filter = SchemaRepresentation.makeFilterReviver( + "acme/schema/minLength", + Schema.Struct({ minimum: Schema.Number }), + ({ annotations, payload }) => { + expect(payload).type.toBe<{ readonly minimum: number }>() + return Schema.isMinLength(payload.minimum, annotations) + } + ) + expect(filter).type.toBe>() + + const filterGroup = SchemaRepresentation.makeFilterGroupReviver( + "acme/schema/nonEmpty", + Schema.Null, + ({ annotations, payload }) => { + expect(payload).type.toBe() + return Schema.makeFilterGroup([Schema.isMinLength(1)], annotations) + } + ) + expect(filterGroup).type.toBe>() + }) + + it("accepts concrete payload revivers at the erased collection boundary", () => { + const reviver: SchemaRepresentation.FilterReviver<{ readonly source: string }> = { + id: "acme/schema/isPattern", + payloadSchema: Schema.Struct({ source: Schema.String }), + revive: ({ payload, annotations }) => Schema.isPattern(new RegExp(payload.source), annotations) + } + const revivers: ReadonlyArray = [reviver] + + expect(revivers).type.toBe>() + }) + + it("separates JSON decoding from schema revival", () => { + expect(SchemaRepresentation.fromJson).type.toBe< + (input: Schema.Json) => SchemaRepresentation.Document + >() + expect(SchemaRepresentation.fromJsonMultiDocument).type.toBe< + (input: Schema.Json) => SchemaRepresentation.MultiDocument + >() + expect(SchemaRepresentation.fromRepresentation).type.toBe< + ( + document: SchemaRepresentation.Document, + options: { readonly revivers: ReadonlyArray } + ) => Schema.Top + >() + expect(SchemaRepresentation.fromRepresentations).type.toBe< + ( + document: SchemaRepresentation.MultiDocument, + options: { readonly revivers: ReadonlyArray } + ) => SchemaRepresentation.SchemaMultiDocument + >() + + const document = SchemaRepresentation.fromJson({ representation: { _tag: "String", checks: [] }, references: {} }) + // @ts-expect-error Expected 2 arguments, but got 1. + SchemaRepresentation.fromRepresentation(document) + // @ts-expect-error Expected 2 arguments, but got 1. + SchemaRepresentation.fromRepresentations({ + representations: [{ _tag: "String", checks: [] }], + references: {} + }) + }) +}) diff --git a/packages/effect/typetest/unstable/httpapi/OpenApiRepresentation.tst.ts b/packages/effect/typetest/unstable/httpapi/OpenApiRepresentation.tst.ts new file mode 100644 index 00000000000..8e7d92656f5 --- /dev/null +++ b/packages/effect/typetest/unstable/httpapi/OpenApiRepresentation.tst.ts @@ -0,0 +1,12 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { describe, expect, it } from "tstyche" + +describe("OpenApi representation consumer", () => { + it("keeps the synchronous fromApi signature", () => { + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add(HttpApiEndpoint.get("health", "/health")) + ) + + expect(OpenApi.fromApi(Api)).type.toBe() + }) +}) diff --git a/packages/platform-node/test/__snapshots__/HttpApi.test.ts.snap b/packages/platform-node/test/__snapshots__/HttpApi.test.ts.snap index 9765aadd09c..a7a2faf5b87 100644 --- a/packages/platform-node/test/__snapshots__/HttpApi.test.ts.snap +++ b/packages/platform-node/test/__snapshots__/HttpApi.test.ts.snap @@ -20,7 +20,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` ], "type": "object", }, - "Group": { + "GroupJsonEncoding": { "additionalProperties": false, "properties": { "id": { @@ -36,7 +36,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` ], "type": "object", }, - "NoStatusError": { + "NoStatusErrorJsonEncoding": { "additionalProperties": false, "properties": { "_tag": { @@ -51,7 +51,22 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` ], "type": "object", }, - "User": { + "UserErrorJsonEncoding": { + "additionalProperties": false, + "properties": { + "_tag": { + "enum": [ + "UserError", + ], + "type": "string", + }, + }, + "required": [ + "_tag", + ], + "type": "object", + }, + "UserJsonEncoding": { "additionalProperties": false, "properties": { "createdAt": { @@ -81,21 +96,6 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` ], "type": "object", }, - "UserError": { - "additionalProperties": false, - "properties": { - "_tag": { - "enum": [ - "UserError", - ], - "type": "string", - }, - }, - "required": [ - "_tag", - ], - "type": "object", - }, }, "securitySchemes": { "cookie": { @@ -168,7 +168,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Group", + "$ref": "#/components/schemas/GroupJsonEncoding", }, }, }, @@ -325,7 +325,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Group", + "$ref": "#/components/schemas/GroupJsonEncoding", }, }, }, @@ -397,7 +397,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/User", + "$ref": "#/components/schemas/UserJsonEncoding", }, "type": "array", }, @@ -409,7 +409,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NoStatusError", + "$ref": "#/components/schemas/NoStatusErrorJsonEncoding", }, }, }, @@ -477,7 +477,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User", + "$ref": "#/components/schemas/UserJsonEncoding", }, }, }, @@ -489,10 +489,10 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "schema": { "anyOf": [ { - "$ref": "#/components/schemas/UserError", + "$ref": "#/components/schemas/UserErrorJsonEncoding", }, { - "$ref": "#/components/schemas/UserError", + "$ref": "#/components/schemas/UserErrorJsonEncoding", }, ], }, @@ -668,7 +668,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User", + "$ref": "#/components/schemas/UserJsonEncoding", }, }, }, @@ -678,7 +678,7 @@ exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserError", + "$ref": "#/components/schemas/UserErrorJsonEncoding", }, }, }, diff --git a/packages/tools/bundle/fixtures/schema-representation-roundtrip.ts b/packages/tools/bundle/fixtures/schema-representation-roundtrip.ts index 4a66b9c46b3..cf506fec2b3 100644 --- a/packages/tools/bundle/fixtures/schema-representation-roundtrip.ts +++ b/packages/tools/bundle/fixtures/schema-representation-roundtrip.ts @@ -7,10 +7,9 @@ const schema = Schema.toCodecJson(Schema.Struct({ c: Schema.Array(Schema.String) })) -const json = Schema.encodeSync(SchemaRepresentation.DocumentFromJson)( - SchemaRepresentation.fromAST(schema.ast) -) +const json = SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)) -SchemaRepresentation.toSchema( - Schema.decodeSync(SchemaRepresentation.DocumentFromJson)(JSON.parse(JSON.stringify(json))) +SchemaRepresentation.fromRepresentation( + SchemaRepresentation.fromJson(JSON.parse(JSON.stringify(json))), + { revivers: [Schema.isFiniteReviver] } ) diff --git a/packages/tools/bundle/fixtures/schema-toCodeDocument.ts b/packages/tools/bundle/fixtures/schema-toCodeDocument.ts index a664245ebf7..af986fbbe80 100644 --- a/packages/tools/bundle/fixtures/schema-toCodeDocument.ts +++ b/packages/tools/bundle/fixtures/schema-toCodeDocument.ts @@ -7,6 +7,4 @@ const schema = Schema.Struct({ c: Schema.Array(Schema.String) }) -const representation = Schema.toRepresentation(schema) - -SchemaRepresentation.toCodeDocument(SchemaRepresentation.toMultiDocument(representation)) +SchemaRepresentation.toCodeDocument(SchemaRepresentation.toRepresentations([schema.ast])) diff --git a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts index 2dcb40908b7..dee76d78293 100644 --- a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts +++ b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts @@ -24,16 +24,17 @@ import * as Rec from "effect/Record" import * as SchemaRepresentation from "effect/SchemaRepresentation" type Source = "openapi-3.0" | "openapi-3.1" - interface GenerateOptions { readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined } +interface MultipartSchemaRefs { + readonly singleFile: string + readonly files: string +} + interface GenerateHttpApiOptions extends GenerateOptions { - readonly multipartSchemaRefs?: { - readonly singleFile: string - readonly files: string - } | undefined + readonly multipartSchemaRefs?: MultipartSchemaRefs | undefined } /** @@ -49,6 +50,10 @@ interface GenerateHttpApiOptions extends GenerateOptions { * @since 4.0.0 */ export function make() { + return makeWithRepresentation() +} + +function makeWithRepresentation() { const store: Record = {} function addSchema(name: string, schema: JsonSchema.JsonSchema): string { @@ -114,7 +119,8 @@ export function make() { renderSchemaTypeAndRuntime(generated.nameMap[i], code, typeOnly) ) - return render("recursive declarations", recursiveDeclarations) + + return renderImportArtifacts(generated.codeDocument, !typeOnly) + + render("recursive declarations", recursiveDeclarations) + render("non-recursive definitions", nonRecursives) + render("recursive definitions", recursives) + render("schemas", codes) @@ -165,7 +171,8 @@ export function make() { renderSchemaTypeAndRuntime(generated.nameMap[i], code, false, options?.multipartSchemaRefs) ) - return render("recursive declarations", recursiveDeclarations) + + return renderImportArtifacts(generated.codeDocument, true) + + render("recursive declarations", recursiveDeclarations) + render("non-recursive definitions", nonRecursives) + render("recursive definitions", recursives) + render("schemas", codes) @@ -174,7 +181,7 @@ export function make() { function makeCodeDocument( source: Source, components: JsonSchema.Definitions, - options?: GenerateOptions + options?: GenerateHttpApiOptions ): { readonly nameMap: Array readonly codeDocument: SchemaRepresentation.CodeDocument @@ -182,7 +189,7 @@ export function make() { const nameMap: Array = [] const schemas: Array = [] - const definitions: JsonSchema.Definitions = Rec.map( + let definitions: JsonSchema.Definitions = Rec.map( components, (js) => fromSchemaOpenApi(source, js).schema ) @@ -195,24 +202,33 @@ export function make() { if (!Arr.isArrayNonEmpty(schemas)) { return } + if (options?.multipartSchemaRefs !== undefined) { + definitions = omitSupersededMultipartDefinitions(definitions, schemas, options.multipartSchemaRefs) + } - const multiDocument: SchemaRepresentation.MultiDocument = SchemaRepresentation.fromJsonSchemaMultiDocument({ - dialect: "draft-2020-12", + const document = { + dialect: "draft-2020-12" as const, schemas, definitions - }, { - onEnter(js) { + } + const importerOptions = { + onEnter(js: JsonSchema.JsonSchema) { const out = { ...js } if (out.type === "object" && out.additionalProperties === undefined) { out.additionalProperties = false } - return options?.onEnter?.(out) ?? out + return options?.onEnter === undefined ? out : options.onEnter(out) } - }) + } + const codeDocument = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromSchemaMultiDocument( + SchemaRepresentation.fromJsonSchemaMultiDocument(document, importerOptions) + ) + ) return { nameMap, - codeDocument: SchemaRepresentation.toCodeDocument(multiDocument) + codeDocument } } @@ -232,10 +248,7 @@ function renderSchemaTypeAndRuntime( $ref: string, code: SchemaRepresentation.Code, typeOnly: boolean, - multipartSchemaRefs?: { - readonly singleFile: string - readonly files: string - } + multipartSchemaRefs?: MultipartSchemaRefs ) { if (!typeOnly && multipartSchemaRefs !== undefined) { if ($ref === multipartSchemaRefs.singleFile) { @@ -275,6 +288,72 @@ function render(title: string, as: ReadonlyArray) { return "// " + title + "\n" + as.join("\n") + "\n" } +function renderImportArtifacts(codeDocument: SchemaRepresentation.CodeDocument, enabled: boolean): string { + if (!enabled) return "" + const imports = codeDocument.artifacts.flatMap((artifact) => + artifact._tag === "Import" ? [artifact.importDeclaration] : [] + ) + return imports.length === 0 ? "" : imports.join("\n") + "\n" +} + +function omitSupersededMultipartDefinitions( + definitions: JsonSchema.Definitions, + schemas: ReadonlyArray, + multipartSchemaRefs: MultipartSchemaRefs +): JsonSchema.Definitions { + const rootReferences = collectReferenceKeys(schemas) + const multipartReferences = new Set([multipartSchemaRefs.singleFile, multipartSchemaRefs.files]) + const output: JsonSchema.Definitions = {} + + for (const [key, schema] of Object.entries(definitions)) { + const superseded = !multipartReferences.has(key) && !rootReferences.has(key) && + referencesAny(schema, multipartReferences) + if (!superseded) { + Object.defineProperty(output, key, { + value: schema, + enumerable: true, + configurable: true, + writable: true + }) + } + } + return output +} + +function collectReferenceKeys(input: unknown): Set { + const references = new Set() + visitReferences(input, ($ref) => { + const token = $ref.split("/").at(-1) + if (token !== undefined && token.length > 0) { + references.add(token.replaceAll("~1", "/").replaceAll("~0", "~")) + } + }) + return references +} + +function referencesAny(input: unknown, keys: ReadonlySet): boolean { + const references = collectReferenceKeys(input) + for (const key of references) { + if (keys.has(key)) return true + } + return false +} + +function visitReferences(input: unknown, onReference: ($ref: string) => void): void { + if (Array.isArray(input)) { + for (const value of input) visitReferences(value, onReference) + return + } + if (typeof input !== "object" || input === null) return + for (const [key, value] of Object.entries(input)) { + if (key === "$ref" && typeof value === "string") { + onReference(value) + } else { + visitReferences(value, onReference) + } + } +} + const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g function collectForwardReferencedRecursives( diff --git a/packages/tools/openapi-generator/src/OpenApiGenerator.ts b/packages/tools/openapi-generator/src/OpenApiGenerator.ts index d447a0452d3..498f88810e6 100644 --- a/packages/tools/openapi-generator/src/OpenApiGenerator.ts +++ b/packages/tools/openapi-generator/src/OpenApiGenerator.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect" import type * as JsonSchema from "effect/JsonSchema" import * as Layer from "effect/Layer" import * as Predicate from "effect/Predicate" +import * as Rec from "effect/Record" import * as String from "effect/String" import type { OpenAPISecurityScheme, OpenAPISpec, OpenAPISpecMethodName } from "effect/unstable/httpapi/OpenApi" import SwaggerToOpenApi from "swagger2openapi" @@ -156,7 +157,7 @@ export const make = Effect.gen(function*() { const generation = options.format === "httpapi" ? generator.generateHttpApi( source, - withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs), + withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs, resolveRef), { onEnter: options.onEnter, multipartSchemaRefs @@ -807,13 +808,14 @@ const toDefinitionRef = (name: string): string => `#/$defs/${name.replaceAll("~" const withHttpApiMultipartSchemas = ( definitions: JsonSchema.Definitions, - multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined + multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined, + resolveRef: (ref: string) => unknown ): JsonSchema.Definitions => { if (multipartSchemaRefs === undefined) { return definitions } return { - ...definitions, + ...Rec.map(definitions, (schema) => transformMultipartSchema(schema, multipartSchemaRefs, resolveRef)), [multipartSchemaRefs.singleFile]: { type: "string", format: "binary" diff --git a/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts b/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts new file mode 100644 index 00000000000..a2db051e00c --- /dev/null +++ b/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts @@ -0,0 +1,27 @@ +import * as JsonSchemaGenerator from "@effect/openapi-generator/JsonSchemaGenerator" +import { assert, describe, it } from "@effect/vitest" + +describe("JsonSchemaGenerator representation v2", () => { + it("keeps definitions out of roots and emits unreachable definitions", () => { + const generator = JsonSchemaGenerator.make() + generator.addSchema("Root", { $ref: "#/components/schemas/Shared" }) + + const output = generator.generate("openapi-3.1", { + Shared: { type: "string" }, + Unused: { type: "boolean" } + }, false) + + assert.strictEqual( + output, + `// non-recursive definitions +export type Shared = string +export const Shared = Schema.String +export type Unused = boolean +export const Unused = Schema.Boolean +// schemas +export type Root = Shared +export const Root = Shared +` + ) + }) +})