Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/quicktype-core/src/Run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ class Run implements RunContext {
stringTypeMapping,
conflateNumbers,
true,
targetLanguage.supportsUnionsWithMultipleObjectTypes,
debugPrintReconstitution,
);
});
Expand Down Expand Up @@ -372,6 +373,7 @@ class Run implements RunContext {
stringTypeMapping,
conflateNumbers,
false,
targetLanguage.supportsUnionsWithMultipleObjectTypes,
debugPrintReconstitution,
);
});
Expand Down Expand Up @@ -436,6 +438,7 @@ class Run implements RunContext {
stringTypeMapping,
conflateNumbers,
false,
targetLanguage.supportsUnionsWithMultipleObjectTypes,
debugPrintReconstitution,
);
});
Expand Down Expand Up @@ -485,6 +488,7 @@ class Run implements RunContext {
stringTypeMapping,
conflateNumbers,
false,
targetLanguage.supportsUnionsWithMultipleObjectTypes,
debugPrintReconstitution,
);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/quicktype-core/src/TargetLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export abstract class TargetLanguage<
return false;
}

public get supportsUnionsWithMultipleObjectTypes(): boolean {
return false;
}

public needsTransformerForType(_t: Type): boolean {
return false;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/quicktype-core/src/Type/Type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
mapSortToArray,
setFilter,
setMap,
setSortBy,
setUnionInto,
toReadonlySet,
} from "collection-utils";
Expand Down Expand Up @@ -861,8 +860,12 @@ export abstract class SetOperationType extends Type {
}

public getNonAttributeChildren(): Set<Type> {
// FIXME: We're assuming no two members of the same kind.
return setSortBy(this.members, (t) => t.kind);
// Opted-in targets can retain same-kind union members.
const members = Array.from(this.members).sort((a, b) => {
const kindOrder = a.kind.localeCompare(b.kind);
return kindOrder !== 0 ? kindOrder : a.index - b.index;
});
return new Set(members);
}

