Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0a48f7a
Refactor object inference in JSON converter
Fellmonkey Aug 2, 2025
b44f0b1
Refactor model deserialization logic in C# template
Fellmonkey Aug 2, 2025
4c6b9f7
Handle optional properties in model From method
Fellmonkey Aug 9, 2025
b071cbc
synchronization with the Unity template
Fellmonkey Aug 14, 2025
e9586d2
Refactor model parsing for nullable and array properties
Fellmonkey Sep 25, 2025
5ad9a4b
Skip null parameters in request parameter loop
Fellmonkey Sep 25, 2025
953600d
Refactor model class name generation in template
Fellmonkey Sep 28, 2025
d9f6d71
Merge branch 'master' into improve-json-serialization
Fellmonkey Oct 1, 2025
cc2cd62
Add parse_value Twig function for DotNet models
Fellmonkey Oct 1, 2025
ebbad72
make generated array mappings null-safe
Fellmonkey Oct 1, 2025
ff2545a
lint
Fellmonkey Oct 3, 2025
faad585
Import Enums namespace conditionally in model template
Fellmonkey Oct 3, 2025
995ba87
Merge branch 'master' into improve-json-serialization
ChiragAgg5k Oct 4, 2025
10993e5
Refactor array handling in DotNet code generation
Fellmonkey Oct 13, 2025
76ce99c
Merge branch 'master' into improve-json-serialization
Fellmonkey Oct 13, 2025
c243bca
Merge remote-tracking branch 'upstream/master' into improve-json-seri…
Fellmonkey Jan 17, 2026
a91f388
Update docblock for getFunctions method
Fellmonkey Jan 17, 2026
2950f87
Refactor exception and extension handling
Fellmonkey Jan 19, 2026
488fe88
Refactor .NET model property serialization
Fellmonkey Feb 14, 2026
8fe9521
Always call ToList() for mapped arrays
Fellmonkey Feb 14, 2026
dfab06f
Merge branch 'master' into improve-json-serialization
Fellmonkey Feb 14, 2026
eab63b9
Add missing closing for toMapValue TwigFilter
Fellmonkey Feb 14, 2026
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
187 changes: 163 additions & 24 deletions src/SDK/Language/DotNet.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,12 @@ public function getFilters(): array
new TwigFilter('propertyType', function (array $property, array $spec = []) {
return $this->getPropertyType($property, $spec);
}),
new TwigFilter('propertyAssignment', function (array $property) {
return $this->getPropertyAssignment($property);
}),
new TwigFilter('toMapValue', function (array $property, string $definitionName) {
return $this->getToMapExpression($property, $definitionName);
}),
];
}

Expand All @@ -521,7 +527,7 @@ public function getFilters(): array
* @param array $spec
* @return string
*/
protected function getPropertyType(array $property, array $spec = []): string
protected function getPropertyType(array $property, array $spec = [], bool $fullyQualified = true): string
{
if (isset($property['sub_schema']) && !empty($property['sub_schema'])) {
$type = $this->toPascalCase($property['sub_schema']);
Expand All @@ -534,7 +540,8 @@ protected function getPropertyType(array $property, array $spec = []): string

if (isset($property['enum']) && !empty($property['enum'])) {
$enumName = $property['enumName'] ?? $property['name'];
return 'Appwrite.Enums.' . $this->toPascalCase($enumName);
$prefix = $fullyQualified ? 'Appwrite.Enums.' : '';
return $prefix . $this->toPascalCase($enumName);
}

return $this->getTypeName($property, $spec);
Expand All @@ -548,39 +555,171 @@ public function getFunctions(): array
{
return [
new TwigFunction('sub_schema', function (array $property) {
$result = '';

if (isset($property['sub_schema']) && !empty($property['sub_schema'])) {
if ($property['type'] === 'array') {
$result = 'List<' . $this->toPascalCase($property['sub_schema']) . '>';
} else {
$result = $this->toPascalCase($property['sub_schema']);
}
} elseif (isset($property['enum']) && !empty($property['enum'])) {
$enumName = $property['enumName'] ?? $property['name'];
$result = $this->toPascalCase($enumName);
} else {
$result = $this->getTypeName($property);
}
$result = $this->getPropertyType($property, [], false);

if (!($property['required'] ?? true)) {
$result .= '?';
}

return $result;
}),
}, ['is_safe' => ['html']]),
new TwigFunction('property_name', function (array $definition, array $property) {
$name = $property['name'];
$name = \str_replace('$', '', $name);
$name = $this->toPascalCase($name);
if (\in_array($name, $this->getKeywords())) {
$name = '@' . $name;
}
return $name;
return $this->getPropertyName($property);
}),
];
}

