Skip to content

Commit b10f7fa

Browse files
dinwwwhclaude
andauthored
fix(downgrader): merge the literals of each type in a mixed-type union in 3.1 to 3.0 (#135)
Zod, ArkType and Valibot write `'a' | 'b' | number` as an `anyOf` with a `const` branch for each literal, and utoipa writes the unit variants of a Rust enum beside its other variants. mergeEnums only merged unions whose enums all share one type, so these stayed an `anyOf` of one-value enums, which NSwag and OpenAPI Generator read as an empty class and oapi-codegen and openapi-python-client as one type per literal. The enums of each type now become one at the first one's place, so scalarOneOf can make a `oneOf` of a union of scalars. A `oneOf` whose branches share a value stays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JU51h5pQGFxxMWjNUcAMz
1 parent aae889f commit b10f7fa

3 files changed

Lines changed: 120 additions & 6 deletions

File tree

‎packages/downgrader/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
185185
| An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, unknown, or `$ref`, Zod and Valibot a nullable nullish one with such a pair as the other branch, and Effect a nullish one with `{ type: "null" }` twice | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that holds a `type` where the schema around it holds one, or another keyword that schema holds with another value, or holds `properties` or `additionalProperties` where that schema holds the other, since `additionalProperties` reads the `properties` beside it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, alone or with siblings such as the `description` Zod writes beside a described reference, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. A `$ref` to a schema whose `type` already holds `"null"`, as GitHub writes, takes the place of the `anyOf` when nothing else sits beside it, as 3.0 writes a nullable reference. A branch that accepts every value, such as `{}`, which Zod writes for a nullable unknown and Pydantic for `Optional[Any]`, removes the `anyOf`, since the pair then accepts every value too, and code generators read it as a union with a string or as a type they never define. Its annotations, such as the `description` Zod writes there for a described unknown, take its place, unless the schema around it has one of them with another value. A null branch whose other keywords repeat the schema around it, as ArkType writes the description of a described nullable value into every branch, counts as `{ type: "null" }`. |
186186
| A [`oneOf`][js-one-of] of `{ type: "null" }` and one other branch, as typia and Litestar write a nullable value | That branch with `nullable: true` in place of the `oneOf`, under the same conditions as the `anyOf` pair above but only with a single null branch and a branch without `nullable`, since null then matches one branch only. Otherwise the `oneOf` stays. |
187187
| An [`anyOf`][js-any-of] of scalar types, or of them and `"array"`, as ArkType, Valibot and Pydantic write a union of them and Effect a number, and a [`type`][js-type] list of them, as Zod writes it and ASP.NET Core a number that may come as a string | A `oneOf` in place of the `anyOf` when each branch has a scalar or `"array"` `type` of its own and is not `nullable`, they do not name both `"integer"` and `"number"`, and no `enum` or `oneOf` sits beside them, since no value then matches two branches, with null in it once where Effect writes it twice for a nullish number. Such a `oneOf` with nothing beside it but annotations, as a nullable union of them becomes, joins the branches around it, and its annotations, such as the `description` Zod writes there for a described union, move to the schema around it, unless that schema or another such branch has one of them with another value. NSwag reads that `oneOf` as the first branch, and the OpenAPI Generator TypeScript client as a union, but the `anyOf` as an empty class. A schema a `$ref` points to keeps the `anyOf`, since NSwag gives a property a type it never defines when an `allOf` holds a `$ref` to a `oneOf`, as a `$ref` with siblings becomes. |
188-
| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. A union whose branches all become enums of one `type`, as OpenSearch writes a documented enum, gets that `type` beside it too, since NSwag otherwise reads it as an empty class or as a type it never defines. When those enums hold nothing else, as Zod, Valibot, TypeBox and GitHub write a union of literals, or only keywords the schema around them holds with the same value, as ArkType writes a described one, they become one `enum`, beside any null branch, since code generators read the union as a string or as a union of one-value types. A `oneOf` whose branches share a value stays. |
188+
| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. A union whose branches all become enums of one `type`, as OpenSearch writes a documented enum, gets that `type` beside it too, since NSwag otherwise reads it as an empty class or as a type it never defines. When those enums hold nothing else, as Zod, Valibot, TypeBox and GitHub write a union of literals, or only keywords the schema around them holds with the same value, as ArkType writes a described one, they become one `enum`, beside any null branch, since code generators read the union as a string or as a union of one-value types. Beside branches of other types, as Zod, ArkType and Valibot write a union of string literals and `number`, and utoipa the unit variants of a Rust enum, the enums of each type become one, and a union of scalars then becomes a `oneOf`. A `oneOf` whose branches share a value stays. |
189189
| An [`enum`][js-enum] with no `type`, as ArkType writes a union of literals | The `type` its values share, when they share one, since code generators drop or leave untyped a bare `enum`, or read its integers as numbers. |
190190
| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. |
191191
| [`examples`][js-examples] | The first entry fills `example` when that is missing. |

‎packages/downgrader/src/v3.1-to-v3.0.ts‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -748,11 +748,11 @@ function mergeEnums(out: Record<string, unknown>, key: 'anyOf' | 'oneOf'): void
748748
const branches = out[key] as unknown[]
749749
const enums = branches.filter((branch) => !isNullBranch(branch, out))
750750
const type = enumUnionType(enums)
751-
if (
752-
type === undefined ||
753-
(out.type !== undefined && out.type !== type) ||
754-
out.enum !== undefined
755-
) {
751+
if ((out.type !== undefined && out.type !== type) || out.enum !== undefined) {
752+
return
753+
}
754+
if (type === undefined) {
755+
mergeEnumsByType(out, key)
756756
return
757757
}
758758
const values: unknown[] = []
@@ -786,6 +786,49 @@ function mergeEnums(out: Record<string, unknown>, key: 'anyOf' | 'oneOf'): void
786786
}
787787
}
788788

