Conversation
…erties the caller never set
With conditionalSerialization=true, an all-optional model's public constructor
took every value-typed property as a plain value type and then guarded the
serialization flag with `if (this.AutoRenew != null)` — always true for a
non-nullable bool/int/enum, so every value-typed property was marked as set the
moment the model was constructed. A partial-update command built as
`new UpdateThingCommand(note: "renamed")` went on the wire as
{"autoRenew":false,"note":"renamed"}: an invented false on the exact
operation where it does the most damage. (The C# compiler flags the old guard
itself: CS0472 'the result of the expression is always true'.)
Optional value-typed constructor parameters without a default value are now
nullable, and the flag is only raised when the argument was actually provided:
if (autoRenew != null)
{
this._AutoRenew = autoRenew.Value;
this._flagAutoRenew = true;
}
Required properties, properties with a spec default, reference types, and the
public property surface (plain value-typed properties with flag-raising
setters) are all generated byte-for-byte as before; only the optional
value-type constructor path changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxDNCjqJycKTfzVWg2SeTJ
There was a problem hiding this comment.
3 issues found across 30 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/Order.cs">
<violation number="1" location="samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/Order.cs:97">
P2: Changing `Order`'s public constructor parameters from `long`, `int`, and `DateTime` to nullable types changes its CLR signature. Applications that update this generated library without recompiling now fail to resolve the old constructor; retain a compatibility overload or document the binary break.</violation>
</file>
<file name="samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/ApiResponse.cs">
<violation number="1" location="samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/ApiResponse.cs:41">
P2: Changing `int` to `int?` changes the public CLR constructor signature, so an already-compiled consumer calling `ApiResponse(int, string, string)` fails with `MissingMethodException` after upgrading this library. Preserve the old constructor signature as a compatibility overload and delegate it to the nullable implementation.</violation>
</file>
<file name="modules/openapi-generator/src/main/resources/csharp/modelGeneric.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/csharp/modelGeneric.mustache:143">
P2: For nullable value-type properties (spec `nullable: true`), the new `?` appended here may collide with an existing `?` already present in `datatypeWithEnum`/`dataType`, producing an invalid `int??` parameter type (compile error) instead of `int?`. The C# codegen appends `?` to nullable value-type dataTypes (CSharpClientCodegen around line 654: `if (isSupportNullable() && ModelUtils.isNullable(p) && this.getNullableTypes().contains(type))`), so the type may already end in `?`. Unlike the enum branch, which is unguarded here, the new value-type branch should only append `?` when `datatypeWithEnum` does not already end in `?`. The conditional-serialization sample only covers plain `bool`/`integer` (non-nullable), so this path is untested. Please verify that an optional nullable value-type property does not generate `??` and guard the `?` accordingly (e.g. also require the type not to already end with `?`).</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| /// <param name="status">Order Status.</param> | ||
| /// <param name="complete">complete (default to false).</param> | ||
| public Order(long id = default, long petId = default, int quantity = default, DateTime shipDate = default, StatusEnum? status = default, bool complete = false) | ||
| public Order(long? id = default, long? petId = default, int? quantity = default, DateTime? shipDate = default, StatusEnum? status = default, bool complete = false) |
There was a problem hiding this comment.
P2: Changing Order's public constructor parameters from long, int, and DateTime to nullable types changes its CLR signature. Applications that update this generated library without recompiling now fail to resolve the old constructor; retain a compatibility overload or document the binary break.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/Order.cs, line 97:
<comment>Changing `Order`'s public constructor parameters from `long`, `int`, and `DateTime` to nullable types changes its CLR signature. Applications that update this generated library without recompiling now fail to resolve the old constructor; retain a compatibility overload or document the binary break.</comment>
<file context>
@@ -94,31 +94,31 @@ public bool ShouldSerializeStatus()
/// <param name="status">Order Status.</param>
/// <param name="complete">complete (default to false).</param>
- public Order(long id = default, long petId = default, int quantity = default, DateTime shipDate = default, StatusEnum? status = default, bool complete = false)
+ public Order(long? id = default, long? petId = default, int? quantity = default, DateTime? shipDate = default, StatusEnum? status = default, bool complete = false)
{
- this._Id = id;
</file context>
There was a problem hiding this comment.
The CLR signature does change, but I am deliberately not adding a compatibility overload. The overload would break source compatibility and bring the bug back. I tested this with the exact pair it would generate (C# 12, net8.0):
public ApiResponse(int? code = default, string type = default, string message = default) // new
public ApiResponse(int code = default, string type = default, string message = default) // compat overloadnew ApiResponse()andnew ApiResponse(message: "x"): error CS0121, "The call is ambiguous between ApiResponse(int?, string, string) and ApiResponse(int, string, string)". Every model with an optional value-typed property would stop compiling for callers that skip that argument. Partial-update calls are exactly that shape.new ApiResponse(code: 5)binds to theintoverload, because the identity conversion is better thaninttoint?. So every caller who passes a value-typed argument goes back to the flag-raising constructor this PR fixes.
A consumer who recompiles against the regenerated model (the normal workflow for generated code) picks up the new constructor with no source changes, because int converts implicitly to int?. The only scenario left is swapping in a regenerated assembly without rebuilding its callers. The generator already gives no guarantees for that: the model has a single all-parameter constructor, so adding any optional property to the spec changes its CLR signature in the same way. The change is also limited to conditionalSerialization=true, which only one sample in the repo uses. I will call out the signature change in the PR description so it can go into the release notes.
| /// <param name="type">type.</param> | ||
| /// <param name="message">message.</param> | ||
| public ApiResponse(int code = default, string type = default, string message = default) | ||
| public ApiResponse(int? code = default, string type = default, string message = default) |
There was a problem hiding this comment.
P2: Changing int to int? changes the public CLR constructor signature, so an already-compiled consumer calling ApiResponse(int, string, string) fails with MissingMethodException after upgrading this library. Preserve the old constructor signature as a compatibility overload and delegate it to the nullable implementation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/restsharp/standard2.0/ConditionalSerialization/src/Org.OpenAPITools/Model/ApiResponse.cs, line 41:
<comment>Changing `int` to `int?` changes the public CLR constructor signature, so an already-compiled consumer calling `ApiResponse(int, string, string)` fails with `MissingMethodException` after upgrading this library. Preserve the old constructor signature as a compatibility overload and delegate it to the nullable implementation.</comment>
<file context>
@@ -38,11 +38,11 @@ public partial class ApiResponse : IEquatable<ApiResponse>, IValidatableObject
/// <param name="type">type.</param>
/// <param name="message">message.</param>
- public ApiResponse(int code = default, string type = default, string message = default)
+ public ApiResponse(int? code = default, string type = default, string message = default)
{
- this._Code = code;
</file context>
There was a problem hiding this comment.
The CLR signature does change, but I am deliberately not adding a compatibility overload. The overload would break source compatibility and bring the bug back. I tested this with the exact pair it would generate (C# 12, net8.0):
public ApiResponse(int? code = default, string type = default, string message = default) // new
public ApiResponse(int code = default, string type = default, string message = default) // compat overloadnew ApiResponse()andnew ApiResponse(message: "x"): error CS0121, "The call is ambiguous between ApiResponse(int?, string, string) and ApiResponse(int, string, string)". Every model with an optional value-typed property would stop compiling for callers that skip that argument. Partial-update calls are exactly that shape.new ApiResponse(code: 5)binds to theintoverload, because the identity conversion is better thaninttoint?. So every caller who passes a value-typed argument goes back to the flag-raising constructor this PR fixes.
A consumer who recompiles against the regenerated model (the normal workflow for generated code) picks up the new constructor with no source changes, because int converts implicitly to int?. The only scenario left is swapping in a regenerated assembly without rebuilding its callers. The generator already gives no guarantees for that: the model has a single all-parameter constructor, so adding any optional property to the spec changes its CLR signature in the same way. The change is also limited to conditionalSerialization=true, which only one sample in the repo uses. I will call out the signature change in the PR description so it can go into the release notes.
| [JsonConstructorAttribute] | ||
| {{/hasOnlyReadOnly}} | ||
| public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{#defaultValue}}{{^isDateTime}}{{#isString}}{{^isEnum}}@{{/isEnum}}{{/isString}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default{{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default{{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}} | ||
| public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}}{{^isEnum}}{{#conditionalSerialization}}{{#vendorExtensions.x-csharp-value-type}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/vendorExtensions.x-csharp-value-type}}{{/conditionalSerialization}}{{/isEnum}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{#defaultValue}}{{^isDateTime}}{{#isString}}{{^isEnum}}@{{/isEnum}}{{/isString}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default{{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default{{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}} |
There was a problem hiding this comment.
P2: For nullable value-type properties (spec nullable: true), the new ? appended here may collide with an existing ? already present in datatypeWithEnum/dataType, producing an invalid int?? parameter type (compile error) instead of int?. The C# codegen appends ? to nullable value-type dataTypes (CSharpClientCodegen around line 654: if (isSupportNullable() && ModelUtils.isNullable(p) && this.getNullableTypes().contains(type))), so the type may already end in ?. Unlike the enum branch, which is unguarded here, the new value-type branch should only append ? when datatypeWithEnum does not already end in ?. The conditional-serialization sample only covers plain bool/integer (non-nullable), so this path is untested. Please verify that an optional nullable value-type property does not generate ?? and guard the ? accordingly (e.g. also require the type not to already end with ?).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/csharp/modelGeneric.mustache, line 143:
<comment>For nullable value-type properties (spec `nullable: true`), the new `?` appended here may collide with an existing `?` already present in `datatypeWithEnum`/`dataType`, producing an invalid `int??` parameter type (compile error) instead of `int?`. The C# codegen appends `?` to nullable value-type dataTypes (CSharpClientCodegen around line 654: `if (isSupportNullable() && ModelUtils.isNullable(p) && this.getNullableTypes().contains(type))`), so the type may already end in `?`. Unlike the enum branch, which is unguarded here, the new value-type branch should only append `?` when `datatypeWithEnum` does not already end in `?`. The conditional-serialization sample only covers plain `bool`/`integer` (non-nullable), so this path is untested. Please verify that an optional nullable value-type property does not generate `??` and guard the `?` accordingly (e.g. also require the type not to already end with `?`).</comment>
<file context>
@@ -140,7 +140,7 @@
[JsonConstructorAttribute]
{{/hasOnlyReadOnly}}
- public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{#defaultValue}}{{^isDateTime}}{{#isString}}{{^isEnum}}@{{/isEnum}}{{/isString}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default{{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default{{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}}
+ public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}}{{^isEnum}}{{#conditionalSerialization}}{{#vendorExtensions.x-csharp-value-type}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/vendorExtensions.x-csharp-value-type}}{{/conditionalSerialization}}{{/isEnum}} {{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}} = {{#defaultValue}}{{^isDateTime}}{{#isString}}{{^isEnum}}@{{/isEnum}}{{/isString}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default{{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default{{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_sanitize_param}}{{name}}{{/lambda.camelcase_sanitize_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}}
{
{{#vars}}
</file context>
There was a problem hiding this comment.
Checked this against the generator; the T?? case does not occur. The appended ? sits inside {{#vendorExtensions.x-csharp-value-type}}, and CSharpClientCodegen.patchProperty sets that flag only when getNullableTypes() contains property.dataType. That set holds the bare names (int, bool, DateTime, ...), so a nullable: true property whose dataType is already int? never gets the flag. It keeps the original template path: int? graceDays as the parameter, with the existing if (this.GraceDays != null) check, which is meaningful for a nullable type. Enums never end in ? in datatypeWithEnum (the name is StatusEnum even when nullable), so the enum branch appends exactly one ?, as it did before this PR.
I verified this by generating a model with nullable int/bool, an inline enum, a nullable inline enum and a $ref enum for httpclient, restsharp and unityWebRequest, with and without nullableReferenceTypes. No ?? appears, and the httpclient/net8.0 output builds with 0 errors and 0 warnings.
In dfc897f I added a nullable integer and a nullable enum to the test fixture, and asserted that no ?? is emitted and that the nullable value type keeps its original null check. That locks this in, so a future change to the value-type extension cannot silently regress it.
…onditionalSerialization constructor Extends the conditional-serialization-value-types fixture with an optional inline enum, a nullable enum and a nullable integer. The test now asserts that enums take the guarded `.Value` path, that `nullable: true` value types keep their existing null check, and that no parameter type is emitted as `T??`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ones already imply Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With
conditionalSerialization=true, the public constructor of an all-optional model takes every property as a plain value type and raises the serialization flag behindif (this.AutoRenew != null). For a value type (bool, numbers,DateTime,Guid, enums) that guard is always true (CS0472), so every value-typed property counts as "set" as soon as the model is constructed. Reproduced on current master with thehttpclientlibrary (Newtonsoft):new UpdateThingCommand(note: "renamed"){"autoRenew":false,"note":"renamed"}{"note":"renamed"}new UpdateThingCommand(autoRenew: false){"autoRenew":false}{"autoRenew":false}new UpdateThingCommand { Period = 12 }{"autoRenew":false,"period":12}{"period":12}A partial update meant to rename the thing also switches off auto-renewal. Models with required properties avoid the bug because Json.NET populates them through their
[JsonConstructor], so it affects all-optional models, which is the partial-update shape.Fix
modelGeneric.mustache, constructor only:Only optional value-typed parameters without a spec default change. Required parameters, defaulted ones, reference types, nullable non-enum value types (
int?), the public property surface and all output without the flag are unchanged.Compatibility
Source-compatible:
boolconverts implicitly tobool?. The CLR signature changes, so dependent assemblies must be rebuilt. There is deliberately no overload with the old signature: it would make calls such asnew Model()ambiguous (CS0121) and would bring the bug back for calls that pass a plain value.Tests
CSharpClientCodegenTest, on a new spec3_0/csharp/conditional-serialization-value-types.yaml:testConditionalSerializationValueTypeConstructorFlags: nullable parameters, guarded flag assignment for a value type and an enum,int?keeps its null check, unchanged property surface and required-model constructor. Fails without the fix.testValueTypeConstructorUnchangedWithoutConditionalSerialization: without the flag, the constructor is unchanged.Known gaps
Required value-typed properties never raise their flag in the public constructor either. The generated code for them is identical before and after this PR, so that fix belongs in a separate PR.
PR checklist
./bin/generate-samples.sh ./bin/configs/csharp*.yaml). Onlycsharp-restsharp-netstandard2.0-conditionalSerializationuses the option; it changes in the constructor only (27 model files).Generated with Claude Code