/**
* Generate property name for C# model
*
* @param array $property
* @return string
*/
protected function getPropertyName(array $property): string
{
$name = $property['name'];
$name = \str_replace('$', '', $name);
$name = $this->toPascalCase($name);
if (\in_array($name, $this->getKeywords())) {
$name = '@' . $name;
}
return $name;
}

/**
* Resolved property name with overrides applied
*
* @param array $property
* @param string $definitionName
* @return string
*/
protected function getResolvedPropertyName(array $property, string $definitionName): string
{
$name = $this->getPropertyName($property);
$overrides = $this->getPropertyOverrides();
if (isset($overrides[$definitionName][$name])) {
return $overrides[$definitionName][$name];
}
return $name;
}

/**
* Generate full property assignment expression for model deserialization (From method).
* Handles TryGetValue wrapping for optional properties internally.
*
* @param array $property
* @return string
*/
protected function getPropertyAssignment(array $property): string
{
$required = $property['required'] ?? false;
$propertyName = $property['name'];
$mapAccess = "map[\"{$propertyName}\"]";

if ($required) {
return $this->convertValue($property, $mapAccess);
}

$v = 'v' . $this->toPascalCase(\str_replace('$', '', $propertyName));
$tryGet = "map.TryGetValue(\"{$propertyName}\", out var {$v})";

// Sub_schema objects — use pattern matching for type-safe cast
if (!empty($property['sub_schema']) && $property['type'] !== 'array') {
$subSchema = $this->toPascalCase($property['sub_schema']);
return "{$tryGet} && {$v} is Dictionary<string, object> {$v}Map ? {$subSchema}.From(map: {$v}Map) : null";
}

// Integer, number, enum — guard with null check to avoid Convert/constructor on null
if (\in_array($property['type'], ['integer', 'number']) || !empty($property['enum'])) {
$expr = $this->convertValue($property, $v);
return "{$tryGet} && {$v} != null ? {$expr} : null";
}

// String, boolean, arrays — null-safe conversion
$expr = $this->convertValue($property, $v, false);
return "{$tryGet} ? {$expr} : null";
}
Comment on lines +695 to +700
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Optional arrays: NRE when map value is null.

When getPropertyAssignment handles an optional array property (line 638–640), it falls into the default branch:

tryGet ? convertValue($property, $v, false) : null

But convertValue for arrays (lines 669–678 and 656–658) unconditionally calls $src.ToEnumerable().Select(…).ToList(). If TryGetValue succeeds but the value is null, the generated C# will invoke .ToEnumerable() on nullNullReferenceException.

Compare with the integer/number/enum branch (line 633–635), which correctly guards with && {$v} != null.

Proposed fix — add null guard for optional arrays
-        // String, boolean, arrays — null-safe conversion
-        $expr = $this->convertValue($property, $v, false);
-        return "{$tryGet} ? {$expr} : null";
+        // Arrays need an explicit null guard (ToEnumerable cannot be called on null)
+        if ($property['type'] === 'array') {
+            $expr = $this->convertValue($property, $v, false);
+            return "{$tryGet} && {$v} != null ? {$expr} : null";
+        }
+
+        // String, boolean — null-safe conversion
+        $expr = $this->convertValue($property, $v, false);
+        return "{$tryGet} ? {$expr} : null";

Also applies to: 668-678

