From 19b4efba72babcae9c614028daf36be0645a95ab Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:20:37 -0400 Subject: [PATCH 1/2] fix(schema): respect "required" for properties inside oneOf/anyOf (#1104) Co-Authored-By: gpt-5.6-sol via pi --- packages/quicktype-core/src/UnifyClasses.ts | 29 ++++++++++++++----- .../quicktype-core/src/attributes/Schema.ts | 18 ++++++++++++ .../src/input/JSONSchemaInput.ts | 6 ++++ .../src/rewrites/FlattenUnions.ts | 9 ++++++ test/inputs/schema/required-in-any-of.1.json | 5 ++++ ...ired-in-any-of.2.fail.strict-optional.json | 3 ++ test/inputs/schema/required-in-any-of.schema | 29 +++++++++++++++++++ 7 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 packages/quicktype-core/src/attributes/Schema.ts create mode 100644 test/inputs/schema/required-in-any-of.1.json create mode 100644 test/inputs/schema/required-in-any-of.2.fail.strict-optional.json create mode 100644 test/inputs/schema/required-in-any-of.schema diff --git a/packages/quicktype-core/src/UnifyClasses.ts b/packages/quicktype-core/src/UnifyClasses.ts index b6a5a6efb9..9fbca9b921 100644 --- a/packages/quicktype-core/src/UnifyClasses.ts +++ b/packages/quicktype-core/src/UnifyClasses.ts @@ -26,6 +26,7 @@ function getCliqueProperties( clique: ObjectType[], builder: TypeBuilder, makePropertyType: (types: ReadonlySet) => TypeRef, + preserveSchemaRequiredProperties: boolean, ): [ReadonlyMap, TypeRef | undefined, boolean] { const lostTypeAttributes = false; const propertyNames = new Set(); @@ -53,7 +54,15 @@ function getCliqueProperties( let [name, types, isOptional] = property; const maybeProperty = o.getProperties().get(name); if (maybeProperty === undefined) { - isOptional = true; + // An open alternative can still contain this property as + // optional additional data. + if ( + !preserveSchemaRequiredProperties || + additional !== undefined + ) { + isOptional = true; + } + if (additional !== undefined && additional.kind !== "any") { types.add(additional); } @@ -126,6 +135,7 @@ export class UnifyUnionBuilder extends UnionBuilder< private readonly _makeObjectTypes: boolean, private readonly _makeClassesFixed: boolean, private readonly _unifyTypes: (typesToUnify: TypeRef[]) => TypeRef, + private readonly _preserveSchemaRequiredProperties = false, ) { super(typeBuilder); } @@ -193,12 +203,17 @@ export class UnifyUnionBuilder extends UnionBuilder< ); } else { const [properties, additionalProperties, lostTypeAttributes] = - getCliqueProperties(objectTypes, this.typeBuilder, (types) => { - assert(types.size > 0, "Property has no type"); - return this._unifyTypes( - Array.from(types).map((t) => t.typeRef), - ); - }); + getCliqueProperties( + objectTypes, + this.typeBuilder, + (types) => { + assert(types.size > 0, "Property has no type"); + return this._unifyTypes( + Array.from(types).map((t) => t.typeRef), + ); + }, + this._preserveSchemaRequiredProperties, + ); if (lostTypeAttributes) { this.typeBuilder.setLostTypeAttributes(); } diff --git a/packages/quicktype-core/src/attributes/Schema.ts b/packages/quicktype-core/src/attributes/Schema.ts new file mode 100644 index 0000000000..dfc24209ca --- /dev/null +++ b/packages/quicktype-core/src/attributes/Schema.ts @@ -0,0 +1,18 @@ +import { TypeAttributeKind } from "./TypeAttributes.js"; + +class SchemaSetOperationTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("schemaSetOperation"); + } + + public combine(_: true[]): true { + return true; + } + + public makeInferred(_: true): true { + return true; + } +} + +export const schemaSetOperationTypeAttributeKind: TypeAttributeKind = + new SchemaSetOperationTypeAttributeKind(); diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index efd2ccbef9..d985daa19f 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -32,6 +32,7 @@ import { import { descriptionAttributeProducer } from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; import { StringTypes } from "../attributes/StringTypes.js"; +import { schemaSetOperationTypeAttributeKind } from "../attributes/Schema.js"; import { type TypeAttributes, combineTypeAttributes, @@ -1245,6 +1246,11 @@ async function addTypesInSchema( ); } + unionAttributes = combineTypeAttributes( + "union", + unionAttributes, + schemaSetOperationTypeAttributeKind.makeAttributes(true), + ); const unionType = typeBuilder.getUniqueUnionType( unionAttributes, undefined, diff --git a/packages/quicktype-core/src/rewrites/FlattenUnions.ts b/packages/quicktype-core/src/rewrites/FlattenUnions.ts index aab4d85119..559d1442c6 100644 --- a/packages/quicktype-core/src/rewrites/FlattenUnions.ts +++ b/packages/quicktype-core/src/rewrites/FlattenUnions.ts @@ -1,5 +1,6 @@ import { iterableSome, setFilter } from "collection-utils"; +import { schemaSetOperationTypeAttributeKind } from "../attributes/Schema.js"; import { emptyTypeAttributes } from "../attributes/TypeAttributes.js"; import type { GraphRewriteBuilder } from "../GraphRewriting.js"; import { messageAssert } from "../Messages.js"; @@ -136,11 +137,19 @@ export function flattenUnions( ); }; + const isSchemaSetOperation = iterableSome( + types, + (t) => + schemaSetOperationTypeAttributeKind.tryGetInAttributes( + t.getAttributes(), + ) !== undefined, + ); unionBuilder = new UnifyUnionBuilder( builder, makeObjectTypes, true, unifyTypeRefs, + isSchemaSetOperation, ); return unifyTypes( types, diff --git a/test/inputs/schema/required-in-any-of.1.json b/test/inputs/schema/required-in-any-of.1.json new file mode 100644 index 0000000000..8075931d89 --- /dev/null +++ b/test/inputs/schema/required-in-any-of.1.json @@ -0,0 +1,5 @@ +{ + "anyof": { + "name": "example" + } +} diff --git a/test/inputs/schema/required-in-any-of.2.fail.strict-optional.json b/test/inputs/schema/required-in-any-of.2.fail.strict-optional.json new file mode 100644 index 0000000000..1f3ac8a9bc --- /dev/null +++ b/test/inputs/schema/required-in-any-of.2.fail.strict-optional.json @@ -0,0 +1,3 @@ +{ + "anyof": {} +} diff --git a/test/inputs/schema/required-in-any-of.schema b/test/inputs/schema/required-in-any-of.schema new file mode 100644 index 0000000000..02c18d1827 --- /dev/null +++ b/test/inputs/schema/required-in-any-of.schema @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "anyof": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "size": { "type": "string" } + }, + "required": [], + "minProperties": 1, + "additionalProperties": false + } + ] + } + }, + "required": ["anyof"], + "additionalProperties": false +} From 2cfe180857b8c83c39a537e4bf99dbae1d344cea Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:20:10 -0400 Subject: [PATCH 2/2] fix(cpp): break cycles for required recursive class members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oneOf/anyOf `required` fix (#1104) can make a self-referential property required, e.g. FacetSpec/RepeatSpec in vega-lite require a nested `spec`. Previously such members were optional and the C++ renderer stored them as `std::shared_ptr` (heap indirection) via the `isCycleBreakerType` path in `isOptionalAsValuePossible`, which broke the cycle. A required member skipped that path and was emitted by value (`Spec spec;`), producing an incomplete recursive type that fails to compile. Mirror the optional behaviour: a required class-typed member that is a cycle-breaker type now also gets heap indirection, so recursive required members compile. Verified all 71 schema-cplusplus fixtures pass, including vega-lite. Also scope out the new `required-in-any-of.schema` for Elixir, which — like the existing `strict-optional.schema`/`required.schema` — cannot enforce required struct keys at runtime, so its `.fail.strict-optional` sample does not fail as the fixture expects. Co-Authored-By: Claude --- .../src/language/CPlusPlus/CPlusPlusRenderer.ts | 14 ++++++++++++-- test/languages.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts index 55e371d735..7cec329e83 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts @@ -784,8 +784,18 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { this.variantIndirection( classType, ctx.needsForwardIndirection && - this.isForwardDeclaredType(classType) && - !isOptional, + !isOptional && + // A required class-typed member still needs heap + // indirection when the class participates in a cycle, + // otherwise it would be stored by value and form an + // incomplete recursive type. Optional members already + // get this through `isCycleBreakerType` in + // `isOptionalAsValuePossible`; mirror that here so that + // required recursive members (e.g. a `oneOf`/`anyOf` + // branch that requires a self-referential property) + // compile. + (this.isForwardDeclaredType(classType) || + this.isCycleBreakerType(classType)), [ this.ourQualifier(inJsonNamespace), this.nameForNamedType(classType), diff --git a/test/languages.ts b/test/languages.ts index 7f80f5e3b1..48d3466afd 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -2153,6 +2153,7 @@ export const ElixirLanguage: Language = { // Struct keys cannot be enforced at runtime in Elixir and their values will just be set to null. "strict-optional.schema", "required.schema", + "required-in-any-of.schema", "intersection.schema", // The test incorrectly succeeds due to the emitter being permissive for unions that contain only primitives. A future enhancement