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
8 changes: 8 additions & 0 deletions packages/http-client-csharp/.tspd/docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Before customizing generated code, consider whether your change should be made i

Use C# code customizations (partial classes) when TypeSpec cannot express the behavior you need.

## Optional nullable model properties

For a TypeSpec property such as `feature?: Feature | null`, generated model classes distinguish an omitted property from an explicitly assigned `null` during JSON serialization. Leaving the property untouched omits it; assigning `null` writes `"feature": null`; assigning a value writes that value. Reading JSON preserves the same distinction when the model is serialized again, including nested models and wire-format serialization.

Presence tracking does not change the public property type or constructor/model-factory signatures. Defaulted constructor and model-factory arguments retain their existing behavior: a scalar `null` argument is treated as omitted, since C# cannot distinguish an omitted argument from an explicitly supplied default value. Model factories also retain their existing collection initialization behavior, which can materialize a defaulted list argument as an empty list. To write explicit null for a writable property, assign the property after construction.

A handwritten property or field that replaces a generated member (including a `[CodeGenMember]` replacement) retains its existing customization behavior; the generator cannot observe assignments inside a handwritten setter. There is no presence-tracking opt-in for replacement members. Models customized as readonly structs also retain their existing constructor-only behavior, because readonly structs cannot contain the mutable backing fields and presence flags used by generated classes. Keep the generated class property when its omitted/null distinction is needed. Similarly, plugins that replace generated property bodies or serialization methods must preserve the tracking behavior themselves rather than relying on a replacement auto-property.

## Make a model internal

Define a class with the same namespace and name as generated model and use the desired accessibility.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ private MethodBodyStatement[] BuildXmlDeserializationMethodBody()
new IfStatement(_xmlElementParameterSnippet.Equal(Null)) { valueKindEqualsNullReturn },
MethodBodyStatement.EmptyLine,
GetXmlNamespaceDeclarations(categorizedProperties.Namespaces),
GetPropertyVariableDeclarations(),
GetPropertyVariableDeclarations(preserveJsonPresence: false),
MethodBodyStatement.EmptyLine
};

Expand Down Expand Up @@ -651,7 +651,7 @@ private MethodBodyStatement[] BuildXmlDeserializationMethodBody()
statements.Add(MethodBodyStatement.EmptyLine);
}

statements.Add(Return(New.Instance(_model.Type, GetSerializationCtorParameterValues())));
statements.Add(Return(New.Instance(_model.Type, GetSerializationCtorParameterValues(preserveJsonPresence: false))));

return [.. statements];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -980,11 +980,26 @@ private MethodBodyStatement[] BuildDeserializationMethodBody()
new IfStatement(_jsonElementParameterSnippet.ValueKindEqualsNull()) { valueKindEqualsNullReturn },
GetPropertyVariableDeclarations(),
deserializePropertiesForEachStatement,
Return(New.Instance(_model.Type, GetSerializationCtorParameterValues()))
Return(New.Instance(SerializationConstructor.Signature, GetSerializationCtorParameterValues(), GetNullablePresenceInitializer()))
];
}

private MethodBodyStatement GetPropertyVariableDeclarations()
private IReadOnlyDictionary<ValueExpression, ValueExpression>? GetNullablePresenceInitializer()
{
Dictionary<ValueExpression, ValueExpression>? values = null;
foreach (var parameter in SerializationConstructor.Signature.Parameters)
{
if (parameter.Property is { } property &&
ScmModelProvider.GetNullablePropertyPresence(property) is { } presence)
{
values ??= [];
values[presence] = presence.AsVariableExpression;
}
}
return values;
}

