diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index 898640aff..e5f794e97 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -341,6 +341,7 @@ class Run implements RunContext { graph, stringTypeMapping, conflateNumbers, + targetLanguage.supportsUnionsWithMultipleObjectTypes, true, debugPrintReconstitution, ); @@ -371,6 +372,7 @@ class Run implements RunContext { graph, stringTypeMapping, conflateNumbers, + targetLanguage.supportsUnionsWithMultipleObjectTypes, false, debugPrintReconstitution, ); @@ -435,6 +437,7 @@ class Run implements RunContext { graph, stringTypeMapping, conflateNumbers, + targetLanguage.supportsUnionsWithMultipleObjectTypes, false, debugPrintReconstitution, ); @@ -484,6 +487,7 @@ class Run implements RunContext { graph, stringTypeMapping, conflateNumbers, + targetLanguage.supportsUnionsWithMultipleObjectTypes, false, debugPrintReconstitution, ); diff --git a/packages/quicktype-core/src/TargetLanguage.ts b/packages/quicktype-core/src/TargetLanguage.ts index f0617b739..0a96df255 100644 --- a/packages/quicktype-core/src/TargetLanguage.ts +++ b/packages/quicktype-core/src/TargetLanguage.ts @@ -111,6 +111,10 @@ export abstract class TargetLanguage< return false; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return false; + } + public get supportsFullObjectType(): boolean { return false; } diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts index 0c930a99e..5646b6280 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts @@ -86,6 +86,10 @@ export class TypeScriptTargetLanguage extends TargetLanguage< return true; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return true; + } + public get supportsFullObjectType(): boolean { return true; } diff --git a/packages/quicktype-core/src/rewrites/FlattenUnions.ts b/packages/quicktype-core/src/rewrites/FlattenUnions.ts index aab4d8511..1e378afa6 100644 --- a/packages/quicktype-core/src/rewrites/FlattenUnions.ts +++ b/packages/quicktype-core/src/rewrites/FlattenUnions.ts @@ -1,4 +1,4 @@ -import { iterableSome, setFilter } from "collection-utils"; +import { iterableEvery, iterableSome, setFilter } from "collection-utils"; import { emptyTypeAttributes } from "../attributes/TypeAttributes.js"; import type { GraphRewriteBuilder } from "../GraphRewriting.js"; @@ -8,13 +8,17 @@ import { IntersectionType, type Type, UnionType } from "../Type/Type.js"; import type { StringTypeMapping } from "../Type/TypeBuilderUtils.js"; import type { TypeGraph } from "../Type/TypeGraph.js"; import { type TypeRef, derefTypeRef, typeRefIndex } from "../Type/TypeRef.js"; -import { makeGroupsToFlatten } from "../Type/TypeUtils.js"; +import { + makeGroupsToFlatten, + setOperationMembersRecursively, +} from "../Type/TypeUtils.js"; import { UnifyUnionBuilder, unifyTypes } from "../UnifyClasses.js"; export function flattenUnions( graph: TypeGraph, stringTypeMapping: StringTypeMapping, conflateNumbers: boolean, + supportsUnionsWithMultipleObjectTypes: boolean, makeObjectTypes: boolean, debugPrintReconstitution: boolean, ): [TypeGraph, boolean] { @@ -157,8 +161,42 @@ export function flattenUnions( (t) => t instanceof UnionType, ) as Set; const nonCanonicalUnions = setFilter(allUnions, (u) => !u.isCanonical); + // IntersectionAccumulator can only represent one member of each kind, so + // object unions that participate in intersections still need flattening. + const unionsInIntersections = new Set(); + if (supportsUnionsWithMultipleObjectTypes) { + for (const t of graph.allTypesUnordered()) { + if (!(t instanceof IntersectionType)) continue; + for (const member of setOperationMembersRecursively( + t, + undefined, + )[0]) { + if (member instanceof UnionType) { + unionsInIntersections.add(member); + } + } + } + } + + const unionsToFlatten = setFilter(nonCanonicalUnions, (u) => { + if ( + !supportsUnionsWithMultipleObjectTypes || + unionsInIntersections.has(u) + ) { + return true; + } + + const members = setOperationMembersRecursively(u, undefined)[0]; + return ( + members.size <= 1 || + !iterableEvery( + members, + (m) => m.kind === "class" || m.kind === "object", + ) + ); + }); let foundIntersection = false; - const groups = makeGroupsToFlatten(nonCanonicalUnions, (members) => { + const groups = makeGroupsToFlatten(unionsToFlatten, (members) => { messageAssert(members.size > 0, "IRNoEmptyUnions", {}); if (!iterableSome(members, (m) => m instanceof IntersectionType)) return true; diff --git a/test/inputs/schema/anyof-object-union.1.fail.class-union.json b/test/inputs/schema/anyof-object-union.1.fail.class-union.json new file mode 100644 index 000000000..8d0937455 --- /dev/null +++ b/test/inputs/schema/anyof-object-union.1.fail.class-union.json @@ -0,0 +1,6 @@ +{ + "firstProperty": { + "a": "alpha", + "d": "delta" + } +} diff --git a/test/inputs/schema/anyof-object-union.1.json b/test/inputs/schema/anyof-object-union.1.json new file mode 100644 index 000000000..c865e790f --- /dev/null +++ b/test/inputs/schema/anyof-object-union.1.json @@ -0,0 +1,12 @@ +{ + "firstProperty": { + "a": "alpha", + "b": 1, + "c": true + }, + "secondProperty": { + "d": "delta", + "e": 2, + "f": false + } +} diff --git a/test/inputs/schema/anyof-object-union.schema b/test/inputs/schema/anyof-object-union.schema new file mode 100644 index 000000000..7b68f6931 --- /dev/null +++ b/test/inputs/schema/anyof-object-union.schema @@ -0,0 +1,44 @@ +{ + "$ref": "#/definitions/TestObject", + "definitions": { + "TestObject": { + "type": "object", + "additionalProperties": false, + "properties": { + "firstProperty": { + "anyOf": [ + { "$ref": "#/definitions/MyObjectA" }, + { "$ref": "#/definitions/MyObjectB" } + ] + }, + "secondProperty": { + "anyOf": [ + { + "type": "array", + "items": { "$ref": "#/definitions/MyObjectA" } + }, + { "$ref": "#/definitions/MyObjectB" } + ] + } + } + }, + "MyObjectA": { + "type": "object", + "additionalProperties": false, + "properties": { + "a": { "type": "string" }, + "b": { "type": "number" }, + "c": { "type": "boolean" } + } + }, + "MyObjectB": { + "type": "object", + "additionalProperties": false, + "properties": { + "d": { "type": "string" }, + "e": { "type": "number" }, + "f": { "type": "boolean" } + } + } + } +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..1cb46b412 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -70,6 +70,7 @@ const skipsArrayElementValidation = ["issue2680-top-level-array.schema"]; export type LanguageFeature = | "enum" | "union" + | "class-union" | "no-defaults" | "strict-optional" | "date-time" @@ -1115,7 +1116,14 @@ export const TypeScriptLanguage: Language = { "e8b04.json", ], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "strict-optional", "date-time"], + features: [ + "enum", + "union", + "class-union", + "no-defaults", + "strict-optional", + "date-time", + ], output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [], @@ -1463,6 +1471,9 @@ export const KotlinLanguage: Language = { "top-level-enum.schema", "top-level-primitive.schema", "recursive-union-flattening.schema", + // Pre-existing Klaxon constructor-matching limitation for merged + // multi-object unions, unrelated to the TypeScript-only union change. + "anyof-object-union.schema", // A top-level array is deserialized without enforcing its element // type, so a mistyped element round-trips instead of failing. ...skipsArrayElementValidation, @@ -1677,6 +1688,11 @@ export const KotlinXLanguage: Language = { // deserialization fails at runtime (documented TODO in // KotlinXRenderer.ts). "accessors.schema", + // secondProperty is an array|object union, which the kotlinx + // renderer emits as a sealed class without serializer wiring, so it + // fails to deserialize at runtime (documented TODO in + // KotlinXRenderer.ts). + "anyof-object-union.schema", "bool-string.schema", "class-map-union.schema", "class-with-additional.schema",