From c9d40dc563efcf07c57adf529abd8dd19c8717cf Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 18 Aug 2026 10:39:42 +0200 Subject: [PATCH 1/2] Fix JSON Schema object scope intersections --- .changeset/tidy-json-schema-object-scopes.md | 5 + packages/effect/src/Schema.ts | 9 +- packages/effect/src/SchemaRepresentation.ts | 36 +- .../internal/schema/fromJsonSchemaDocument.ts | 288 ++++++++------ .../internal/schema/toJsonSchemaDocument.ts | 32 +- .../test/schema/jsonSchemaRoundTrip.test.ts | 189 +++++++++ .../fromJsonSchemaDocument.test.ts | 367 ++++++++---------- .../test/schema/toJsonSchemaDocument.test.ts | 63 ++- 8 files changed, 627 insertions(+), 362 deletions(-) create mode 100644 .changeset/tidy-json-schema-object-scopes.md create mode 100644 packages/effect/test/schema/jsonSchemaRoundTrip.test.ts diff --git a/.changeset/tidy-json-schema-object-scopes.md b/.changeset/tidy-json-schema-object-scopes.md new file mode 100644 index 00000000000..2d3f1b7b417 --- /dev/null +++ b/.changeset/tidy-json-schema-object-scopes.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve JSON Schema object keyword scopes when importing `allOf` intersections, including closed empty objects and required-only keys. Emit intersecting index signatures without weakening their constraints, and reject object scope intersections that cannot be represented faithfully. diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index bd4fd55ef90..0b1583d3d94 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -15171,15 +15171,18 @@ export interface ToJsonSchemaOptions { * The `options` parameter controls generation details such as additional * properties and synthesized check descriptions; it does not change the draft * target. Declarations are lowered through their `toCodecJson` or `toCodec` - * annotation when available before the representation document is compiled. + * annotation when available before the representation document is compiled. For schemas whose codec JSON AST can be + * represented exactly in JSON Schema, importing the emitted document reconstructs a schema that accepts the same JSON + * values. This is a semantic round-trip guarantee; the reconstructed AST may have a different shape. * * **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. Opaque declarations without a structural codec are represented by an - * unconstrained JSON Schema. + * shape. Such schemas are outside the exact round-trip subset. Opaque declarations without a structural codec are + * represented by an unconstrained JSON Schema. Effect decoding may discard excess object properties by default; use + * `onExcessProperty: "error"` when comparing validation semantics with an emitted JSON Schema. * * @category converting * @since 4.0.0 diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 8818ccc81f7..d77058a4a09 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -752,12 +752,21 @@ export function toMultiDocument(document: Document): MultiDocument { * * Use when you need JSON Schema output from a representation whose checks carry compiler annotations. * + * **Details** + * + * For representation documents whose validation semantics can be expressed exactly in JSON Schema, importing the + * emitted document with {@link fromJsonSchemaDocument} reconstructs a schema that accepts the same JSON values. This + * is a semantic round-trip guarantee; the emitted document and reconstructed representation may have different shapes. + * * **Gotchas** * - * Opaque declarations are represented by an unconstrained JSON Schema. Check callback results are used directly, and - * exceptions raised by a callback pass through unchanged. Callbacks must treat their input schemas as immutable. Each - * returned value must be a valid JSON Schema object graph and must not be mutated after the callback returns. Local - * definition references returned by callbacks are resolved together with compiler-generated references. + * - Opaque declarations are represented by an unconstrained JSON Schema and are outside the exact round-trip subset. + * - Check callback results are used directly, and exceptions raised by a callback pass through unchanged. Callbacks + * must treat their input schemas as immutable. Each returned value must be a valid JSON Schema object graph and must + * not be mutated after the callback returns. + * - Local definition references returned by callbacks are resolved together with compiler-generated references. + * - Effect decoding may discard excess object properties by default. Use `onExcessProperty: "error"` when comparing + * validation semantics with the emitted JSON Schema. * * @see {@link toJsonSchemaMultiDocument} for multiple roots sharing definitions * @@ -1178,12 +1187,23 @@ export function fromRepresentations( * * Use when you need to validate or transform values described by an external JSON Schema document. * + * **Details** + * + * For the Draft 2020-12 subset translated exactly by this importer, compiling the imported schema through + * {@link toRepresentation} and {@link toJsonSchemaDocument} produces a document that accepts the same JSON values as + * the input. This is a semantic round-trip guarantee; keyword layout, definitions, and annotations may be normalized. + * * **Gotchas** * - * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Pattern - * constraints reached during translation cause an error by default. Use `patterns: "apply"` only for trusted documents, - * or `patterns: "ignore"` to weaken validation explicitly. Callback results are used directly, and exceptions raised by a - * callback pass through unchanged. + * - Import is best-effort outside the exactly translated subset. Unsupported or ignored keywords are not covered by the + * round-trip guarantee. + * - Built-in declarations and checks are reconstructed with importer-owned revivers. + * - Pattern constraints reached during translation cause an error by default. Use `patterns: "apply"` only for trusted + * documents, or `patterns: "ignore"` to weaken validation explicitly; ignored patterns are outside the round-trip + * guarantee. + * - `onEnter` results replace the corresponding input nodes, so the round-trip guarantee applies to the rewritten + * document. + * - Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions * @see {@link toRepresentation} for converting the result to a representation document diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 590802eff1c..7c5ea5aab22 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -29,6 +29,24 @@ type ImportedJsonSchemaRepresentation = Extract +interface ImportedObjectPattern { + readonly source: string + readonly parameter: SchemaRepresentation.String + readonly type: ImportedJsonSchemaRepresentation +} + +interface ImportedObjectProperty { + readonly type: ImportedJsonSchemaRepresentation | undefined + readonly isOptional: boolean +} + +interface ImportedObjectScope { + readonly properties: ReadonlyMap + readonly hasProperties: boolean + readonly patterns: ReadonlyArray + readonly additionalProperties: ImportedJsonSchemaRepresentation +} + const never: ImportedJsonSchemaRepresentation = { _tag: "Never", checks: [] } const unknown: ImportedJsonSchemaRepresentation = { _tag: "Unknown", checks: [] } const string: ImportedJsonSchemaRepresentation = { _tag: "String", checks: [] } @@ -37,28 +55,6 @@ function makeLiteral(literal: string | number | boolean): SchemaRepresentation.L return { _tag: "Literal", literal, checks: [] } } -function annotate( - 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 - } - } -} - const jsonSchemaTypes = new Set([ "null", "string", @@ -216,11 +212,37 @@ function translateJsonSchemaMultiDocument( ): SchemaRepresentation.MultiDocument { const definitionCache = new Map() const reachableDefinitions = new Map() + const objectScopesByProperties = new WeakMap< + SchemaRepresentation.Objects["propertySignatures"], + ReadonlyArray + >() const annotatedReferences: Array<{ readonly reference: SchemaRepresentation.Reference readonly path: Path }> = [] + function getObjectScopes( + representation: SchemaRepresentation.Objects + ): ReadonlyArray { + const scopes = objectScopesByProperties.get(representation.propertySignatures) + if (scopes === undefined) throw new Error("Missing imported object scopes") + return scopes + } + + function annotate( + 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 translateDefinition( key: string, path: Path, @@ -428,67 +450,104 @@ function translateJsonSchemaMultiDocument( return { elements, rest: rest._tag === "Never" ? [] : [rest] } } - function combineProperties( - left: ReadonlyArray, - right: ReadonlyArray, + function combineTypes( + types: ReadonlyArray, path: Path - ): Array { - const rightByName = new Map(right.map((property) => [property.name, property])) - const names = new Set() - const properties = left.map((property) => { - const name = property.name - names.add(name) - const other = rightByName.get(name) - if (other === undefined) return property - return { - name: property.name, - type: combine( - property.type as ImportedJsonSchemaRepresentation, - other.type as ImportedJsonSchemaRepresentation, - [...path, "properties", globalThis.String(name)] - ), - isOptional: property.isOptional && other.isOptional, - isMutable: false - } - }) - for (const property of right) { - if (!names.has(property.name)) properties.push(property) + ): ImportedJsonSchemaRepresentation { + let out = types[0] ?? unknown + for (let index = 1; index < types.length; index++) { + out = combine(out, types[index], [...path, index]) } - return properties + return out } - function isUnconstrainedString(representation: Representation): boolean { - return representation._tag === "String" && representation.checks.length === 0 && - representation.annotations === undefined - } - - function combineIndexSignatures( - left: ReadonlyArray, - right: ReadonlyArray, + function lowerObject( + scopes: ReadonlyArray, + checks: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined, 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"] - ) + ): ImportedJsonSchemaRepresentation { + const names = new Set() + let hasFiniteKeyDomain = false + let requiresFiniteKeyDomain = false + for (const scope of scopes) { + for (const name of scope.properties.keys()) names.add(name) + hasFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" && scope.patterns.length === 0 + requiresFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" || + scope.additionalProperties._tag !== "Unknown" && (scope.hasProperties || scope.patterns.length > 0) + } + if (!hasFiniteKeyDomain && requiresFiniteKeyDomain) { + throw errorWithPath("Unsupported object keyword scopes", path) + } + + const properties: Array = [] + for (const name of names) { + let isOptional = true + const types: Array = [] + for (const scope of scopes) { + const property = scope.properties.get(name) + if (property !== undefined) { + if (!property.isOptional) isOptional = false + if (property.type !== undefined) types.push(property.type) + } + let matches = false + for (const pattern of scope.patterns) { + if (globalThis.RegExp(pattern.source).test(name)) { + types.push(pattern.type) + matches = true } - } else { - signatures.push(signature) } - } else { - signatures.push(signature) + if (property?.type === undefined && !matches) types.push(scope.additionalProperties) } + const type = combineTypes(types, [...path, "properties", name]) + if (!isOptional && type._tag === "Never") return never + properties.push({ name, type, isOptional, isMutable: false }) + } + + const indexSignatures: Array = [] + if (!hasFiniteKeyDomain) { + const additionalProperties = combineTypes( + scopes.map((scope) => scope.additionalProperties), + [...path, "additionalProperties"] + ) + const patterns = new Map() + for (const scope of scopes) { + for (const pattern of scope.patterns) { + const previous = patterns.get(pattern.source) + patterns.set( + pattern.source, + previous === undefined + ? pattern + : { + ...pattern, + type: combine( + previous.type, + pattern.type, + [...path, "patternProperties", pattern.source] + ) + } + ) + } + } + for (const { parameter, type } of patterns.values()) { + indexSignatures.push({ + parameter, + type: combine(type, additionalProperties, [...path, "indexSignatures"]) + }) + } + indexSignatures.push({ parameter: string, type: additionalProperties }) + } else if (properties.length === 0) { + indexSignatures.push({ parameter: string, type: never }) + } + + objectScopesByProperties.set(properties, scopes) + return { + _tag: "Objects", + propertySignatures: properties, + indexSignatures, + checks, + annotations } - return signatures } function combine( @@ -614,14 +673,15 @@ function translateJsonSchemaMultiDocument( case "Objects": { if (right._tag !== "Objects") return never const objectChecks = combineChecks(left.checks, right.checks, right.annotations) - return annotate( - { - _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) + const scopes = [ + ...getObjectScopes(left), + ...getObjectScopes(right) + ] + return lowerObject( + scopes, + objectChecks ?? left.checks, + mergeAnnotations(left.annotations, objectChecks === undefined ? right.annotations : undefined), + path ) } } @@ -779,13 +839,10 @@ function translateJsonSchemaMultiDocument( checks: collectArrayChecks(schema, isMaxItemsRedundant) } } - case "object": - return { - _tag: "Objects", - propertySignatures: collectProperties(schema, path), - indexSignatures: collectIndexSignatures(schema, path), - checks: collectObjectChecks(schema, path) - } + case "object": { + const scope = collectObjectScope(schema, path) + return lowerObject([scope], collectObjectChecks(schema, path), undefined, path) + } default: return unknown } @@ -836,31 +893,27 @@ function translateJsonSchemaMultiDocument( return checks } - function collectProperties( + function collectObjectScope( schema: JsonSchema.JsonSchema, path: Path - ): Array { - const properties = + ): ImportedObjectScope { + const sourceProperties = 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 = [] + const propertyNames = Object.keys(sourceProperties) + const keys = new Set([...propertyNames, ...required]) + const properties = new Map(Array.from(keys, (name) => [name, { + type: Object.hasOwn(sourceProperties, name) + ? recur(sourceProperties[name], [...path, "properties", name]) + : undefined, + isOptional: !required.includes(name) + }])) + const hasProperties = propertyNames.length > 0 + const patterns: Array = [] if ( typeof schema.patternProperties === "object" && schema.patternProperties !== null && @@ -868,8 +921,9 @@ function translateJsonSchemaMultiDocument( ) { for (const [pattern, value] of Object.entries(schema.patternProperties)) { const checks = importPatternChecks(pattern, [...path, "patternProperties", pattern]) - if (checks.length === 0) return [{ parameter: string, type: unknown }] - signatures.push({ + if (checks.length === 0) return { properties, hasProperties, patterns: [], additionalProperties: unknown } + patterns.push({ + source: pattern, parameter: { _tag: "String", checks @@ -878,18 +932,12 @@ function translateJsonSchemaMultiDocument( }) } } - if (schema.additionalProperties === undefined || schema.additionalProperties === true) { - signatures.push({ - parameter: string, - type: unknown - }) - } else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) { - signatures.push({ - parameter: string, - type: recur(schema.additionalProperties, [...path, "additionalProperties"]) - }) - } - return signatures + const additionalProperties = schema.additionalProperties === false + ? never + : typeof schema.additionalProperties === "object" && schema.additionalProperties !== null + ? recur(schema.additionalProperties, [...path, "additionalProperties"]) + : unknown + return { properties, hasProperties, patterns, additionalProperties } } function collectObjectChecks( diff --git a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts index 89900ee4993..0463d9ed981 100644 --- a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts @@ -388,8 +388,8 @@ function compileJsonSchema( } if (representation.propertySignatures.length > 0) out.properties = properties if (required.length > 0) out.required = required - out.additionalProperties = options?.additionalProperties ?? false const patternProperties: Record = {} + const additionalProperties: Array = [] for (let index = 0; index < representation.indexSignatures.length; index++) { const signature = representation.indexSignatures[index] let type: JsonSchema.JsonSchema | false = recur( @@ -403,14 +403,36 @@ function compileJsonSchema( new Set() ) if (patterns.length === 0) { - out.additionalProperties = type + additionalProperties.push(type) } else { - for (const pattern of patterns) InternalRecord.assignProperty(patternProperties, pattern, type) + for (const pattern of patterns) { + const previous = patternProperties[pattern] + InternalRecord.assignProperty( + patternProperties, + pattern, + previous === undefined + ? type + : previous === false || type === false + ? false + : appendJsonSchema(previous, type) + ) + } } } - if (Object.keys(patternProperties).length > 0) { + const hasPatternProperties = Object.keys(patternProperties).length > 0 + if (hasPatternProperties) { out.patternProperties = patternProperties - delete out.additionalProperties + } + if (representation.indexSignatures.length === 0) { + out.additionalProperties = options?.additionalProperties ?? false + } else if ( + additionalProperties.length === 1 && + representation.propertySignatures.length === 0 && + !hasPatternProperties + ) { + out.additionalProperties = additionalProperties[0] + } else if (additionalProperties.length > 0) { + out.allOf = additionalProperties.map((type) => ({ type: "object", additionalProperties: type })) } if ( typeof out.additionalProperties === "object" && diff --git a/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts b/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts new file mode 100644 index 00000000000..e6b900f5a99 --- /dev/null +++ b/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts @@ -0,0 +1,189 @@ +import { assert, describe, it } from "@effect/vitest" +import type { Options as AjvOptions } from "ajv" +import { Exit, JsonSchema, Schema, SchemaRepresentation } from "effect" + +// oxlint-disable-next-line @typescript-eslint/no-require-imports +const Ajv2020 = require("ajv/dist/2020") + +const ajv = new Ajv2020.default( + { + allErrors: true, + strict: false, + validateSchema: true, + code: { esm: true } + } satisfies AjvOptions +) + +function compile(document: JsonSchema.Document<"draft-2020-12">) { + return ajv.compile({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + $defs: document.definitions + }) +} + +function assertSameAcceptedValues( + left: (input: unknown) => unknown, + right: (input: unknown) => unknown, + inputs: ReadonlyArray +): void { + for (const input of inputs) { + assert.strictEqual( + left(input) === true, + right(input) === true, + `Validation differs for ${JSON.stringify(input)}` + ) + } +} + +function assertJsonSchemaEquivalent( + left: JsonSchema.Document<"draft-2020-12">, + right: JsonSchema.Document<"draft-2020-12">, + inputs: ReadonlyArray +): void { + assertSameAcceptedValues(compile(left), compile(right), inputs) +} + +function assertJsonSchemaImportRoundTrip( + schema: JsonSchema.JsonSchema, + inputs: ReadonlyArray +): void { + const source = JsonSchema.fromSchemaDraft2020_12(schema) + const imported = SchemaRepresentation.fromJsonSchemaDocument(source, { patterns: "apply" }) + const representation = SchemaRepresentation.toRepresentation(imported.ast) + const emitted = SchemaRepresentation.toJsonSchemaDocument(representation) + assertJsonSchemaEquivalent(source, emitted, inputs) + + const validate = compile(source) + const decode = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder, { + onExcessProperty: "error" + }) + assertSameAcceptedValues((input) => Exit.isSuccess(decode(input)), validate, inputs) +} + +function assertRepresentationRoundTrip( + schema: Schema.ConstraintDecoder, + inputs: ReadonlyArray +): void { + const emitted = Schema.toJsonSchemaDocument(schema) + const imported = SchemaRepresentation.fromJsonSchemaDocument(emitted, { patterns: "apply" }) + const decodeSource = Schema.decodeUnknownExit(schema, { onExcessProperty: "error" }) + const decodeImported = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder, { + onExcessProperty: "error" + }) + assertSameAcceptedValues( + (input) => Exit.isSuccess(decodeImported(input)), + (input) => Exit.isSuccess(decodeSource(input)), + inputs + ) + assertJsonSchemaEquivalent(emitted, Schema.toJsonSchemaDocument(imported), inputs) +} + +describe("JSON Schema round-trip laws", () => { + describe("toJsonSchema(fromJsonSchema(A))", () => { + it("preserves empty closed objects", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", additionalProperties: false }, + [{}, { a: 1 }, [], [1], null, 1] + ) + }) + + it("applies additionalProperties to required-only names", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", required: ["a"], additionalProperties: false }, + [{}, { a: 1 }, { a: "a" }, { b: 1 }, []] + ) + }) + + it("applies an additionalProperties schema to required-only names", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", required: ["a"], additionalProperties: { type: "string" } }, + [{}, { a: 1 }, { a: "a" }, { a: "a", b: "b" }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves closed object scopes", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } } }] + }, + [{}, { a: "a" }, { a: 1 }, { b: 1 }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves sibling additionalProperties schemas", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: { type: "boolean" } }] + }, + [{}, { a: "a" }, { b: true }, { b: "b" }, { a: "a", b: true }, []] + ) + }) + + it("preserves open patternProperties", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + patternProperties: { "^a": { type: "string" } } + }, + [{}, { a: "a" }, { a: 1 }, { ab: "a", b: 1 }, { b: 1 }, []] + ) + }) + }) + + describe("fromJsonSchema(toJsonSchema(X))", () => { + it("preserves structs", () => { + assertRepresentationRoundTrip( + Schema.Struct({ a: Schema.String }), + [{}, { a: "a" }, { a: 1 }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves string indexes", () => { + assertRepresentationRoundTrip( + Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])), + [{}, { a: "a" }, { a: 1 }, { a: true }, []] + ) + }) + + it("preserves pattern indexes", () => { + assertRepresentationRoundTrip( + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + [{}, { A: 1 }, { A: "a" }, { a: 1 }, { a: "a" }, []] + ) + }) + + it("preserves pattern and string indexes", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + Schema.Record(Schema.String, Schema.Boolean) + ]), + [{}, { A: 1 }, { A: true }, { a: 1 }, { a: true }, []] + ) + }) + + it("preserves multiple string indexes", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.String])), + Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.Finite])) + ]), + [{}, { a: true }, { a: false }, { a: 1 }, { a: "a" }, []] + ) + }) + + it("applies string indexes to explicit properties", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({ a: Schema.Union([Schema.String, Schema.Boolean]) }), [ + Schema.Record(Schema.String, Schema.Boolean) + ]), + [{}, { a: true }, { a: false }, { a: "a" }, { a: 1 }, { a: true, b: false }, []] + ) + }) + }) +}) diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index c1f6347ec22..9079ae9c91c 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -1980,7 +1980,18 @@ describe("fromJsonSchemaDocument", () => { "_tag": "Objects", "checks": [], "propertySignatures": [], - "indexSignatures": [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Never", + "checks": [] + } + } + ] }, "references": {} } @@ -2066,211 +2077,44 @@ describe("fromJsonSchemaDocument", () => { }) it("properties & additionalProperties", () => { - assertFromJsonSchema( - { - schema: { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: { type: "boolean" } - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [ - { - "name": { - "type": "string", - "value": "a" - }, - "type": { - "_tag": "String", - "checks": [] - }, - "isOptional": false, - "isMutable": false - } - ], - "indexSignatures": [ - { - "parameter": { - "_tag": "String", - "checks": [] - }, - "type": { - "_tag": "Boolean", - "checks": [] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) - it("imports a single pattern property", () => { - assertFromJsonSchema( - { - schema: { + it("rejects a closed single pattern property", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", patternProperties: { "a*": { type: "string" } }, additionalProperties: false - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [], - "indexSignatures": [ - { - "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": [] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) - it("imports multiple pattern properties", () => { - assertFromJsonSchema( - { - schema: { + it("rejects closed multiple pattern properties", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", patternProperties: { "a*": { type: "string" }, "b*": { type: "number" } }, additionalProperties: false - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [], - "indexSignatures": [ - { - "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": [] - } - }, - { - "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", - "representation": { - "id": "effect/schema/isFinite", - "payload": null - }, - "annotations": { - "expected": "a finite number", - "arbitrary": { - "constraint": { - "noInfinity": true, - "noNaN": true - } - } - }, - "aborted": false - } - ] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) @@ -3283,7 +3127,18 @@ describe("fromJsonSchemaDocument", () => { }, "checks": [], "propertySignatures": [], - "indexSignatures": [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Never", + "checks": [] + } + } + ] }, "references": {} } @@ -5424,28 +5279,128 @@ describe("fromJsonSchemaDocument", () => { } }) - it("merges object index signatures", () => { - 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) + it("keeps additionalProperties scopes separate", () => { + for ( + const schema of [ + { + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } } }] + }, + { + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: false }] + } + ] + ) { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema))) + assertTrue(is({})) + assertFalse(is({ a: "a" })) } }) + it("does not move sibling properties into a closed scope", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + allOf: [{ properties: { b: { type: "number" } } }] + }))) + assertTrue(is({ a: "a" })) + assertFalse(is({ b: 1 })) + assertFalse(is({ a: "a", b: 1 })) + }) + + it("rejects an object when a required sibling property is outside a closed scope", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } }, required: ["a"] }] + }))) + assertFalse(is({})) + assertFalse(is({ a: "a" })) + }) + + it("keeps object keyword scopes through references", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "#/$defs/Closed", + allOf: [{ properties: { a: { type: "string" } } }], + $defs: { + Closed: { type: "object", additionalProperties: false } + } + }))) + assertTrue(is({})) + assertFalse(is({ a: "a" })) + }) + + it("applies a sibling additionalProperties schema to fixed properties", () => { + const schema = toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: { type: "boolean" } }] + })) + const is = Schema.is(schema) + assertTrue(is({ b: true })) + assertFalse(is({ a: "a" })) + assertFalse(is({ b: "b" })) + deepStrictEqual(Schema.toJsonSchemaDocument(schema).schema, { + type: "object", + properties: { a: { not: {} } }, + allOf: [{ type: "object", additionalProperties: { type: "boolean" } }] + }) + }) + + it("lowers open patterns over a finite object domain", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + allOf: [{ patternProperties: { "^a$": { minLength: 2 } } }] + }))) + assertTrue(is({ a: "aa" })) + assertFalse(is({ a: "a" })) + }) + + it("preserves open pattern scopes", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + patternProperties: { "^a": { type: "string" } } + }))) + assertTrue(is({ a: "a", b: 1 })) + assertFalse(is({ a: 1 })) + }) + + it("applies open patterns to fixed properties", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + patternProperties: { "^a$": { minLength: 2 } } + }))) + assertTrue(is({ a: "aa" })) + assertFalse(is({ a: "a" })) + }) + + it("rejects object scopes that require pattern complements", () => { + throws( + () => + 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" } } + } + ] + })), + `Unsupported object keyword scopes\n at ["schema"]` + ) + }) + describe("type: object", () => { it("add properties", () => { assertFromJsonSchema( @@ -5469,7 +5424,7 @@ describe("fromJsonSchemaDocument", () => { "value": "a" }, "type": { - "_tag": "String", + "_tag": "Never", "checks": [] }, "isOptional": true, diff --git a/packages/effect/test/schema/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/toJsonSchemaDocument.test.ts index 333e73b057d..5ec13ef263f 100644 --- a/packages/effect/test/schema/toJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/toJsonSchemaDocument.test.ts @@ -3021,27 +3021,50 @@ describe("toJsonSchemaDocument", () => { }) }) - it("StructWithRest", () => { - assertJsonSchemaDocument( - Schema.StructWithRest(Schema.Struct({ a: Schema.String }), [ - Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])) - ]), - { - schema: { - "type": "object", - "properties": { - "a": { "type": "string" } - }, - "additionalProperties": { - "anyOf": [ - { "type": "number" }, - { "type": "string" } - ] - }, - "required": ["a"] + describe("StructWithRest", () => { + it("property and string index", () => { + assertJsonSchemaDocument( + Schema.StructWithRest(Schema.Struct({ a: Schema.String }), [ + Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])) + ]), + { + schema: { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "allOf": [{ + "type": "object", + "additionalProperties": { + "anyOf": [ + { "type": "number" }, + { "type": "string" } + ] + } + }], + "required": ["a"] + } } - } - ) + ) + }) + + it("pattern and string indexes", () => { + assertJsonSchemaDocument( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + Schema.Record(Schema.String, Schema.Boolean) + ]), + { + schema: { + type: "object", + patternProperties: { + "^[^a-z]*$": { type: "number" } + }, + allOf: [{ type: "object", additionalProperties: { type: "boolean" } }] + } + } + ) + }) }) describe("Tuple", () => { From 52de8d9db53d2fd5820cddf40613bc75800f48ec Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 18 Aug 2026 11:23:31 +0200 Subject: [PATCH 2/2] Fix JSON Schema code generation test fixture --- .../test/schema/representation/toCodeDocument.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/effect/test/schema/representation/toCodeDocument.test.ts b/packages/effect/test/schema/representation/toCodeDocument.test.ts index 608966ca266..206ae1c9e5c 100644 --- a/packages/effect/test/schema/representation/toCodeDocument.test.ts +++ b/packages/effect/test/schema/representation/toCodeDocument.test.ts @@ -1824,7 +1824,8 @@ describe("toCodeDocument", () => { properties: { a: { type: "string" - } + }, + b: {} }, required: ["a"] } @@ -1835,7 +1836,8 @@ describe("toCodeDocument", () => { properties: { b: { type: "number" - } + }, + a: {} }, required: ["b"] }