private MethodBodyStatement GetPropertyVariableDeclarations(bool preserveJsonPresence = true)
{
var parameters = SerializationConstructor.Signature.Parameters;
var propertyDeclarationStatements = new List<MethodBodyStatement>(parameters.Count);
Expand Down Expand Up @@ -1022,6 +1037,10 @@ private MethodBodyStatement GetPropertyVariableDeclarations()
}
else
{
if (preserveJsonPresence && ScmModelProvider.GetNullablePropertyPresence(property) is { } presence)
{
propertyDeclarationStatements.Add(Declare(presence.AsVariableExpression, False));
}
ValueExpression defaultValue;
if (property.IsDiscriminator && _model.DiscriminatorValue != null && property.Type.IsFrameworkType)
{
Expand All @@ -1031,6 +1050,10 @@ private MethodBodyStatement GetPropertyVariableDeclarations()
{
defaultValue = New.List(property.Type.ElementType);
}
else if (preserveJsonPresence && IsOptionalNullableCollection(property))
{
defaultValue = New.Instance(property.Type.PropertyInitializationType);
}
else
{
defaultValue = Default;
Expand Down Expand Up @@ -1149,7 +1172,7 @@ private MethodBodyStatement CallBaseJsonModelWriteCore(bool isDynamicModelWithNo
/// <summary>
/// Builds the values for the serialization constructor parameters.
/// </summary>
private ValueExpression[] GetSerializationCtorParameterValues()
private ValueExpression[] GetSerializationCtorParameterValues(bool preserveJsonPresence = true)
{
var parameters = SerializationConstructor.Signature.Parameters;
ValueExpression[] serializationCtorParameters = new ValueExpression[parameters.Count];
Expand All @@ -1160,7 +1183,7 @@ private ValueExpression[] GetSerializationCtorParameterValues()
var parameter = parameters[i];
if (parameter.Property is { } property)
{
serializationCtorParameters[i] = GetValueForSerializationConstructor(property);
serializationCtorParameters[i] = GetValueForSerializationConstructor(property, preserveJsonPresence);
continue;
}
else
Expand All @@ -1174,10 +1197,15 @@ private ValueExpression[] GetSerializationCtorParameterValues()
return serializationCtorParameters;
}

private static ValueExpression GetValueForSerializationConstructor(PropertyProvider propertyProvider)
private static ValueExpression GetValueForSerializationConstructor(PropertyProvider propertyProvider, bool preserveJsonPresence)
{
var isRequired = propertyProvider.WireInfo?.IsRequired ?? false;

if (preserveJsonPresence && IsOptionalNullableCollection(propertyProvider))
{
return propertyProvider.AsVariableExpression;
}

if (!propertyProvider.Type.IsFrameworkType || propertyProvider.IsAdditionalProperties)
{
return propertyProvider.Type.IsReadOnlyDictionary
Expand All @@ -1192,6 +1220,10 @@ private static ValueExpression GetValueForSerializationConstructor(PropertyProvi
return propertyProvider.AsVariableExpression;
}

private static bool IsOptionalNullableCollection(PropertyProvider property)
=> property.WireInfo is { IsRequired: false, IsNullable: true } &&
property.Type is { IsCollection: true, IsReadOnlyMemory: false };

private List<MethodBodyStatement> BuildDeserializePropertiesStatements(ScopedApi<JsonProperty> jsonProperty)
{
List<MethodBodyStatement> propertyDeserializationStatements = [];
Expand Down Expand Up @@ -1226,10 +1258,14 @@ private List<MethodBodyStatement> BuildDeserializePropertiesStatements(ScopedApi
var propertyName = parameter.Property?.Name ?? parameter.Field?.Name;
var propertyType = parameter.Property?.Type ?? parameter.Field?.Type;
var propertyExpression = parameter.Property?.AsVariableExpression ?? parameter.Field?.AsVariableExpression;
var checkIfJsonPropEqualsName = new IfStatement(jsonProperty.NameEquals(propertySerializationName))
var checkIfJsonPropEqualsName = new IfStatement(jsonProperty.NameEquals(propertySerializationName));
if (parameter.Property is { } property &&
ScmModelProvider.GetNullablePropertyPresence(property) is { } presence)
{
DeserializeProperty(propertyName!, propertyType!, wireInfo, propertyExpression!, jsonProperty, serializationAttributes, wireInfo.SerializationFormat)
};
checkIfJsonPropEqualsName.Add(presence.AsVariableExpression.Assign(True).Terminate());
}
checkIfJsonPropEqualsName.Add(
DeserializeProperty(propertyName!, propertyType!, wireInfo, propertyExpression!, jsonProperty, serializationAttributes, wireInfo.SerializationFormat));
propertyDeserializationStatements.Add(checkIfJsonPropEqualsName);
}
else
Expand Down Expand Up @@ -1633,7 +1669,7 @@ private static MethodBodyStatement DeserializationPropertyNullCheckStatement(

if ((serializedType.IsNullable || !serializedType.IsValueType) && wireInfo.IsNullable)
{
if (!serializedType.IsCollection)
if (!serializedType.IsCollection || !propertyIsRequired)
{
return new IfStatement(checkEmptyProperty)
{
Expand Down Expand Up @@ -1879,7 +1915,8 @@ private MethodBodyStatement[] CreateWritePropertiesStatements(bool isDynamicMode
continue;
}

propertyStatements.Add(CreateWritePropertyStatement(property.WireInfo, property.Type, property.Name, property, property.WireInfo?.SerializationFormat));
propertyStatements.Add(CreateWritePropertyStatement(property.WireInfo, property.Type, property.Name, property, property.WireInfo?.SerializationFormat,
ScmModelProvider.GetNullablePropertyPresence(property)));
}

foreach (var field in baseModelProvider.CanonicalView.Fields)
Expand All @@ -1903,7 +1940,8 @@ private MethodBodyStatement[] CreateWritePropertiesStatements(bool isDynamicMode
continue;
}

propertyStatements.Add(CreateWritePropertyStatement(property.WireInfo, property.Type, property.Name, property, property.WireInfo.SerializationFormat));
propertyStatements.Add(CreateWritePropertyStatement(property.WireInfo, property.Type, property.Name, property, property.WireInfo.SerializationFormat,
ScmModelProvider.GetNullablePropertyPresence(property)));
}

foreach (var field in _model.CanonicalView.Fields)
Expand All @@ -1924,7 +1962,8 @@ private MethodBodyStatement CreateWritePropertyStatement(
CSharpType propertyType,
string propertyName,
MemberExpression propertyExpression,
SerializationFormat? serializationFormat)
SerializationFormat? serializationFormat,
FieldProvider? presence = null)
{
var propertySerializationName = GetJsonSerializedName(wireInfo);
var propertySerializationFormat = wireInfo.SerializationFormat;
Expand Down Expand Up @@ -1985,7 +2024,8 @@ private MethodBodyStatement CreateWritePropertyStatement(
propertyIsRequired,
propertyIsReadOnly,
propertyIsNullable,
writePropertySerializationStatements);
writePropertySerializationStatements,
presence);

return wrapInIsDefinedStatement;
}
Expand All @@ -1997,7 +2037,8 @@ private MethodBodyStatement WrapInIsDefined(
bool propertyIsRequired,
bool propertyIsReadOnly,
bool propertyIsNullable,
MethodBodyStatement writePropertySerializationStatement)
MethodBodyStatement writePropertySerializationStatement,
FieldProvider? presence)
{
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
ScopedApi<bool>? patchCheck = _jsonPatchProperty != null
Expand Down Expand Up @@ -2029,7 +2070,8 @@ private MethodBodyStatement WrapInIsDefined(
propertyIsRequired,
jsonSerializedName,
patchCheck,
writePropertySerializationStatement);
writePropertySerializationStatement,
presence);
}

/// <summary>
Expand Down Expand Up @@ -2598,7 +2640,8 @@ private MethodBodyStatement CreateConditionalSerializationStatement(
bool isRequired,
string serializedName,
ValueExpression? patchCheck,
MethodBodyStatement writePropertySerializationStatement)
MethodBodyStatement writePropertySerializationStatement,
FieldProvider? presence)
{
ScopedApi<bool> condition;
bool shouldCheckJsonPath = patchCheck != null && (propertyType.IsList || propertyType.IsArray);
Expand Down Expand Up @@ -2629,6 +2672,31 @@ private MethodBodyStatement CreateConditionalSerializationStatement(
? OptionalSnippets.IsCollectionDefined(propertyMemberExpression)
: OptionalSnippets.IsDefined(propertyMemberExpression);

if (!isRequired && isNullable)
{
if (presence != null || propertyType is { IsCollection: true, IsReadOnlyMemory: false })
{
if (presence != null)
{
isDefinedCondition = presence.As<bool>().Or(isDefinedCondition);
Comment on lines +2677 to +2681
}
var writeNullableProperty = new IfElseStatement(
new IfStatement(propertyMemberExpression.NotEqual(Null)) { writePropertySerializationStatement },
_utf8JsonWriterSnippet.WriteNull(serializedName));
condition = isReadOnly ? _isNotEqualToWireConditionSnippet.And(isDefinedCondition) : isDefinedCondition;
if (shouldCheckJsonPath)
{
return CreateConditionalPatchSerializationStatement(
serializedName, condition, writeNullableProperty, null);
}
if (patchCheck != null)
{
condition = condition.And(patchCheck);
}
return new IfStatement(condition) { writeNullableProperty };
}
}

if (patchCheck != null && !shouldCheckJsonPath)
{
isDefinedCondition = isDefinedCondition.And(patchCheck);
Expand Down
Loading
Loading