From 0a48f7aeea8db3454cb3e60bc72c33b84c47fc81 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:04:00 +0300 Subject: [PATCH 01/17] Refactor object inference in JSON converter Replaces switch on JsonTokenType with a recursive method using JsonElement.ValueKind for more robust and accurate type inference. This improves handling of nested objects and arrays, and unifies the logic for converting JSON values to .NET types. --- .../ObjectToInferredTypesConverter.cs.twig | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig b/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig index 563f92992a..ce772c93df 100644 --- a/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig +++ b/templates/dotnet/Package/Converters/ObjectToInferredTypesConverter.cs.twig @@ -7,32 +7,60 @@ namespace {{ spec.title | caseUcfirst }}.Converters { public class ObjectToInferredTypesConverter : JsonConverter { - 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(); + foreach (var property in element.EnumerateObject()) { - return l; + dictionary[property.Name] = ConvertElement(property.Value); + } + return dictionary; + + case JsonValueKind.Array: + var list = new List(); + 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>(ref reader, options)!; - case JsonTokenType.StartArray: - return JsonSerializer.Deserialize(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}"); } } From b44f0b12a8f0847ffd30166f9180e0e7770ab2dd Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:04:19 +0300 Subject: [PATCH 02/17] Refactor model deserialization logic in C# template Simplifies and standardizes the deserialization of model properties from dictionaries, removing special handling for JsonElement and streamlining array and primitive type conversions. This improves code readability and maintainability in generated model classes. --- templates/dotnet/Package/Models/Model.cs.twig | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index ff46ff18e4..85468fac06 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -1,6 +1,5 @@ {% macro sub_schema(property) %}{% if property.sub_schema %}{% if property.type == 'array' %}List<{{property.sub_schema | caseUcfirst | overrideIdentifier}}>{% else %}{{property.sub_schema | caseUcfirst | overrideIdentifier}}{% endif %}{% else %}{{property | typeName}}{% endif %}{% if not property.required %}?{% endif %}{% endmacro %} {% macro property_name(definition, property) %}{{ property.name | caseUcfirst | removeDollarSign | escapeKeyword }}{% endmacro %} - using System; using System.Linq; using System.Collections.Generic; @@ -42,25 +41,21 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} {%- if property.sub_schema %} {%- if property.type == 'array' -%} - map["{{ property.name }}"] is JsonElement jsonArray{{ loop.index }} ? jsonArray{{ loop.index }}.Deserialize>>()!.Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() : ((IEnumerable>)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: it)).ToList() + ((IEnumerable)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)it)).ToList() {%- else -%} - {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: map["{{ property.name }}"] is JsonElement jsonObj{{ loop.index }} ? jsonObj{{ loop.index }}.Deserialize>()! : (Dictionary)map["{{ property.name }}"]) + {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)map["{{ property.name }}"]) {%- endif %} {%- else %} {%- if property.type == 'array' -%} - map["{{ property.name }}"] is JsonElement jsonArrayProp{{ loop.index }} ? jsonArrayProp{{ loop.index }}.Deserialize<{{ property | typeName }}>()! : ({{ property | typeName }})map["{{ property.name }}"] + ((IEnumerable)map["{{ property.name }}"]).Select(x => {% if property.items.type == "string" %}x?.ToString(){% elseif property.items.type == "integer" %}{% if not property.required %}x == null ? (long?)null : {% endif %}Convert.ToInt64(x){% elseif property.items.type == "number" %}{% if not property.required %}x == null ? (double?)null : {% endif %}Convert.ToDouble(x){% elseif property.items.type == "boolean" %}{% if not property.required %}x == null ? (bool?)null : {% endif %}(bool)x{% else %}x{% endif %}).{% if property.items.type == "string" and property.required %}Where(x => x != null).{% endif %}ToList()! {%- else %} {%- if property.type == "integer" or property.type == "number" %} - {%- if not property.required -%}map["{{ property.name }}"] == null ? null :{% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]) + {%- if not property.required -%}map["{{ property.name }}"] == null ? null : {% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]) {%- else %} {%- if property.type == "boolean" -%} ({{ property | typeName }}{% if not property.required %}?{% endif %})map["{{ property.name }}"] - {%- else %} - {%- if not property.required -%} - map.TryGetValue("{{ property.name }}", out var {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}) ? {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}?.ToString() : null - {%- else -%} - map["{{ property.name }}"].ToString() - {%- endif %} + {%- else -%} + map["{{ property.name }}"]{% if not property.required %}?{% endif %}.ToString() {%- endif %} {%~ endif %} {%~ endif %} From 4c6b9f7c0bb3003ff8602d311ad47088e19e6a72 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 9 Aug 2025 22:25:23 +0300 Subject: [PATCH 03/17] Handle optional properties in model From method Updated the From method in the model template to check for the existence of optional properties in the input map before assigning values. This prevents errors when optional properties are missing from the input dictionary. (for examle in model: User, :-/ ) --- templates/dotnet/Package/Models/Model.cs.twig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 85468fac06..f4eabaa7d5 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -39,6 +39,7 @@ namespace {{ spec.title | caseUcfirst }}.Models public static {{ definition.name | caseUcfirst | overrideIdentifier }} From(Dictionary map) => new {{ definition.name | caseUcfirst | overrideIdentifier }}( {%~ for property in definition.properties %} {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} + {%- if not property.required -%}map.ContainsKey("{{ property.name }}") ? {% endif %} {%- if property.sub_schema %} {%- if property.type == 'array' -%} ((IEnumerable)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)it)).ToList() @@ -60,6 +61,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endif %} {%~ endif %} + {%- if not property.required %} : null{% endif %} {%- if not loop.last or (loop.last and definition.additionalProperties) %}, {%~ endif %} {%~ endfor %} @@ -96,4 +98,4 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} } -} +} \ No newline at end of file From b071cbcfa8224cb41783c296ec5588eb606987bb Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:04:31 +0300 Subject: [PATCH 04/17] synchronization with the Unity template --- templates/dotnet/Package/Exception.cs.twig | 2 +- templates/dotnet/Package/Extensions/Extensions.cs.twig | 2 +- templates/dotnet/Package/Models/InputFile.cs.twig | 4 ++-- templates/dotnet/Package/Models/Model.cs.twig | 2 +- templates/dotnet/Package/Models/UploadProgress.cs.twig | 2 +- templates/dotnet/Package/Query.cs.twig | 2 +- templates/dotnet/Package/Role.cs.twig | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/templates/dotnet/Package/Exception.cs.twig b/templates/dotnet/Package/Exception.cs.twig index e78d78c2cc..31d9c70adc 100644 --- a/templates/dotnet/Package/Exception.cs.twig +++ b/templates/dotnet/Package/Exception.cs.twig @@ -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) { } } } - diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index d57318077e..ec325429fb 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -624,4 +624,4 @@ namespace {{ spec.title | caseUcfirst }}.Extensions return GetMimeTypeFromExtension(System.IO.Path.GetExtension(path)); } } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Models/InputFile.cs.twig b/templates/dotnet/Package/Models/InputFile.cs.twig index 241a3adad5..aaf7a66202 100644 --- a/templates/dotnet/Package/Models/InputFile.cs.twig +++ b/templates/dotnet/Package/Models/InputFile.cs.twig @@ -1,5 +1,5 @@ using System.IO; -using Appwrite.Extensions; +using {{ spec.title | caseUcfirst }}.Extensions; namespace {{ spec.title | caseUcfirst }}.Models { @@ -38,4 +38,4 @@ namespace {{ spec.title | caseUcfirst }}.Models SourceType = "bytes" }; } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index f4eabaa7d5..a142d474e8 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -98,4 +98,4 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Models/UploadProgress.cs.twig b/templates/dotnet/Package/Models/UploadProgress.cs.twig index 47c78391ce..ee6fb58ba3 100644 --- a/templates/dotnet/Package/Models/UploadProgress.cs.twig +++ b/templates/dotnet/Package/Models/UploadProgress.cs.twig @@ -23,4 +23,4 @@ namespace {{ spec.title | caseUcfirst }} ChunksUploaded = chunksUploaded; } } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Query.cs.twig b/templates/dotnet/Package/Query.cs.twig index 18359f30c2..9c3ec9f82a 100644 --- a/templates/dotnet/Package/Query.cs.twig +++ b/templates/dotnet/Package/Query.cs.twig @@ -158,4 +158,4 @@ namespace {{ spec.title | caseUcfirst }} return new Query("and", null, queries.Select(q => JsonSerializer.Deserialize(q, Client.DeserializerOptions)).ToList()).ToString(); } } -} \ No newline at end of file +} diff --git a/templates/dotnet/Package/Role.cs.twig b/templates/dotnet/Package/Role.cs.twig index b3ecf2610b..3c7b2b33f3 100644 --- a/templates/dotnet/Package/Role.cs.twig +++ b/templates/dotnet/Package/Role.cs.twig @@ -1,4 +1,4 @@ -namespace Appwrite +namespace {{ spec.title | caseUcfirst }} { /// /// Helper class to generate role strings for Permission. @@ -89,4 +89,4 @@ namespace Appwrite return $"label:{name}"; } } -} \ No newline at end of file +} From e9586d2acf1ea2191be450d90924f54fe632bb0e Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:26:15 +0300 Subject: [PATCH 05/17] Refactor model parsing for nullable and array properties Improves the From() method in Model.cs.twig to handle nullable and array properties more robustly, using helper macros for parsing arrays and sub-schemas. This change ensures correct handling of optional fields and type conversions, reducing runtime errors and improving code maintainability. Also removes an unnecessary blank line in ServiceTemplate.cs.twig. --- templates/dotnet/Package/Models/Model.cs.twig | 45 ++++++++++++++++--- .../Package/Services/ServiceTemplate.cs.twig | 1 - 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index a142d474e8..ef559eaa23 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -1,5 +1,12 @@ {% macro sub_schema(property) %}{% if property.sub_schema %}{% if property.type == 'array' %}List<{{property.sub_schema | caseUcfirst | overrideIdentifier}}>{% else %}{{property.sub_schema | caseUcfirst | overrideIdentifier}}{% endif %}{% else %}{{property | typeName}}{% endif %}{% if not property.required %}?{% endif %}{% endmacro %} {% macro property_name(definition, property) %}{{ property.name | caseUcfirst | removeDollarSign | escapeKeyword }}{% endmacro %} +{% macro array_source(src, required) %}{% if required %}((IEnumerable){{ src | raw }}){% else %}({{ src | raw }} as IEnumerable ?? Array.Empty()){% endif %}{% endmacro %} +{%~ macro parse_primitive_array(items_type, src, required) -%} + {{ _self.array_source(src, required) }}.Select(x => {% if items_type == "string" %}x?.ToString(){% elseif items_type == "integer" %}{% if not required %}x == null ? (long?)null : {% endif %}Convert.ToInt64(x){% elseif items_type == "number" %}{% if not required %}x == null ? (double?)null : {% endif %}Convert.ToDouble(x){% elseif items_type == "boolean" %}{% if not required %}x == null ? (bool?)null : {% endif %}(bool)x{% else %}x{% endif %}){% if required and items_type == "string" %}.Where(x => x != null){% endif %}.ToList()! +{%- endmacro -%} +{%~ macro parse_subschema_array(sub_schema_name, src, required) -%} + {{ _self.array_source(src, required) }}.Select(it => {{ sub_schema_name | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)it)).ToList() +{%- endmacro -%} using System; using System.Linq; using System.Collections.Generic; @@ -38,25 +45,49 @@ namespace {{ spec.title | caseUcfirst }}.Models public static {{ definition.name | caseUcfirst | overrideIdentifier }} From(Dictionary map) => new {{ definition.name | caseUcfirst | overrideIdentifier }}( {%~ for property in definition.properties %} + {%~ set v = 'v' ~ loop.index0 %} + {%~ set mapAccess = 'map["' ~ property.name ~ '"]' %} {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} - {%- if not property.required -%}map.ContainsKey("{{ property.name }}") ? {% endif %} + {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif %} {%- if property.sub_schema %} {%- if property.type == 'array' -%} - ((IEnumerable)map["{{ property.name }}"]).Select(it => {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)it)).ToList() + {%- if property.required -%} + {{ _self.parse_subschema_array(property.sub_schema, mapAccess, true) }} + {%- else -%} + {{ _self.parse_subschema_array(property.sub_schema, v, false) }} + {%- endif %} {%- else -%} - {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)map["{{ property.name }}"]) + {%- if property.required -%} + {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary){{ mapAccess | raw }}) + {%- else -%} + ({{ v }} as Dictionary) is { } obj + ? {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: obj) + : null + {%- endif %} {%- endif %} {%- else %} {%- if property.type == 'array' -%} - ((IEnumerable)map["{{ property.name }}"]).Select(x => {% if property.items.type == "string" %}x?.ToString(){% elseif property.items.type == "integer" %}{% if not property.required %}x == null ? (long?)null : {% endif %}Convert.ToInt64(x){% elseif property.items.type == "number" %}{% if not property.required %}x == null ? (double?)null : {% endif %}Convert.ToDouble(x){% elseif property.items.type == "boolean" %}{% if not property.required %}x == null ? (bool?)null : {% endif %}(bool)x{% else %}x{% endif %}).{% if property.items.type == "string" and property.required %}Where(x => x != null).{% endif %}ToList()! + {%- if property.required -%} + {{ _self.parse_primitive_array(property.items.type, mapAccess, true) }} + {%- else -%} + {{ _self.parse_primitive_array(property.items.type, v, false) }} + {%- endif -%} {%- else %} {%- if property.type == "integer" or property.type == "number" %} - {%- if not property.required -%}map["{{ property.name }}"] == null ? null : {% endif %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}(map["{{ property.name }}"]) + {%- if not property.required -%}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}({{ v }}){% else %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}({{ mapAccess | raw }}){%- endif %} {%- else %} {%- if property.type == "boolean" -%} - ({{ property | typeName }}{% if not property.required %}?{% endif %})map["{{ property.name }}"] + {%- if not property.required -%} + ({{ property | typeName }}?){{ v }} + {%- else -%} + ({{ property | typeName }}){{ mapAccess | raw }} + {%- endif %} {%- else -%} - map["{{ property.name }}"]{% if not property.required %}?{% endif %}.ToString() + {%- if not property.required -%} + {{ v }}?.ToString() + {%- else -%} + {{ mapAccess | raw }}.ToString() + {%- endif %} {%- endif %} {%~ endif %} {%~ endif %} diff --git a/templates/dotnet/Package/Services/ServiceTemplate.cs.twig b/templates/dotnet/Package/Services/ServiceTemplate.cs.twig index 99cf15653b..8043469739 100644 --- a/templates/dotnet/Package/Services/ServiceTemplate.cs.twig +++ b/templates/dotnet/Package/Services/ServiceTemplate.cs.twig @@ -1,5 +1,4 @@ {% import 'dotnet/base/utils.twig' as utils %} - using System; using System.Collections.Generic; using System.Linq; From 5ad9a4b49610064b3799852ba4680709e6757d1e Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:15:59 +0300 Subject: [PATCH 06/17] Skip null parameters in request parameter loop Fields with null values in multipart are now omitted (so they don't turn into empty strings). --- templates/dotnet/Package/Client.cs.twig | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/dotnet/Package/Client.cs.twig b/templates/dotnet/Package/Client.cs.twig index 8f95277902..6f349ee847 100644 --- a/templates/dotnet/Package/Client.cs.twig +++ b/templates/dotnet/Package/Client.cs.twig @@ -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; From 953600d0cbdfa5f15fb80df09c0a5a866c961158 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:14:32 +0300 Subject: [PATCH 07/17] Refactor model class name generation in template --- templates/dotnet/Package/Models/Model.cs.twig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index ef559eaa23..7df4b45c65 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -7,6 +7,7 @@ {%~ macro parse_subschema_array(sub_schema_name, src, required) -%} {{ _self.array_source(src, required) }}.Select(it => {{ sub_schema_name | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary)it)).ToList() {%- endmacro -%} +{% set DefinitionClass = definition.name | caseUcfirst | overrideIdentifier %} using System; using System.Linq; using System.Collections.Generic; @@ -15,7 +16,7 @@ using System.Text.Json.Serialization; namespace {{ spec.title | caseUcfirst }}.Models { - public class {{ definition.name | caseUcfirst | overrideIdentifier }} + public class {{ DefinitionClass }} { {%~ for property in definition.properties %} [JsonPropertyName("{{ property.name }}")] @@ -26,7 +27,7 @@ namespace {{ spec.title | caseUcfirst }}.Models public Dictionary Data { get; private set; } {%~ endif %} - public {{ definition.name | caseUcfirst | overrideIdentifier }}( + public {{ DefinitionClass }}( {%~ for property in definition.properties %} {{ _self.sub_schema(property) }} {{ property.name | caseCamel | escapeKeyword }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} @@ -43,7 +44,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} } - public static {{ definition.name | caseUcfirst | overrideIdentifier }} From(Dictionary map) => new {{ definition.name | caseUcfirst | overrideIdentifier }}( + public static {{ DefinitionClass }} From(Dictionary map) => new {{ DefinitionClass }}( {%~ for property in definition.properties %} {%~ set v = 'v' ~ loop.index0 %} {%~ set mapAccess = 'map["' ~ property.name ~ '"]' %} From cc2cd62bebcf2f079266d163f7eaa672105de3ee Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:54:08 +0300 Subject: [PATCH 08/17] Add parse_value Twig function for DotNet models Introduces a new parse_value Twig function in DotNet.php to centralize and simplify value parsing logic for model properties. Updates Model.cs.twig to use this function, reducing template complexity and improving maintainability. --- src/SDK/Language/DotNet.php | 80 ++++++++++++++++++- templates/dotnet/Package/Models/Model.cs.twig | 64 ++------------- 2 files changed, 84 insertions(+), 60 deletions(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 085a503a3b..e8f725c43f 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -467,7 +467,7 @@ public function getFilters(): array } /** - * get sub_scheme and property_name functions + * get sub_scheme, property_name and parse_value functions * @return TwigFunction[] */ public function getFunctions(): array @@ -494,7 +494,7 @@ public function getFunctions(): array } return $result; - }), + }, ['is_safe' => ['html']]), new TwigFunction('property_name', function (array $definition, array $property) { $name = $property['name']; $name = \str_replace('$', '', $name); @@ -504,6 +504,82 @@ public function getFunctions(): array } return $name; }), + new TwigFunction('parse_value', function (array $property, string $mapAccess, string $v) { + $required = $property['required'] ?? false; + + // Handle sub_schema + if (isset($property['sub_schema']) && !empty($property['sub_schema'])) { + $subSchema = \ucfirst($property['sub_schema']); + + if ($property['type'] === 'array') { + $arraySource = $required + ? "((IEnumerable){$mapAccess})" + : "({$v} as IEnumerable)"; + return "{$arraySource}?.Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()!"; + } else { + if ($required) { + return "{$subSchema}.From(map: (Dictionary){$mapAccess})"; + } + return "({$v} as Dictionary) is { } obj ? {$subSchema}.From(map: obj) : null"; + } + } + + // Handle enum + if (isset($property['enum']) && !empty($property['enum'])) { + $enumName = $property['enumName'] ?? $property['name']; + $enumClass = \ucfirst($enumName); + + if ($required) { + return "new {$enumClass}({$mapAccess}.ToString())"; + } + return "{$v} == null ? null : new {$enumClass}({$v}.ToString())"; + } + + // Handle arrays + if ($property['type'] === 'array') { + $itemsType = $property['items']['type'] ?? 'object'; + $src = $required ? $mapAccess : $v; + $arraySource = $required + ? "((IEnumerable){$src})" + : "({$src} as IEnumerable)"; + + $selectExpression = match($itemsType) { + 'string' => 'x.ToString()', + 'integer' => 'Convert.ToInt64(x)', + 'number' => 'Convert.ToDouble(x)', + 'boolean' => '(bool)x', + default => 'x' + }; + + return "{$arraySource}?.Select(x => {$selectExpression}).ToList()!"; + } + + // Handle integer/number + if ($property['type'] === 'integer' || $property['type'] === 'number') { + $convertMethod = $property['type'] === 'integer' ? 'Int64' : 'Double'; + + if ($required) { + return "Convert.To{$convertMethod}({$mapAccess})"; + } + return "{$v} == null ? null : Convert.To{$convertMethod}({$v})"; + } + + // Handle boolean + if ($property['type'] === 'boolean') { + $typeName = $this->getTypeName($property); + + if ($required) { + return "({$typeName}){$mapAccess}"; + } + return "({$typeName}?){$v}"; + } + + // Handle string type + if ($required) { + return "{$mapAccess}.ToString()"; + } + return "{$v}?.ToString()"; + }, ['is_safe' => ['html']]), ]; } diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index d4105c2573..1f8c534077 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -1,3 +1,4 @@ +{% set DefinitionClass = definition.name | caseUcfirst | overrideIdentifier %} using System; using System.Linq; using System.Collections.Generic; @@ -11,7 +12,7 @@ namespace {{ spec.title | caseUcfirst }}.Models { {%~ for property in definition.properties %} [JsonPropertyName("{{ property.name }}")] - public {{ sub_schema(property) | raw }} {{ property_name(definition, property) | overrideProperty(definition.name) }} { get; private set; } + public {{ sub_schema(property) }} {{ property_name(definition, property) | overrideProperty(definition.name) }} { get; private set; } {%~ endfor %} {%~ if definition.additionalProperties %} @@ -20,7 +21,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} public {{ DefinitionClass }}( {%~ for property in definition.properties %} - {{ sub_schema(property) | raw }} {{ property.name | caseCamel | escapeKeyword }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} + {{ sub_schema(property) }} {{ property.name | caseCamel | escapeKeyword }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} {%~ endfor %} {%~ if definition.additionalProperties %} @@ -40,62 +41,9 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ set v = 'v' ~ loop.index0 %} {%~ set mapAccess = 'map["' ~ property.name ~ '"]' %} {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} - {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif %} - {%- if property.sub_schema %} - {%- if property.type == 'array' -%} - {%- if property.required -%} - {{ _self.parse_subschema_array(property.sub_schema, mapAccess, true) }} - {%- else -%} - {{ _self.parse_subschema_array(property.sub_schema, v, false) }} - {%- endif %} - {%- else -%} - {%- if property.required -%} - {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: (Dictionary){{ mapAccess | raw }}) - {%- else -%} - ({{ v }} as Dictionary) is { } obj - ? {{ property.sub_schema | caseUcfirst | overrideIdentifier }}.From(map: obj) - : null - {%- endif %} - {%- endif %} - {%- elseif property.enum %} - {%- set enumName = property['enumName'] ?? property.name -%} - {%- if not property.required -%} - map.TryGetValue("{{ property.name }}", out var enumRaw{{ loop.index }}) - ? enumRaw{{ loop.index }} == null - ? null - : new {{ enumName | caseUcfirst }}(enumRaw{{ loop.index }}.ToString()!) - : null - {%- else -%} - new {{ enumName | caseUcfirst }}(map["{{ property.name }}"].ToString()!) - {%- endif %} - {%- else %} - {%- if property.type == 'array' -%} - {%- if property.required -%} - {{ _self.parse_primitive_array(property.items.type, mapAccess, true) }} - {%- else -%} - {{ _self.parse_primitive_array(property.items.type, v, false) }} - {%- endif -%} - {%- else %} - {%- if property.type == "integer" or property.type == "number" %} - {%- if not property.required -%}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}({{ v }}){% else %}Convert.To{% if property.type == "integer" %}Int64{% else %}Double{% endif %}({{ mapAccess | raw }}){%- endif %} - {%- else %} - {%- if property.type == "boolean" -%} - {%- if not property.required -%} - ({{ property | typeName }}?){{ v }} - {%- else -%} - ({{ property | typeName }}){{ mapAccess | raw }} - {%- endif %} - {%- else -%} - {%- if not property.required -%} - {{ v }}?.ToString() - {%- else -%} - {{ mapAccess | raw }}.ToString() - {%- endif %} - {%- endif %} - {%~ endif %} - {%~ endif %} - {%~ endif %} - {%- if not property.required %} : null{% endif %} + {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif -%} +{{ parse_value(property, mapAccess, v) }} + {%- if not property.required %} : null{% endif -%} {%- if not loop.last or (loop.last and definition.additionalProperties) %}, {%~ endif %} {%~ endfor %} From ebbad72cb5c2daebf505a95f485292b131c0d665 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:34:22 +0300 Subject: [PATCH 09/17] make generated array mappings null-safe Remove null-forgiving operator (!) from optional array mappings and use null-safe casting to preserve null vs empty semantics in generated models. --- src/SDK/Language/DotNet.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index e8f725c43f..3ed39f3394 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -514,8 +514,8 @@ public function getFunctions(): array if ($property['type'] === 'array') { $arraySource = $required ? "((IEnumerable){$mapAccess})" - : "({$v} as IEnumerable)"; - return "{$arraySource}?.Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()!"; + : "({$v} as IEnumerable)?"; + return "{$arraySource}.Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()"; } else { if ($required) { return "{$subSchema}.From(map: (Dictionary){$mapAccess})"; @@ -541,7 +541,7 @@ public function getFunctions(): array $src = $required ? $mapAccess : $v; $arraySource = $required ? "((IEnumerable){$src})" - : "({$src} as IEnumerable)"; + : "({$src} as IEnumerable)?"; $selectExpression = match($itemsType) { 'string' => 'x.ToString()', @@ -551,7 +551,7 @@ public function getFunctions(): array default => 'x' }; - return "{$arraySource}?.Select(x => {$selectExpression}).ToList()!"; + return "{$arraySource}.Select(x => {$selectExpression}).ToList()"; } // Handle integer/number From ff2545a602d7d81aeb36f68123ec9d0aaac9d8e7 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Fri, 3 Oct 2025 07:13:18 +0300 Subject: [PATCH 10/17] lint --- src/SDK/Language/DotNet.php | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 3ed39f3394..d304aa18cc 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -506,14 +506,14 @@ public function getFunctions(): array }), new TwigFunction('parse_value', function (array $property, string $mapAccess, string $v) { $required = $property['required'] ?? false; - + // Handle sub_schema if (isset($property['sub_schema']) && !empty($property['sub_schema'])) { $subSchema = \ucfirst($property['sub_schema']); - + if ($property['type'] === 'array') { - $arraySource = $required - ? "((IEnumerable){$mapAccess})" + $arraySource = $required + ? "((IEnumerable){$mapAccess})" : "({$v} as IEnumerable)?"; return "{$arraySource}.Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()"; } else { @@ -523,7 +523,7 @@ public function getFunctions(): array return "({$v} as Dictionary) is { } obj ? {$subSchema}.From(map: obj) : null"; } } - + // Handle enum if (isset($property['enum']) && !empty($property['enum'])) { $enumName = $property['enumName'] ?? $property['name']; @@ -534,46 +534,46 @@ public function getFunctions(): array } return "{$v} == null ? null : new {$enumClass}({$v}.ToString())"; } - + // Handle arrays if ($property['type'] === 'array') { $itemsType = $property['items']['type'] ?? 'object'; $src = $required ? $mapAccess : $v; - $arraySource = $required - ? "((IEnumerable){$src})" + $arraySource = $required + ? "((IEnumerable){$src})" : "({$src} as IEnumerable)?"; - - $selectExpression = match($itemsType) { + + $selectExpression = match ($itemsType) { 'string' => 'x.ToString()', 'integer' => 'Convert.ToInt64(x)', 'number' => 'Convert.ToDouble(x)', 'boolean' => '(bool)x', default => 'x' }; - + return "{$arraySource}.Select(x => {$selectExpression}).ToList()"; } - + // Handle integer/number if ($property['type'] === 'integer' || $property['type'] === 'number') { $convertMethod = $property['type'] === 'integer' ? 'Int64' : 'Double'; - + if ($required) { return "Convert.To{$convertMethod}({$mapAccess})"; } return "{$v} == null ? null : Convert.To{$convertMethod}({$v})"; } - + // Handle boolean if ($property['type'] === 'boolean') { $typeName = $this->getTypeName($property); - + if ($required) { return "({$typeName}){$mapAccess}"; } return "({$typeName}?){$v}"; } - + // Handle string type if ($required) { return "{$mapAccess}.ToString()"; From faad58532cd4fa839c9f68dd650d0aa7ea11760f Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:25:22 +0300 Subject: [PATCH 11/17] Import Enums namespace conditionally in model template Adds conditional import of the Enums namespace in the Model.cs.twig template only when the model definition contains enum properties. This prevents unnecessary imports and improves template clarity. --- templates/dotnet/Package/Models/Model.cs.twig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 1f8c534077..978b6757d4 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -4,7 +4,9 @@ using System.Linq; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +{% if definition.properties | filter(p => p.enum) | length > 0 %} using {{ spec.title | caseUcfirst }}.Enums; +{% endif %} namespace {{ spec.title | caseUcfirst }}.Models { From 10993e5e31936e25144a6129b817b2b28dfc09f4 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:33:07 +0300 Subject: [PATCH 12/17] Refactor array handling in DotNet code generation Introduces a ToEnumerable extension method to unify array and enumerable conversions in generated .NET code. Updates code generation logic to use ToEnumerable for array properties, simplifying and improving type safety. Also adds necessary using statement for Extensions in generated model files. --- src/SDK/Language/DotNet.php | 11 +++-------- .../dotnet/Package/Extensions/Extensions.cs.twig | 11 +++++++++++ templates/dotnet/Package/Models/Model.cs.twig | 1 + 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index d304aa18cc..223960a9f4 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -512,10 +512,8 @@ public function getFunctions(): array $subSchema = \ucfirst($property['sub_schema']); if ($property['type'] === 'array') { - $arraySource = $required - ? "((IEnumerable){$mapAccess})" - : "({$v} as IEnumerable)?"; - return "{$arraySource}.Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()"; + $src = $required ? $mapAccess : $v; + return "{$src}.ToEnumerable().Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()"; } else { if ($required) { return "{$subSchema}.From(map: (Dictionary){$mapAccess})"; @@ -539,9 +537,6 @@ public function getFunctions(): array if ($property['type'] === 'array') { $itemsType = $property['items']['type'] ?? 'object'; $src = $required ? $mapAccess : $v; - $arraySource = $required - ? "((IEnumerable){$src})" - : "({$src} as IEnumerable)?"; $selectExpression = match ($itemsType) { 'string' => 'x.ToString()', @@ -551,7 +546,7 @@ public function getFunctions(): array default => 'x' }; - return "{$arraySource}.Select(x => {$selectExpression}).ToList()"; + return "{$src}.ToEnumerable().Select(x => {$selectExpression}).ToList()"; } // Handle integer/number diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index ec325429fb..5827301791 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -12,6 +12,17 @@ namespace {{ spec.title | caseUcfirst }}.Extensions return JsonSerializer.Serialize(dict, Client.SerializerOptions); } + public static IEnumerable ToEnumerable(this object value) + { + return value switch + { + object[] array => array, + IEnumerable enumerable => enumerable, + IEnumerable nonGeneric => nonGeneric.Cast(), + _ => throw new InvalidCastException($"Cannot convert {value?.GetType().Name ?? "null"} to IEnumerable") + }; + } + public static string ToQueryString(this Dictionary parameters) { var query = new List(); diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 978b6757d4..2ff72fac9e 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; {% if definition.properties | filter(p => p.enum) | length > 0 %} using {{ spec.title | caseUcfirst }}.Enums; {% endif %} +using {{ spec.title | caseUcfirst }}.Extensions; namespace {{ spec.title | caseUcfirst }}.Models { From a91f3885c7d1c7f038e37a9e10a83dffa8b06d2d Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:04:10 +0300 Subject: [PATCH 13/17] Update docblock for getFunctions method --- src/SDK/Language/DotNet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index fc4478c12d..90300a8f31 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -541,7 +541,7 @@ protected function getPropertyType(array $property, array $spec = []): string } /** - * get sub_scheme and property_name functions + * get sub_scheme, property_name and parse_value functions * @return TwigFunction[] */ public function getFunctions(): array From 2950f87f9382aaffb771baa3d220546f5911aa8e Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:03:39 +0300 Subject: [PATCH 14/17] Refactor exception and extension handling --- templates/dotnet/Package/Exception.cs.twig | 4 ++-- templates/dotnet/Package/Extensions/Extensions.cs.twig | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/templates/dotnet/Package/Exception.cs.twig b/templates/dotnet/Package/Exception.cs.twig index 31d9c70adc..857bd0eeb2 100644 --- a/templates/dotnet/Package/Exception.cs.twig +++ b/templates/dotnet/Package/Exception.cs.twig @@ -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, diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index 4fe485e1c6..55635f7bc1 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -49,7 +49,7 @@ namespace {{ spec.title | caseUcfirst }}.Extensions return Uri.EscapeUriString(string.Join("&", query)); } - private static IDictionary _mappings = new Dictionary(StringComparer.InvariantCultureIgnoreCase) { + private static readonly IDictionary Mappings = new Dictionary(StringComparer.InvariantCultureIgnoreCase) { #region Mime Types {".323", "text/h323"}, @@ -620,7 +620,7 @@ namespace {{ spec.title | caseUcfirst }}.Extensions { if (extension == null) { - throw new ArgumentNullException("extension"); + throw new ArgumentNullException(nameof(extension)); } if (!extension.StartsWith(".")) @@ -628,7 +628,7 @@ namespace {{ spec.title | caseUcfirst }}.Extensions 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) From 488fe88c5629d922cec8d2e5f65543607e4dcd04 Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:52:34 +0300 Subject: [PATCH 15/17] Refactor .NET model property serialization Introduce centralized property conversion and mapping helpers for the .NET SDK generator and update model template to use them. Changes: - Add Twig filters: propertyAssignment and toMapValue. - Make getPropertyType optionally return non-fully-qualified names. - Replace inline parsing logic with new methods: getPropertyName, getResolvedPropertyName, getPropertyAssignment, convertValue, getToMapExpression. These handle enums, sub-schemas, arrays, primitive conversions and null-safety in a unified way. - Simplify getFunctions() implementation to delegate to the new helpers. - Update templates/dotnet/Package/Models/Model.cs.twig to use the new filters (propertyAssignment, toMapValue) and clean up From/ToMap generation. Reason: centralizes and standardizes property (de)serialization logic, improves null handling, supports property overrides, and simplifies the model template. --- src/SDK/Language/DotNet.php | 236 +++++++++++------- templates/dotnet/Package/Models/Model.cs.twig | 18 +- .../Package/Models/RequestModel.cs.twig | 2 +- 3 files changed, 159 insertions(+), 97 deletions(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 90300a8f31..43bde0126a 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -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); + }), ]; } @@ -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']); @@ -534,34 +540,22 @@ 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); } /** - * get sub_scheme, property_name and parse_value functions + * get sub_scheme and property_name functions * @return TwigFunction[] */ 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 .= '?'; @@ -570,86 +564,160 @@ public function getFunctions(): array 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); }), - new TwigFunction('parse_value', function (array $property, string $mapAccess, string $v) { - $required = $property['required'] ?? false; + ]; + } - // Handle sub_schema - if (isset($property['sub_schema']) && !empty($property['sub_schema'])) { - $subSchema = \ucfirst($property['sub_schema']); + /** + * 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; + } - if ($property['type'] === 'array') { - $src = $required ? $mapAccess : $v; - return "{$src}.ToEnumerable().Select(it => {$subSchema}.From(map: (Dictionary)it)).ToList()"; - } else { - if ($required) { - return "{$subSchema}.From(map: (Dictionary){$mapAccess})"; - } - return "({$v} as Dictionary) is { } obj ? {$subSchema}.From(map: obj) : null"; - } - } + /** + * 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; + } - // Handle enum - if (isset($property['enum']) && !empty($property['enum'])) { - $enumName = $property['enumName'] ?? $property['name']; - $enumClass = \ucfirst($enumName); + /** + * 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 "new {$enumClass}({$mapAccess}.ToString())"; - } - return "{$v} == null ? null : new {$enumClass}({$v}.ToString())"; - } + if ($required) { + return $this->convertValue($property, $mapAccess); + } - // Handle arrays - if ($property['type'] === 'array') { - $itemsType = $property['items']['type'] ?? 'object'; - $src = $required ? $mapAccess : $v; + $v = 'v' . $this->toPascalCase(\str_replace('$', '', $propertyName)); + $tryGet = "map.TryGetValue(\"{$propertyName}\", out var {$v})"; - $selectExpression = match ($itemsType) { - 'string' => 'x.ToString()', - 'integer' => 'Convert.ToInt64(x)', - 'number' => 'Convert.ToDouble(x)', - 'boolean' => '(bool)x', - default => 'x' - }; + // 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 {$v}Map ? {$subSchema}.From(map: {$v}Map) : null"; + } - return "{$src}.ToEnumerable().Select(x => {$selectExpression}).ToList()"; - } + // 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"; + } - // Handle integer/number - if ($property['type'] === 'integer' || $property['type'] === 'number') { - $convertMethod = $property['type'] === 'integer' ? 'Int64' : 'Double'; + // String, boolean, arrays — null-safe conversion + $expr = $this->convertValue($property, $v, false); + return "{$tryGet} ? {$expr} : null"; + } - if ($required) { - return "Convert.To{$convertMethod}({$mapAccess})"; - } - return "{$v} == null ? null : Convert.To{$convertMethod}({$v})"; - } + /** + * 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)it)).ToList()"; + } + return "{$subSchema}.From(map: (Dictionary){$src})"; + } - // Handle boolean - if ($property['type'] === 'boolean') { - $typeName = $this->getTypeName($property); + // Enum + if (!empty($property['enum'])) { + $enumClass = $this->toPascalCase($property['enumName'] ?? $property['name']); + return "new {$enumClass}({$src}.ToString())"; + } - if ($required) { - return "({$typeName}){$mapAccess}"; - } - return "({$typeName}?){$v}"; - } + // 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()"; + } - // Handle string type - if ($required) { - return "{$mapAccess}.ToString()"; - } - return "{$v}?.ToString()"; - }, ['is_safe' => ['html']]), - ]; + // 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; } /** diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 2ff72fac9e..3b77f1b160 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -41,24 +41,18 @@ namespace {{ spec.title | caseUcfirst }}.Models public static {{ DefinitionClass }} From(Dictionary map) => new {{ DefinitionClass }}( {%~ for property in definition.properties %} - {%~ set v = 'v' ~ loop.index0 %} - {%~ set mapAccess = 'map["' ~ property.name ~ '"]' %} - {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}:{{' '}} - {%- if not property.required -%}map.TryGetValue("{{ property.name }}", out var {{ v }}) ? {% endif -%} -{{ parse_value(property, mapAccess, v) }} - {%- if not property.required %} : null{% endif -%} - {%- if not loop.last or (loop.last and definition.additionalProperties) %}, - {%~ endif %} + {{ property.name | caseCamel | escapeKeyword | removeDollarSign }}: {{ property | propertyAssignment | raw }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} + {%~ endfor %} - {%- if definition.additionalProperties %} + {%~ if definition.additionalProperties %} data: map.TryGetValue("data", out var dataValue) ? (Dictionary)dataValue : map - {%- endif ~%} + {%~ endif %} ); public Dictionary ToMap() => new Dictionary() { {%~ for property in definition.properties %} - { "{{ property.name }}", {% if property.sub_schema %}{% if property.type == 'array' %}{{ property_name(definition, property) | overrideProperty(definition.name) }}.Select(it => it.ToMap()){% else %}{{ property_name(definition, property) | overrideProperty(definition.name) }}.ToMap(){% endif %}{% elseif property.enum %}{{ property_name(definition, property) | overrideProperty(definition.name) }}{% if not property.required %}?{% endif %}.Value{% else %}{{ property_name(definition, property) | overrideProperty(definition.name) }}{% endif %}{{ ' }' }}{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} + { "{{ property.name }}", {{ property | toMapValue(definition.name) | raw }} }{% if not loop.last or (loop.last and definition.additionalProperties) %},{% endif %} {%~ endfor %} {%~ if definition.additionalProperties %} @@ -71,7 +65,7 @@ namespace {{ spec.title | caseUcfirst }}.Models fromJson.Invoke(Data); {%~ endif %} {%~ for property in definition.properties %} - {%~ if property.sub_schema %} + {%~ if property.sub_schema and not definition.additionalProperties %} {%~ for def in spec.definitions %} {%~ if def.name == property.sub_schema and def.additionalProperties and property.type == 'array' %} diff --git a/templates/dotnet/Package/Models/RequestModel.cs.twig b/templates/dotnet/Package/Models/RequestModel.cs.twig index 333320442e..e87c9bc41f 100644 --- a/templates/dotnet/Package/Models/RequestModel.cs.twig +++ b/templates/dotnet/Package/Models/RequestModel.cs.twig @@ -36,7 +36,7 @@ namespace {{ spec.title | caseUcfirst }}.Models return new Dictionary { {%~ for property in requestModel.properties %} - { "{{ property.name }}", {% if property.sub_schema %}{% if property.type == 'array' %}{{ property.name | caseUcfirst | overrideIdentifier }}?.ConvertAll(p => p.ToMap()){% else %}{{ property.name | caseUcfirst | overrideIdentifier }}?.ToMap(){% endif %}{% elseif property.enum %}{{ property.name | caseUcfirst | overrideIdentifier }}{% if not property.required %}?{% endif %}.Value{% else %}{{ property.name | caseUcfirst | overrideIdentifier }}{% endif %} }{% if not loop.last %},{% endif %} + { "{{ property.name }}", {{ property | toMapValue(requestModel.name) | raw }} }{% if not loop.last %},{% endif %} {%~ endfor %} }; From 8fe9521032ad5442bc39649dd98e0d0244661a5b Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:01:12 +0300 Subject: [PATCH 16/17] Always call ToList() for mapped arrays --- src/SDK/Language/DotNet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 43bde0126a..a170003a5a 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -708,7 +708,7 @@ protected function getToMapExpression(array $property, string $definitionName): if (!empty($property['sub_schema'])) { if ($property['type'] === 'array') { - return "{$propName}{$nullOp}.Select(it => it.ToMap())" . (!$required ? '?.ToList()' : ''); + return "{$propName}{$nullOp}.Select(it => it.ToMap()).ToList()"; } return "{$propName}{$nullOp}.ToMap()"; } From eab63b9bd01c1e353ab29751a2a256c07654444e Mon Sep 17 00:00:00 2001 From: Fellmonkey <90258055+Fellmonkey@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:53:53 +0300 Subject: [PATCH 17/17] Add missing closing for toMapValue TwigFilter --- src/SDK/Language/DotNet.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index ffb264c095..5eb25ad4d0 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -527,6 +527,7 @@ public function getFilters(): array }), new TwigFilter('toMapValue', function (array $property, string $definitionName) { return $this->getToMapExpression($property, $definitionName); + }), new TwigFilter('enumExample', function (array $param) { $enumValues = $param['enumValues'] ?? []; if (empty($enumValues)) {