🤖 Prompt for AI Agents
In `@src/SDK/Language/DotNet.php` around lines 636 - 641, getPropertyAssignment's
optional-array path can call convertValue when TryGetValue succeeded but the
mapped value is null, causing a NullReferenceException because convertValue for
arrays calls .ToEnumerable() unconditionally; update the ternary guard around
{$tryGet} so array paths also check {$v} != null (i.e. use "{$tryGet} && {$v} !=
null ? {$expr} : null") or alternatively modify convertValue's array-handling
branch to immediately return null when $src is null before calling
ToEnumerable(); adjust the logic in getPropertyAssignment and/or convertValue
(array branches) consistently so TryGetValue true + null value does not call
.ToEnumerable().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the specification, arrays are always [], and elements are required/non-nullable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fellmonkey, thank you for the clarification! If the specification guarantees that arrays are always [] (never null), then the null guard is indeed unnecessary. The current implementation is correct under that constraint.


✏️ Learnings added
Learnt from: Fellmonkey
Repo: appwrite/sdk-generator PR: 1138
File: src/SDK/Language/DotNet.php:636-641
Timestamp: 2026-02-14T18:53:38.010Z
Learning: In the .NET SDK generator for Appwrite, arrays are always `[]` per specification and never `null`. Array elements are also required/non-nullable. This means null guards are not needed when handling array deserialization.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


/**
* Build type conversion expression for a single value.
*
* @param array $property Property definition
* @param string $src Source variable or expression
* @param bool $srcNonNull Whether $src is guaranteed non-null
* @return string
*/
private function convertValue(array $property, string $src, bool $srcNonNull = true): string
{
// Sub_schema (nested objects)
if (!empty($property['sub_schema'])) {
$subSchema = $this->toPascalCase($property['sub_schema']);
if ($property['type'] === 'array') {
return "{$src}.ToEnumerable().Select(it => {$subSchema}.From(map: (Dictionary<string, object>)it)).ToList()";
}
return "{$subSchema}.From(map: (Dictionary<string, object>){$src})";
}

// Enum
if (!empty($property['enum'])) {
$enumClass = $this->toPascalCase($property['enumName'] ?? $property['name']);
return "new {$enumClass}({$src}.ToString())";
}

// Arrays
if ($property['type'] === 'array') {
$itemsType = $property['items']['type'] ?? 'object';
$selectExpression = match ($itemsType) {
'string' => 'x.ToString()',
'integer' => 'Convert.ToInt64(x)',
'number' => 'Convert.ToDouble(x)',
'boolean' => '(bool)x',
default => 'x'
};
return "{$src}.ToEnumerable().Select(x => {$selectExpression}).ToList()";
}

// Integer/Number
if ($property['type'] === 'integer' || $property['type'] === 'number') {
$convertMethod = $property['type'] === 'integer' ? 'Int64' : 'Double';
return "Convert.To{$convertMethod}({$src})";
}

// Boolean
if ($property['type'] === 'boolean') {
return $srcNonNull ? "(bool){$src}" : "(bool?){$src}";
}

// String (default)
return $srcNonNull ? "{$src}.ToString()" : "{$src}?.ToString()";
}

/**
* Generate ToMap() value expression for a property.
*
* @param array $property
* @param string $definitionName
* @return string
*/
protected function getToMapExpression(array $property, string $definitionName): string
{
$propName = $this->getResolvedPropertyName($property, $definitionName);
$required = $property['required'] ?? true;
$nullOp = $required ? '' : '?';

if (!empty($property['sub_schema'])) {
if ($property['type'] === 'array') {
return "{$propName}{$nullOp}.Select(it => it.ToMap())" . (!$required ? '?.ToList()' : '');
}
return "{$propName}{$nullOp}.ToMap()";
}

if (!empty($property['enum'])) {
return "{$propName}{$nullOp}.Value";
}

return $propName;
}

/**
* Format a PHP array as a C# anonymous object
*/
Expand Down
1 change: 1 addition & 0 deletions templates/dotnet/Package/Client.cs.twig
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ namespace {{ spec.title | caseUcfirst }}

foreach (var parameter in parameters)
{
if (parameter.Value == null) continue;
if (parameter.Key == "file")
{
var fileContent = parameters["file"] as MultipartFormDataContent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,60 @@ namespace {{ spec.title | caseUcfirst }}.Converters
{
public class ObjectToInferredTypesConverter : JsonConverter<object>
{
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
using (JsonDocument document = JsonDocument.ParseValue(ref reader))
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.Number:
if (reader.TryGetInt64(out long l))
return ConvertElement(document.RootElement);
}
}

private object? ConvertElement(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
var dictionary = new Dictionary<string, object?>();
foreach (var property in element.EnumerateObject())
{
return l;
dictionary[property.Name] = ConvertElement(property.Value);
}
return dictionary;

case JsonValueKind.Array:
var list = new List<object?>();
foreach (var item in element.EnumerateArray())
{
list.Add(ConvertElement(item));
}
return reader.GetDouble();
case JsonTokenType.String:
if (reader.TryGetDateTime(out DateTime datetime))
return list;

case JsonValueKind.String:
if (element.TryGetDateTime(out DateTime datetime))
{
return datetime;
}
return reader.GetString()!;
case JsonTokenType.StartObject:
return JsonSerializer.Deserialize<Dictionary<string, object>>(ref reader, options)!;
case JsonTokenType.StartArray:
return JsonSerializer.Deserialize<object[]>(ref reader, options)!;
return element.GetString();

case JsonValueKind.Number:
if (element.TryGetInt64(out long l))
{
return l;
}
return element.GetDouble();

case JsonValueKind.True:
return true;

case JsonValueKind.False:
return false;

case JsonValueKind.Null:
case JsonValueKind.Undefined:
return null;

default:
return JsonDocument.ParseValue(ref reader).RootElement.Clone();
throw new JsonException($"Unsupported JsonValueKind: {element.ValueKind}");
}
}

Expand Down
6 changes: 3 additions & 3 deletions templates/dotnet/Package/Exception.cs.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ namespace {{spec.title | caseUcfirst}}
public class {{spec.title | caseUcfirst}}Exception : Exception
{
public int? Code { get; set; }
public string? Type { get; set; } = null;
public string? Response { get; set; } = null;
public string? Type { get; set; }
public string? Response { get; set; }

public {{spec.title | caseUcfirst}}Exception(
string? message = null,
Expand All @@ -18,10 +18,10 @@ namespace {{spec.title | caseUcfirst}}
this.Type = type;
this.Response = response;
}

public {{spec.title | caseUcfirst}}Exception(string message, Exception inner)
: base(message, inner)
{
}
}
}

19 changes: 9 additions & 10 deletions templates/dotnet/Package/Extensions/Extensions.cs.twig
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@ namespace {{ spec.title | caseUcfirst }}.Extensions
return JsonSerializer.Serialize(dict, Client.SerializerOptions);
}