789+
// Zod, ArkType and Valibot write a union of literals of several types, or of
790+
// literals and other types, as `'a' | 'b' | number`, with a branch for each
791+
// literal, and utoipa so writes the unit variants of a Rust enum beside its
792+
// other variants. The enums of each type become one, so `scalarOneOf` can then
793+
// make a `oneOf` of a union of scalars.
794+
function mergeEnumsByType(out: Record<string, unknown>, key: 'anyOf' | 'oneOf'): void {
795+
const branches = out[key] as unknown[]
796+
const merged = new Map<unknown, { enum: unknown[]; seen: Set<unknown> }>()
797+
const result: unknown[] = []
798+
for (const branch of branches) {
799+
if (
800+
!isRecord(branch) ||
801+
typeof branch.type !== 'string' ||
802+
!Array.isArray(branch.enum) ||
803+
branch.nullable === true ||
804+
!Object.keys(branch).every(
805+
(name) => name === 'type' || name === 'enum' || repeats(out, branch, name),
806+
)
807+
) {
808+
result.push(branch)
809+
continue
810+
}
811+
let group = merged.get(branch.type)
812+
if (group === undefined) {
813+
group = { enum: [], seen: new Set() }
814+
merged.set(branch.type, group)
815+
result.push({ type: branch.type, enum: group.enum })
816+
}
817+
for (const value of branch.enum) {
818+
const valueKey = jsonKey(value)
819+
if (!group.seen.has(valueKey)) {
820+
group.seen.add(valueKey)
821+
group.enum.push(value)
822+
} else if (key === 'oneOf') {
823+
return
824+
}
825+
}
826+
}
827+
if (result.length < branches.length) {
828+
out[key] = result
829+
}
830+
}
831+
789832
const ONE_OF_TYPES = new Set(['array', 'boolean', 'integer', 'number', 'string'])
790833

791834
// Zod writes a union of scalars as a `type` list, which becomes an `anyOf`,

0 commit comments

Comments
 (0)