public isPrimitive(): this is PrimitiveType {
Expand Down
63 changes: 63 additions & 0 deletions packages/quicktype-core/src/UnifyClasses.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { iterableFirst, setUnionInto } from "collection-utils";

import { explicitUnionMemberTypeAttributeKind } from "./attributes/ExplicitUnionMember.js";
import {
type TypeAttributes,
combineTypeAttributes,
Expand Down Expand Up @@ -125,6 +126,7 @@ export class UnifyUnionBuilder extends UnionBuilder<
typeBuilder: BaseGraphRewriteBuilder,
private readonly _makeObjectTypes: boolean,
private readonly _makeClassesFixed: boolean,
private readonly _supportsUnionsWithMultipleObjectTypes: boolean,
private readonly _unifyTypes: (typesToUnify: TypeRef[]) => TypeRef,
) {
super(typeBuilder);
Expand Down Expand Up @@ -159,6 +161,20 @@ export class UnifyUnionBuilder extends UnionBuilder<
const objectTypes = objectRefs.map((r) =>
assertIsObject(derefTypeRef(r, this.typeBuilder)),
);
if (
this._supportsUnionsWithMultipleObjectTypes &&
this.canKeepObjectTypesDistinct(objectTypes, typeAttributes)
) {
const members = new Set(
objectRefs.map((r) => this.typeBuilder.reconstituteTypeRef(r)),
);
return this.typeBuilder.getUnionType(
typeAttributes,
members,
forwardingRef,
);
}

const {
hasProperties,
hasAdditionalProperties,
Expand Down Expand Up @@ -225,6 +241,52 @@ export class UnifyUnionBuilder extends UnionBuilder<
}
}

private canKeepObjectTypesDistinct(
objectTypes: ObjectType[],
typeAttributes: TypeAttributes,
): boolean {
if (objectTypes.length < 2) return false;

// Every member must come from the same explicit union of direct schema
// references. Counting distinct members in each group prevents a marked
// type from changing an unrelated union in which that type is later reused.
const groups =
explicitUnionMemberTypeAttributeKind.tryGetInAttributes(
typeAttributes,
);
if (
groups === undefined ||
!Array.from(groups.values()).some(
(members) => members.size === objectTypes.length,
)
) {
return false;
}

const typeNames = new Set<string>();
const propertyNames = new Set<string>();
for (const objectType of objectTypes) {
if (
!objectType.hasNames ||
objectType.getProperties().size === 0 ||
objectType.getAdditionalProperties() !== undefined
) {
return false;
}

const typeName = objectType.getCombinedName();
if (typeNames.has(typeName)) return false;
typeNames.add(typeName);

for (const propertyName of objectType.getProperties().keys()) {
if (propertyNames.has(propertyName)) return false;
propertyNames.add(propertyName);
}
}

return true;
}

protected makeArray(
arrays: TypeRef[],
typeAttributes: TypeAttributes,
Expand All @@ -249,6 +311,7 @@ export function unionBuilderForUnification<T extends Type>(
typeBuilder,
makeObjectTypes,
makeClassesFixed,
false,
(trefs) =>
unifyTypes(
new Set(trefs.map((tref) => derefTypeRef(tref, typeBuilder))),
Expand Down
47 changes: 47 additions & 0 deletions packages/quicktype-core/src/attributes/ExplicitUnionMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type TypeAttributes, TypeAttributeKind } from "./TypeAttributes.js";

export type ExplicitUnionMemberGroups = ReadonlyMap<
symbol,
ReadonlySet<symbol>
>;

class ExplicitUnionMemberTypeAttributeKind extends TypeAttributeKind<ExplicitUnionMemberGroups> {
public constructor() {
super("explicit-union-member");
}

public combine(
values: ExplicitUnionMemberGroups[],
): ExplicitUnionMemberGroups {
const combined = new Map<symbol, Set<symbol>>();
for (const groups of values) {
for (const [group, members] of groups) {
const combinedMembers = combined.get(group) ?? new Set();
for (const member of members) combinedMembers.add(member);
combined.set(group, combinedMembers);
}
}
return combined;
}

public makeInferred(
value: ExplicitUnionMemberGroups,
): ExplicitUnionMemberGroups {
return value;
}

public stringify(_value: ExplicitUnionMemberGroups): string {
return "explicit union member";
}
}

export const explicitUnionMemberTypeAttributeKind: TypeAttributeKind<ExplicitUnionMemberGroups> =
new ExplicitUnionMemberTypeAttributeKind();

export function makeExplicitUnionMemberAttributes(
group: symbol,
): TypeAttributes {
return explicitUnionMemberTypeAttributeKind.makeAttributes(
new Map([[group, new Set([Symbol()])]]),
);
}
21 changes: 21 additions & 0 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js";
import { descriptionAttributeProducer } from "../attributes/Description.js";
import { enumValuesAttributeProducer } from "../attributes/EnumValues.js";
import { makeExplicitUnionMemberAttributes } from "../attributes/ExplicitUnionMember.js";
import { StringTypes } from "../attributes/StringTypes.js";
import {
type TypeAttributes,
Expand Down Expand Up @@ -1215,6 +1216,26 @@ async function addTypesInSchema(
kind: string,
): Promise<TypeRef> {
const typeRefs = await makeTypesFromCases(cases, kind);
if (
(kind === "oneOf" || kind === "anyOf") &&
cases.length >= 2 &&
cases.every(
(c) =>
c !== null &&
typeof c === "object" &&
!Array.isArray(c) &&
typeof (c as { $ref?: unknown }).$ref === "string",
)
) {
const explicitUnionGroup = Symbol();
for (const typeRef of typeRefs) {
typeBuilder.addAttributes(
typeRef,
makeExplicitUnionMemberAttributes(explicitUnionGroup),
);
}
}

let unionAttributes = makeTypeAttributesInferred(typeAttributes);
if (kind === "oneOf") {
forEachProducedAttribute(
Expand Down
4 changes: 4 additions & 0 deletions packages/quicktype-core/src/language/Python/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export class PythonTargetLanguage extends TargetLanguage<
return false;
}

public get supportsUnionsWithMultipleObjectTypes(): boolean {
return true;
}

public needsTransformerForType(t: Type): boolean {
if (t instanceof UnionType) {
return iterableSome(t.members, (m) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/quicktype-core/src/rewrites/FlattenUnions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function flattenUnions(
stringTypeMapping: StringTypeMapping,
conflateNumbers: boolean,
makeObjectTypes: boolean,
supportsUnionsWithMultipleObjectTypes: boolean,
debugPrintReconstitution: boolean,
): [TypeGraph, boolean] {
let needsRepeat = false;
Expand Down Expand Up @@ -140,6 +141,7 @@ export function flattenUnions(
builder,
makeObjectTypes,
true,
supportsUnionsWithMultipleObjectTypes,
unifyTypeRefs,
);
return unifyTypes(
Expand Down
3 changes: 3 additions & 0 deletions test/inputs/schema/named-class-union.1.fail.union.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"op": []
}
5 changes: 5 additions & 0 deletions test/inputs/schema/named-class-union.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"op": {
"foo": "foo value"
}
}
5 changes: 5 additions & 0 deletions test/inputs/schema/named-class-union.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"op": {
"bar": "bar value"
}
}
33 changes: 33 additions & 0 deletions test/inputs/schema/named-class-union.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Container",
"type": "object",
"additionalProperties": false,
"properties": {
"op": {
"oneOf": [
{ "$ref": "#/definitions/Foo" },
{ "$ref": "#/definitions/Bar" }
]
}
},
"required": ["op"],
"definitions": {
"Foo": {
"type": "object",
"additionalProperties": false,
"properties": {
"foo": { "type": "string" }
},
"required": ["foo"]
},
"Bar": {
"type": "object",
"additionalProperties": false,
"properties": {
"bar": { "type": "string" }
},
"required": ["bar"]
}
}
}
25 changes: 18 additions & 7 deletions test/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,8 @@ export const CJSONLanguage: Language = {
* an integer is expected) are not checked either. */
...skipsArrayElementValidation,
"multi-type-enum.schema",
/* Named object unions merge into an optional-properties class, and wrong-shaped values are not rejected. */
"named-class-union.schema",
"nested-intersection-union.schema",
"prefix-items.schema",
/* Constraints (min/max and regex) are not supported (for the current implementation, can be added later, should abord parsing and return NULL) */
Expand Down Expand Up @@ -829,7 +831,9 @@ export const CPlusPlusLanguage: Language = {
"integer-before-number.schema", // Python-specific union-order regression.
// uses too much memory
"keyword-unions.schema",
// The generated deserializer accepts non-object values when all class properties are optional.
// Named object unions merge into a class with all-optional properties,
// whose generated deserializer accepts non-object values.
"named-class-union.schema",
"nested-intersection-union.schema",
// Recursive top-level unions produce aliases that can refer to later aliases.
"recursive-union-flattening.schema",
Expand Down Expand Up @@ -935,8 +939,9 @@ export const ElmLanguage: Language = {
// property decodes to the object's constructor function.
"constructor.schema",
"keyword-unions.schema",
// The generated decoder accepts invalid union members because all
// class properties decode via `Jpipe.optional`.
// Named object unions merge into a class whose properties all decode
// via `Jpipe.optional`, which accepts invalid union members.
"named-class-union.schema",
"nested-intersection-union.schema",
],
rendererOptions: {},
Expand Down Expand Up @@ -1439,8 +1444,9 @@ export const KotlinLanguage: Language = {
// which is not represented in the types (implicit-class-array-union);
// class-map-union: KlaxonException: Couldn't find a suitable constructor for class UnionValue to initialize with {}
...skipsUntypedUnions,
// Deserializes an array where a union of two classes is expected
// instead of rejecting it.
// Named object unions merge into one optional-properties class, which
// deserializes an array instead of rejecting it.
"named-class-union.schema",
"nested-intersection-union.schema",
"class-with-additional.schema",
...skipsMapValueValidation,
Expand Down Expand Up @@ -1534,8 +1540,9 @@ export const KotlinJacksonLanguage: Language = {
// which is not represented in the types (implicit-class-array-union);
// class-map-union: KlaxonException: Couldn't find a suitable constructor for class UnionValue to initialize with {}
...skipsUntypedUnions,
// Deserializes an array where a union of two classes is expected
// instead of rejecting it.
// Named object unions merge into one optional-properties class, which
// deserializes an array instead of rejecting it.
"named-class-union.schema",
"nested-intersection-union.schema",
"class-with-additional.schema",
...skipsMapValueValidation,
Expand Down Expand Up @@ -1902,6 +1909,10 @@ export const HaskellLanguage: Language = {
skipSchema: [
"integer-before-number.schema", // Python-specific union-order regression.
...skipsUntypedUnions,
// Named object unions merge into an optional-properties class. The test
// driver encodes the Maybe result, so a failed decode prints "null" and
// exits 0 — expected-failure samples cannot be detected.
"named-class-union.schema",
// The test driver encodes the Maybe result, so a failed decode prints
// "null" and exits 0 — expected-failure samples cannot be detected.
// (A top-level `[Int]` correctly fails to decode `[1, 2, "three"]`,
Expand Down
Loading
Loading