public static List<T> ConvertToList<T>(this object value)
public static IEnumerable<object> ToEnumerable(this object value)
{
return value switch
{
JsonElement jsonElement => jsonElement.Deserialize<List<T>>() ?? throw new InvalidCastException($"Cannot deserialize {jsonElement} to List<{typeof(T)}>."),
object[] objArray => objArray.Cast<T>().ToList(),
List<T> list => list,
IEnumerable<T> enumerable => enumerable.ToList(),
_ => throw new InvalidCastException($"Cannot convert {value.GetType()} to List<{typeof(T)}>")
object[] array => array,
IEnumerable<object> enumerable => enumerable,
IEnumerable nonGeneric => nonGeneric.Cast<object>(),
_ => throw new InvalidCastException($"Cannot convert {value?.GetType().Name ?? "null"} to IEnumerable<object>")
};
}

Expand Down Expand Up @@ -50,7 +49,7 @@ namespace {{ spec.title | caseUcfirst }}.Extensions
return Uri.EscapeUriString(string.Join("&", query));
}

private static IDictionary<string, string> _mappings = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) {
private static readonly IDictionary<string, string> Mappings = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) {

#region Mime Types
{".323", "text/h323"},
Expand Down Expand Up @@ -621,20 +620,20 @@ namespace {{ spec.title | caseUcfirst }}.Extensions
{
if (extension == null)
{
throw new ArgumentNullException("extension");
throw new ArgumentNullException(nameof(extension));
}

if (!extension.StartsWith("."))
{
extension = "." + extension;
}

return _mappings.TryGetValue(extension, out var mime) ? mime : "application/octet-stream";
return Mappings.TryGetValue(extension, out var mime) ? mime : "application/octet-stream";
}

public static string GetMimeType(this string path)
{
return GetMimeTypeFromExtension(System.IO.Path.GetExtension(path));
}
}
}
}
Loading