Generate typed interfaces for discriminated unions - #2267
Conversation
A oneOf union of three or more object types was generated as Object, even though the schema carries a discriminator with a complete mapping. Generate an interface per union, annotated with the discriminator property and its tag-to-type mapping; members implement it, and union-typed properties use it. Unions with two members are unchanged. Builders now fill in const-valued properties, so the discriminator tag never has to be written by hand. Deserialization resolves a union by reading the discriminator tag instead of casting by shape, which could not distinguish two variants with the same property shape. Fixes #2266.
Generated builders now supply const-valued properties as a default, so the constant test's SDK snapshot changes accordingly.
The mapping-coverage guard compared a tag count against a member count. Two tags naming the same member kept those equal while leaving another member uncovered, so it was dropped from the interface and could never be deserialized. Compare the set of covered tokens instead. Discriminator names and tags come from the schema and are emitted inside Java string literals, so escape them. Reserve the identifier that is actually emitted: names.Ident normalizes away characters such as '-', so two distinct candidates could collide after normalization while the uniqueness check saw them as different. Point the changelog entries at this PR.
|
How common are discriminated unions with < 3 members? This implementation makes adding a 3rd member to such a union breaking, which I think is surprising. If the registry doesn't have any discriminated unions now with 1 or 2 members, I think we should consider a break here. If the feature is used, then we might have to deal with the incongruity between unions of different sizes. |
It's a break change anyway going from object -> typed thing probably. I wonder if we should add a flag to let providers opt-in to this break change. |
Member count no longer decides how a discriminated union is generated, so adding a member to one is not a breaking change. A package opts in through language.java.fullyTypedUnions; without it every union keeps the shape it has today.
There was a problem hiding this comment.
✅ No issues found
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
|
At least for a provider like azure-native, there's a few hundred < 3 member discriminated unions, and also a few hundred that are 3+. So, both are large.
Would the idea be with this flag enabled, we break at 1-2 elements? And just use Interfaces from the start? |
Unfortunate.
The idea is with this flag enabled, we do the interface for all unions. Without it enabled, nothing changes. Going from untyped to typed is also a breaking change. |
Fixes #2266. Mirrors pulumi/pulumi-dotnet#1107 and pulumi/pulumi-dotnet#1108 for Java.
Neither shape Java generates for a discriminated union can be dispatched on the discriminator. A
oneOfof three or more object types generated asObject, so any value compiled and mistakes surfaced at deploy time, or not at all. A two-member one generatedEither<L, R>, which is worse than it looks:Converter.tryConvertOneOfpicks an arm withcanBeCast, which isisInstance, and the wire value for a struct is a map, so neither arm matches and the property deserializes to null.EitherConverterTestonly ever coveredEither<Integer, String>, where the value already has its final Java type.Three related changes:
1. Typed interfaces, behind a new
fullyTypedUnionsoption. Each discriminated union gets an interface carrying the discriminator and its mapping; members implement it, and union-typed properties use it. From themini-azurenativegolden:Typing a property that used to be
ObjectorEitherbreaks the callers of a generated SDK, and only the provider knows when it is cutting a major version, so a package opts in throughlanguage.java.fullyTypedUnions. Without it the generated output is byte-identical to today.Member count takes no part in the decision. An earlier revision converted only unions of three or more members, which made adding a third member to an existing union a breaking change. Any discriminated union qualifies now, whatever its size, provided the mapping covers every member and the members share a module. Undiscriminated unions are untouched: with no tag there is nothing to dispatch on, so
enum | stringkeepsEither<L, R>.2. Const-valued properties are filled in by generated builders, so the discriminator tag never has to be written by hand. This applies with or without the option. It was the loudest complaint when the service team reviewed the equivalent .NET output.
3. Deserialization dispatches on the discriminator tag instead of
canBeCast, which cast by shape and therefore could not distinguish two variants whose property shapes are identical.Test plan
TestGeneratePackage/mini-azurenativepasses, including itsjava/compilestep. That schema sets the option, so it is the golden that exercises the union end to end.output-funcsdoes not set the option, since its schema is a symlink into thepulumisubmodule, so it covers the un-opted-in path.PropertyValueSerializerTestandDiscriminatedUnionConverterTestcover tag dispatch.Three pre-existing failures are unrelated to this change:
TestGeneratePackage/{jumbo-resources,mini-awsclassic,mini-kubernetes}/java/test. I confirmed they fail identically on a clean checkout of the parent commit with no modifications. None of those three schemas carries a discriminator, and none of their goldens changed here.Note for reviewers
pulumi/pulumi#24213 adds
l2-discriminated-union-internalto the conformance suite, and pulumi/pulumi#24190 addedl2-discriminated-union-many. Neither is in a releasedpulumi/pkgyet, so this PR is verified against codegen goldens and unit tests rather than conformance. Both will also need the schemas to setfullyTypedUnionsbefore they exercise the Java path at all.When the pin next moves, note that
l2-discriminated-union-manyassigns one resource's output into another's input where the output's union is a subset of the input's. That case fails in both .NET (CS0029) and Python (mypy) for a variance reason. Java will likely need an expected-failure entry too, but for a different cause: the output side is generated in the plain shape and the input side in theArgsshape, and those two class families are unrelated for every type, not just unions. Generic invariance onOutput<T>blocks it independently.Scope note on the const commit
Supplying const-valued properties applies to every schema property carrying a
const, not only discriminator tags. The visible effect is one line in theconstantconformance snapshot, updated in this PR.