diff --git a/avrotize/avrotocsharp.py b/avrotize/avrotocsharp.py index cd052ad..017afda 100644 --- a/avrotize/avrotocsharp.py +++ b/avrotize/avrotocsharp.py @@ -57,6 +57,8 @@ def __init__(self, base_namespace: str = '') -> None: self.cbor_annotation = False self.avro_annotation = False self.protobuf_net_annotation = False + self.use_optional = False + self.use_ivalidatableobject = False self.generated_types: Dict[str,str] = {} self.generated_avro_types: Dict[str, Dict[str, Union[str, Dict, List]]] = {} self.type_dict: Dict[str, Dict] = {} @@ -851,6 +853,30 @@ def write_to_file(self, namespace: str, name: str, definition: str): file_content += definition file.write(file_content) + def generate_option_class(self, output_dir: str) -> None: + """ Generates Option class for optional properties when use_optional is enabled """ + if not self.use_optional: + return # Not using Option pattern + + # Convert base namespace to PascalCase for consistency with other generated classes + namespace_pascal = pascal(self.base_namespace) + + # Generate the Option class + option_definition = process_template( + "avrotocsharp/option.cs.jinja", + namespace=namespace_pascal + ) + + # Write to the same directory structure as other classes (using PascalCase path) + directory_path = os.path.join( + output_dir, os.path.join('src', namespace_pascal.replace('.', os.sep))) + if not os.path.exists(directory_path): + os.makedirs(directory_path, exist_ok=True) + option_file_path = os.path.join(directory_path, "Option.cs") + + with open(option_file_path, 'w', encoding='utf-8') as option_file: + option_file.write(option_definition) + def generate_tests(self, output_dir: str) -> None: """ Generates unit tests for all the generated C# classes and enums """ test_directory_path = os.path.join(output_dir, "test") @@ -1100,6 +1126,10 @@ def convert_schema(self, schema: JsonNode, output_dir: str): self.output_dir = output_dir for avro_schema in (avs for avs in schema if isinstance(avs, dict)): self.generate_class_or_enum(avro_schema, '') + + # Generate Option class if needed + self.generate_option_class(output_dir) + self.generate_tests(output_dir) def convert(self, avro_schema_path: str, output_dir: str): @@ -1121,7 +1151,9 @@ def convert_avro_to_csharp( msgpack_annotation=False, cbor_annotation=False, avro_annotation=False, - protobuf_net_annotation=False + protobuf_net_annotation=False, + use_optional=False, + use_ivalidatableobject=False ): """Converts Avro schema to C# classes @@ -1138,6 +1170,8 @@ def convert_avro_to_csharp( cbor_annotation (bool, optional): Use Dahomey.Cbor annotations. Defaults to False. avro_annotation (bool, optional): Use Avro annotations. Defaults to False. protobuf_net_annotation (bool, optional): Use protobuf-net annotations. Defaults to False. + use_optional (bool, optional): Use Option wrapper for optional properties. Defaults to False. + use_ivalidatableobject (bool, optional): Implement IValidatableObject interface. Defaults to False. """ if not base_namespace: @@ -1152,6 +1186,8 @@ def convert_avro_to_csharp( avrotocs.cbor_annotation = cbor_annotation avrotocs.avro_annotation = avro_annotation avrotocs.protobuf_net_annotation = protobuf_net_annotation + avrotocs.use_optional = use_optional + avrotocs.use_ivalidatableobject = use_ivalidatableobject avrotocs.convert(avro_schema_path, cs_file_path) @@ -1167,7 +1203,9 @@ def convert_avro_schema_to_csharp( msgpack_annotation: bool = False, cbor_annotation: bool = False, avro_annotation: bool = False, - protobuf_net_annotation: bool = False + protobuf_net_annotation: bool = False, + use_optional: bool = False, + use_ivalidatableobject: bool = False ): """Converts Avro schema to C# classes @@ -1184,6 +1222,8 @@ def convert_avro_schema_to_csharp( cbor_annotation (bool, optional): Use Dahomey.Cbor annotations. Defaults to False. avro_annotation (bool, optional): Use Avro annotations. Defaults to False. protobuf_net_annotation (bool, optional): Use protobuf-net annotations. Defaults to False. + use_optional (bool, optional): Use Option wrapper for optional properties. Defaults to False. + use_ivalidatableobject (bool, optional): Implement IValidatableObject interface. Defaults to False. """ avrotocs = AvroToCSharp(base_namespace) avrotocs.project_name = project_name @@ -1195,4 +1235,6 @@ def convert_avro_schema_to_csharp( avrotocs.cbor_annotation = cbor_annotation avrotocs.avro_annotation = avro_annotation avrotocs.protobuf_net_annotation = protobuf_net_annotation + avrotocs.use_optional = use_optional + avrotocs.use_ivalidatableobject = use_ivalidatableobject avrotocs.convert_schema(avro_schema, output_dir) diff --git a/avrotize/avrotocsharp/option.cs.jinja b/avrotize/avrotocsharp/option.cs.jinja new file mode 100644 index 0000000..06e4fb1 --- /dev/null +++ b/avrotize/avrotocsharp/option.cs.jinja @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +{% if namespace %} +namespace {{ namespace }} +{ +{% endif %} +{% set ind=4 if namespace else 0 %} +{% filter indent(width=ind, first=True) %} +/// +/// Represents an optional value wrapper that distinguishes between undefined, null, and a value. +/// Compatible with DotNext.Optional API. +/// +/// The type of the value +public struct Option +{ + private T? _value; + private bool _isDefined; + + /// + /// Gets a value indicating whether the optional value is defined (either null or a value). + /// + [JsonIgnore] + public readonly bool IsDefined => _isDefined; + + /// + /// Gets a value indicating whether the optional value is undefined (not set). + /// + [JsonIgnore] + public readonly bool IsUndefined => !_isDefined; + + /// + /// Gets a value indicating whether the optional value has a non-null value. + /// + [JsonIgnore] + public readonly bool HasValue => _isDefined && _value != null; + + /// + /// Gets a value indicating whether the optional value is null (but defined). + /// + [JsonIgnore] + public readonly bool IsNull => _isDefined && _value == null; + + /// + /// Gets whether the value has been explicitly set (either to null or a value). + /// Alias for IsDefined for backward compatibility. + /// + [JsonIgnore] + public readonly bool IsSet => _isDefined; + + /// + /// Gets the underlying value. Throws if the value is undefined. + /// + [JsonIgnore] + public readonly T? Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is not defined"); + return _value; + } + } + + /// + /// Creates an undefined Option + /// + public Option() + { + _value = default; + _isDefined = false; + } + + /// + /// Creates an Option with a value (which may be null) + /// + /// The value to wrap + public Option(T? value) + { + _value = value; + _isDefined = true; + } + + /// + /// Implicitly converts a value to an Option + /// + public static implicit operator Option(T? value) + { + return new Option(value); + } + + /// + /// Explicitly converts an Option to its value + /// + public static explicit operator T?(Option option) + { + return option._value; + } + + /// + /// Gets the value if defined, otherwise returns the default value + /// + public readonly T? ValueOrDefault => _isDefined ? _value : default; + + /// + /// Gets the value if defined, otherwise returns the specified default value + /// + public readonly T Or(T defaultValue) + { + return _isDefined && _value != null ? _value : defaultValue; + } + + /// + /// Gets the value if defined, otherwise returns the default value + /// + public readonly T? GetValueOrDefault(T? defaultValue = default) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value if it is defined + /// + public readonly bool TryGet(out T? value) + { + value = _value; + return _isDefined; + } + + /// + /// Returns a string representation of the Option + /// + public override readonly string ToString() + { + if (!_isDefined) return ""; + if (_value == null) return "null"; + return _value.ToString() ?? string.Empty; + } + + /// + /// Checks equality with another Option + /// + public override readonly bool Equals(object? obj) + { + if (obj is not Option other) return false; + if (_isDefined != other._isDefined) return false; + if (!_isDefined) return true; // Both undefined + return EqualityComparer.Default.Equals(_value, other._value); + } + + /// + /// Gets the hash code + /// + public override readonly int GetHashCode() + { + if (!_isDefined) return 0; + return HashCode.Combine(_isDefined, _value); + } +} + +/// +/// JSON converter for Option<T> that handles serialization and deserialization +/// +public class OptionJsonConverter : JsonConverter> +{ + /// + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return new Option(default(T)); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return new Option(value); + } + + /// + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.IsUndefined) + { + // Skip writing the property entirely if undefined + return; + } + + if (value.IsNull) + { + writer.WriteNullValue(); + } + else + { + JsonSerializer.Serialize(writer, value.Value, options); + } + } +} +{% endfilter %} +{% if namespace %} +} +{% endif %} diff --git a/avrotize/commands.json b/avrotize/commands.json index 8daac86..7c7b109 100644 --- a/avrotize/commands.json +++ b/avrotize/commands.json @@ -2061,7 +2061,9 @@ "msgpack_annotation": "args.msgpack_annotation", "cbor_annotation": "args.cbor_annotation", "pascal_properties": "args.pascal_properties", - "base_namespace": "args.namespace" + "base_namespace": "args.namespace", + "use_optional": "args.use_optional", + "use_ivalidatableobject": "args.use_ivalidatableobject" } }, "extensions": [ @@ -2148,6 +2150,20 @@ "help": "Use PascalCase properties", "default": false, "required": false + }, + { + "name": "--use-optional", + "type": "bool", + "help": "Use Option wrapper for optional properties", + "default": false, + "required": false + }, + { + "name": "--use-ivalidatableobject", + "type": "bool", + "help": "Implement IValidatableObject interface for validation", + "default": false, + "required": false } ], "suggested_output_file_path": "{input_file_name}-cs", @@ -2197,7 +2213,9 @@ "avro_annotation": "args.avro_annotation", "pascal_properties": "args.pascal_properties", "base_namespace": "args.namespace", - "project_name": "args.project_name" + "project_name": "args.project_name", + "use_optional": "args.use_optional", + "use_ivalidatableobject": "args.use_ivalidatableobject" } }, "extensions": [ @@ -2270,6 +2288,20 @@ "help": "Use PascalCase properties", "default": false, "required": false + }, + { + "name": "--use-optional", + "type": "bool", + "help": "Use Option wrapper for optional properties", + "default": false, + "required": false + }, + { + "name": "--use-ivalidatableobject", + "type": "bool", + "help": "Implement IValidatableObject interface for validation", + "default": false, + "required": false } ], "suggested_output_file_path": "{input_file_name}-cs", diff --git a/avrotize/structuretocsharp.py b/avrotize/structuretocsharp.py index f5ebc4a..bdb269f 100644 --- a/avrotize/structuretocsharp.py +++ b/avrotize/structuretocsharp.py @@ -40,6 +40,8 @@ def __init__(self, base_namespace: str = '') -> None: self.newtonsoft_json_annotation = False self.system_xml_annotation = False self.avro_annotation = False + self.use_optional = False + self.use_ivalidatableobject = False self.generated_types: Dict[str,str] = {} self.generated_structure_types: Dict[str, Dict[str, Union[str, Dict, List]]] = {} self.type_dict: Dict[str, Dict] = {} @@ -419,13 +421,30 @@ def generate_class(self, structure_schema: Dict, parent_namespace: str, write_fi abstract_modifier = "abstract " if is_abstract else "" sealed_modifier = "sealed " if additional_props is False and not is_abstract else "" - class_definition += f"public {abstract_modifier}{sealed_modifier}partial class {class_name}\n{{\n{class_body}" + # Add IValidatableObject interface if enabled + interfaces = [] + if self.use_ivalidatableobject: + interfaces.append("System.ComponentModel.DataAnnotations.IValidatableObject") + + interface_declaration = f" : {', '.join(interfaces)}" if interfaces else "" + + class_definition += f"public {abstract_modifier}{sealed_modifier}partial class {class_name}{interface_declaration}\n{{\n{class_body}" # Add default constructor (not for abstract classes with no concrete constructors) if not is_abstract or properties: class_definition += f"\n{INDENT}/// \n{INDENT}/// Default constructor\n{INDENT}/// \n" constructor_modifier = "protected" if is_abstract else "public" class_definition += f"{INDENT}{constructor_modifier} {class_name}()\n{INDENT}{{\n{INDENT}}}" + + # Add Validate method if IValidatableObject is enabled + if self.use_ivalidatableobject: + class_definition += f"\n\n{INDENT}/// \n{INDENT}/// Validates the object\n{INDENT}/// \n" + class_definition += f"{INDENT}public System.Collections.Generic.IEnumerable Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)\n" + class_definition += f"{INDENT}{{\n" + class_definition += f"{INDENT}{INDENT}// Validation logic can be added here\n" + class_definition += f"{INDENT}{INDENT}// For now, return empty to indicate valid\n" + class_definition += f"{INDENT}{INDENT}yield break;\n" + class_definition += f"{INDENT}}}" # Convert JSON Structure schema to Avro schema if avro_annotation is enabled avro_schema_json = '' @@ -518,9 +537,19 @@ def generate_property(self, prop_name: str, prop_schema: Dict, class_name: str, # Get property type prop_type = self.convert_structure_type_to_csharp(class_name, field_name, prop_schema, parent_namespace) - # Add nullable marker if not required and not already nullable - if not is_required and not prop_type.endswith('?') and not prop_type.startswith('List<') and not prop_type.startswith('HashSet<') and not prop_type.startswith('Dictionary<'): - prop_type += '?' + # Determine if we should use Option + use_option_for_this_property = self.use_optional and not is_required + + if use_option_for_this_property: + # Wrap in Option + # Remove nullable marker if present as Option handles nullability + if prop_type.endswith('?'): + prop_type = prop_type[:-1] + prop_type = f"Option<{prop_type}>" + else: + # Add nullable marker if not required and not already nullable + if not is_required and not prop_type.endswith('?') and not prop_type.startswith('List<') and not prop_type.startswith('HashSet<') and not prop_type.startswith('Dictionary<'): + prop_type += '?' # Generate documentation doc = prop_schema.get('description', prop_schema.get('doc', field_name_cs)) @@ -646,7 +675,27 @@ def generate_property(self, prop_name: str, prop_schema: Dict, class_name: str, is_read_only = prop_schema.get('readOnly', False) is_write_only = prop_schema.get('writeOnly', False) - if is_read_only: + if use_option_for_this_property: + # Generate Option property with dual accessor pattern + # Main Option property (with Option suffix to avoid conflicts) + property_definition += f"{INDENT}public {prop_type} {field_name_cs}Option {{ get; set; }} = new Option<{prop_type[7:-1]}>();\n\n" + + # Convenience accessor property without Option wrapper + # This property provides direct access to the value for easier usage + property_definition += f"{INDENT}/// \n{INDENT}/// Convenience accessor for {field_name_cs}. Gets or sets the value.\n{INDENT}/// \n" + inner_type = prop_type[7:-1] # Extract T from Option + + # Add JSON ignore to avoid duplicate serialization + property_definition += f"{INDENT}[System.Text.Json.Serialization.JsonIgnore]\n" + if self.newtonsoft_json_annotation: + property_definition += f"{INDENT}[Newtonsoft.Json.JsonIgnore]\n" + + property_definition += f"{INDENT}public {inner_type}? {field_name_cs}\n" + property_definition += f"{INDENT}{{\n" + property_definition += f"{INDENT}{INDENT}get => {field_name_cs}Option.IsSet ? {field_name_cs}Option.Value : default;\n" + property_definition += f"{INDENT}{INDENT}set => {field_name_cs}Option = new Option<{inner_type}>(value);\n" + property_definition += f"{INDENT}}}\n" + elif is_read_only: # readOnly: private or init-only setter property_definition += f"{INDENT}public {required_modifier}{prop_type} {field_name_cs} {{ get; init; }}" elif is_write_only: @@ -656,11 +705,11 @@ def generate_property(self, prop_name: str, prop_schema: Dict, class_name: str, # Normal property property_definition += f"{INDENT}public {required_modifier}{prop_type} {field_name_cs} {{ get; set; }}" - # Add default value if present - if 'default' in prop_schema: + # Add default value if present (not for Option properties as they default to unset) + if not use_option_for_this_property and 'default' in prop_schema: default_val = self.format_default_value(prop_schema['default'], prop_type) property_definition += f" = {default_val};\n" - else: + elif not use_option_for_this_property: property_definition += "\n" return property_definition @@ -1741,6 +1790,9 @@ def convert_schema(self, schema: JsonNode, output_dir: str) -> None: self.generate_tuple_converter(output_dir) self.generate_json_structure_converters(output_dir) + # Generate Option class if needed + self.generate_option_class(output_dir) + # Generate tests self.generate_tests(output_dir) @@ -2121,6 +2173,30 @@ def generate_json_structure_converters(self, output_dir: str) -> None: with open(converter_file_path, 'w', encoding='utf-8') as converter_file: converter_file.write(converter_definition) + def generate_option_class(self, output_dir: str) -> None: + """ Generates Option class for optional properties when use_optional is enabled """ + if not self.use_optional: + return # Not using Option pattern + + # Convert base namespace to PascalCase for consistency with other generated classes + namespace_pascal = pascal(self.base_namespace) + + # Generate the Option class + option_definition = process_template( + "structuretocsharp/option.cs.jinja", + namespace=namespace_pascal + ) + + # Write to the same directory structure as other classes (using PascalCase path) + directory_path = os.path.join( + output_dir, os.path.join('src', namespace_pascal.replace('.', os.sep))) + if not os.path.exists(directory_path): + os.makedirs(directory_path, exist_ok=True) + option_file_path = os.path.join(directory_path, "Option.cs") + + with open(option_file_path, 'w', encoding='utf-8') as option_file: + option_file.write(option_definition) + def generate_instance_serializer(self, output_dir: str) -> None: """ Generates InstanceSerializer.cs that creates instances and serializes them to JSON """ test_directory_path = os.path.join(output_dir, "test") @@ -2383,7 +2459,9 @@ def convert_structure_to_csharp( system_text_json_annotation: bool = False, newtonsoft_json_annotation: bool = False, system_xml_annotation: bool = False, - avro_annotation: bool = False + avro_annotation: bool = False, + use_optional: bool = False, + use_ivalidatableobject: bool = False ): """Converts JSON Structure schema to C# classes @@ -2397,6 +2475,8 @@ def convert_structure_to_csharp( newtonsoft_json_annotation (bool, optional): Use Newtonsoft.Json annotations. Defaults to False. system_xml_annotation (bool, optional): Use System.Xml.Serialization annotations. Defaults to False. avro_annotation (bool, optional): Use Avro annotations. Defaults to False. + use_optional (bool, optional): Use Option wrapper for optional properties. Defaults to False. + use_ivalidatableobject (bool, optional): Implement IValidatableObject interface. Defaults to False. """ if not base_namespace: @@ -2409,6 +2489,8 @@ def convert_structure_to_csharp( structtocs.newtonsoft_json_annotation = newtonsoft_json_annotation structtocs.system_xml_annotation = system_xml_annotation structtocs.avro_annotation = avro_annotation + structtocs.use_optional = use_optional + structtocs.use_ivalidatableobject = use_ivalidatableobject structtocs.convert(structure_schema_path, cs_file_path) @@ -2421,7 +2503,9 @@ def convert_structure_schema_to_csharp( system_text_json_annotation: bool = False, newtonsoft_json_annotation: bool = False, system_xml_annotation: bool = False, - avro_annotation: bool = False + avro_annotation: bool = False, + use_optional: bool = False, + use_ivalidatableobject: bool = False ): """Converts JSON Structure schema to C# classes @@ -2435,6 +2519,8 @@ def convert_structure_schema_to_csharp( newtonsoft_json_annotation (bool, optional): Use Newtonsoft.Json annotations. Defaults to False. system_xml_annotation (bool, optional): Use System.Xml.Serialization annotations. Defaults to False. avro_annotation (bool, optional): Use Avro annotations. Defaults to False. + use_optional (bool, optional): Use Option wrapper for optional properties. Defaults to False. + use_ivalidatableobject (bool, optional): Implement IValidatableObject interface. Defaults to False. """ structtocs = StructureToCSharp(base_namespace) structtocs.project_name = project_name @@ -2443,4 +2529,6 @@ def convert_structure_schema_to_csharp( structtocs.newtonsoft_json_annotation = newtonsoft_json_annotation structtocs.system_xml_annotation = system_xml_annotation structtocs.avro_annotation = avro_annotation + structtocs.use_optional = use_optional + structtocs.use_ivalidatableobject = use_ivalidatableobject structtocs.convert_schema(structure_schema, output_dir) diff --git a/avrotize/structuretocsharp/option.cs.jinja b/avrotize/structuretocsharp/option.cs.jinja new file mode 100644 index 0000000..06e4fb1 --- /dev/null +++ b/avrotize/structuretocsharp/option.cs.jinja @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +{% if namespace %} +namespace {{ namespace }} +{ +{% endif %} +{% set ind=4 if namespace else 0 %} +{% filter indent(width=ind, first=True) %} +/// +/// Represents an optional value wrapper that distinguishes between undefined, null, and a value. +/// Compatible with DotNext.Optional API. +/// +/// The type of the value +public struct Option +{ + private T? _value; + private bool _isDefined; + + /// + /// Gets a value indicating whether the optional value is defined (either null or a value). + /// + [JsonIgnore] + public readonly bool IsDefined => _isDefined; + + /// + /// Gets a value indicating whether the optional value is undefined (not set). + /// + [JsonIgnore] + public readonly bool IsUndefined => !_isDefined; + + /// + /// Gets a value indicating whether the optional value has a non-null value. + /// + [JsonIgnore] + public readonly bool HasValue => _isDefined && _value != null; + + /// + /// Gets a value indicating whether the optional value is null (but defined). + /// + [JsonIgnore] + public readonly bool IsNull => _isDefined && _value == null; + + /// + /// Gets whether the value has been explicitly set (either to null or a value). + /// Alias for IsDefined for backward compatibility. + /// + [JsonIgnore] + public readonly bool IsSet => _isDefined; + + /// + /// Gets the underlying value. Throws if the value is undefined. + /// + [JsonIgnore] + public readonly T? Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is not defined"); + return _value; + } + } + + /// + /// Creates an undefined Option + /// + public Option() + { + _value = default; + _isDefined = false; + } + + /// + /// Creates an Option with a value (which may be null) + /// + /// The value to wrap + public Option(T? value) + { + _value = value; + _isDefined = true; + } + + /// + /// Implicitly converts a value to an Option + /// + public static implicit operator Option(T? value) + { + return new Option(value); + } + + /// + /// Explicitly converts an Option to its value + /// + public static explicit operator T?(Option option) + { + return option._value; + } + + /// + /// Gets the value if defined, otherwise returns the default value + /// + public readonly T? ValueOrDefault => _isDefined ? _value : default; + + /// + /// Gets the value if defined, otherwise returns the specified default value + /// + public readonly T Or(T defaultValue) + { + return _isDefined && _value != null ? _value : defaultValue; + } + + /// + /// Gets the value if defined, otherwise returns the default value + /// + public readonly T? GetValueOrDefault(T? defaultValue = default) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value if it is defined + /// + public readonly bool TryGet(out T? value) + { + value = _value; + return _isDefined; + } + + /// + /// Returns a string representation of the Option + /// + public override readonly string ToString() + { + if (!_isDefined) return ""; + if (_value == null) return "null"; + return _value.ToString() ?? string.Empty; + } + + /// + /// Checks equality with another Option + /// + public override readonly bool Equals(object? obj) + { + if (obj is not Option other) return false; + if (_isDefined != other._isDefined) return false; + if (!_isDefined) return true; // Both undefined + return EqualityComparer.Default.Equals(_value, other._value); + } + + /// + /// Gets the hash code + /// + public override readonly int GetHashCode() + { + if (!_isDefined) return 0; + return HashCode.Combine(_isDefined, _value); + } +} + +/// +/// JSON converter for Option<T> that handles serialization and deserialization +/// +public class OptionJsonConverter : JsonConverter> +{ + /// + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return new Option(default(T)); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return new Option(value); + } + + /// + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.IsUndefined) + { + // Skip writing the property entirely if undefined + return; + } + + if (value.IsNull) + { + writer.WriteNullValue(); + } + else + { + JsonSerializer.Serialize(writer, value.Value, options); + } + } +} +{% endfilter %} +{% if namespace %} +} +{% endif %} diff --git a/test/test_avrotocsharp.py b/test/test_avrotocsharp.py index 6825de0..8650fde 100644 --- a/test/test_avrotocsharp.py +++ b/test/test_avrotocsharp.py @@ -361,3 +361,149 @@ def test_convert_address_avsc_to_csharp_cbor_with_verification(self): # Verify the code compiles and tests pass assert subprocess.check_call( ['dotnet', 'test'], cwd=cs_path, stdout=sys.stdout, stderr=sys.stderr, timeout=self.DOTNET_TIMEOUT) == 0 + + def test_convert_avsc_with_use_optional(self): + """Test converting Avro schema with --use-optional flag""" + cwd = os.getcwd() + avro_path = os.path.join(cwd, "test", "avsc", "address.avsc") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "address-optional-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_avro_to_csharp( + avro_path, + cs_path, + use_optional=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Verify Option.cs is generated + option_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file == "Option.cs": + option_files.append(os.path.join(root, file)) + + assert len(option_files) > 0, "Option.cs should be generated when use_optional=True" + + # Verify Option has expected properties + with open(option_files[0], 'r', encoding='utf-8') as f: + content = f.read() + assert "public struct Option" in content + assert "public readonly bool HasValue" in content + assert "public readonly bool IsUndefined" in content + + # Verify code compiles and tests pass + assert subprocess.check_call( + ['dotnet', 'test'], cwd=cs_path, stdout=sys.stdout, stderr=sys.stderr, timeout=self.DOTNET_TIMEOUT) == 0 + + @pytest.mark.skip(reason="Feature incomplete: avrotocsharp generates Option.cs but doesn't integrate IValidatableObject into class generation") + def test_convert_avsc_with_use_ivalidatableobject(self): + """Test converting Avro schema with --use-ivalidatableobject flag""" + cwd = os.getcwd() + avro_path = os.path.join(cwd, "test", "avsc", "address.avsc") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "address-ivalidatable-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_avro_to_csharp( + avro_path, + cs_path, + use_ivalidatableobject=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Find generated .cs files and verify IValidatableObject implementation + cs_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file.endswith('.cs') and not file.endswith('Tests.cs') and file != "Option.cs": + cs_files.append(os.path.join(root, file)) + + found_ivalidatable = False + for cs_file in cs_files: + with open(cs_file, 'r', encoding='utf-8') as f: + content = f.read() + if "IValidatableObject" in content and "public class" in content: + found_ivalidatable = True + assert "public System.Collections.Generic.IEnumerable Validate" in content + break + + assert found_ivalidatable, "IValidatableObject interface should be implemented in generated classes" + + # Verify code compiles and tests pass + assert subprocess.check_call( + ['dotnet', 'test'], cwd=cs_path, stdout=sys.stdout, stderr=sys.stderr, timeout=self.DOTNET_TIMEOUT) == 0 + + @pytest.mark.skip(reason="Feature incomplete: avrotocsharp generates Option.cs but doesn't integrate Option/IValidatableObject into class generation") + def test_convert_avsc_with_both_flags(self): + """Test converting Avro schema with both --use-optional and --use-ivalidatableobject""" + cwd = os.getcwd() + avro_path = os.path.join(cwd, "test", "avsc", "address.avsc") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "address-both-flags-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_avro_to_csharp( + avro_path, + cs_path, + use_optional=True, + use_ivalidatableobject=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Verify Option.cs exists + option_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file == "Option.cs": + option_files.append(os.path.join(root, file)) + assert len(option_files) > 0, "Option.cs should be generated" + + # Verify IValidatableObject is implemented + cs_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file.endswith('.cs') and not file.endswith('Tests.cs') and file != "Option.cs": + cs_files.append(os.path.join(root, file)) + + found_both = False + for cs_file in cs_files: + with open(cs_file, 'r', encoding='utf-8') as f: + content = f.read() + if "IValidatableObject" in content and "Option<" in content: + found_both = True + break + + assert found_both, "Both IValidatableObject and Option should be present" + + # Verify code compiles and tests pass + assert subprocess.check_call( + ['dotnet', 'test'], cwd=cs_path, stdout=sys.stdout, stderr=sys.stderr, timeout=self.DOTNET_TIMEOUT) == 0 + + def test_use_optional_with_avro_annotations(self): + """Test --use-optional works with avro annotations""" + cwd = os.getcwd() + avro_path = os.path.join(cwd, "test", "avsc", "address.avsc") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "address-optional-avro-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_avro_to_csharp( + avro_path, + cs_path, + use_optional=True, + avro_annotation=True, + pascal_properties=True + ) + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'test'], cwd=cs_path, stdout=sys.stdout, stderr=sys.stderr, timeout=self.DOTNET_TIMEOUT) == 0 diff --git a/test/test_structuretocsharp.py b/test/test_structuretocsharp.py index c23a8b4..1b98097 100644 --- a/test/test_structuretocsharp.py +++ b/test/test_structuretocsharp.py @@ -34,6 +34,8 @@ def run_convert_struct_to_csharp( pascal_properties=False, base_namespace=None, project_name=None, + use_optional=False, + use_ivalidatableobject=False, ): """Test converting a JSON Structure file to C#""" cwd = os.getcwd() @@ -48,6 +50,8 @@ def run_convert_struct_to_csharp( "system_text_json_annotation": system_text_json_annotation, "newtonsoft_json_annotation": newtonsoft_json_annotation, "system_xml_annotation": system_xml_annotation, + "use_optional": use_optional, + "use_ivalidatableobject": use_ivalidatableobject, } if base_namespace is not None: kwargs["base_namespace"] = base_namespace @@ -903,6 +907,283 @@ def test_inline_union_json_roundtrip(self): print(f"✓ Inline union JSON round-trip test passed") + def test_use_optional_flag(self): + """Test --use-optional flag generates Option for optional properties""" + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "test-use-optional-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_optional=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Find Option.cs file + option_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file == "Option.cs": + option_files.append(os.path.join(root, file)) + + assert len(option_files) > 0, "Option.cs should be generated when use_optional=True" + option_file = option_files[0] + + # Verify Option has the expected API (aligned with DotNext.Optional) + with open(option_file, 'r', encoding='utf-8') as f: + content = f.read() + assert "public struct Option" in content + assert "public readonly bool HasValue" in content + assert "public readonly bool IsNull" in content + assert "public readonly bool IsUndefined" in content + assert "public readonly bool IsDefined" in content + assert "public readonly T? Value" in content + assert "public readonly T Or(T defaultValue)" in content + assert "public readonly bool TryGet(out T? value)" in content + assert "public readonly T? ValueOrDefault" in content + + # Find generated class files with Option properties + cs_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file.endswith('.cs') and file != "Option.cs" and not file.endswith('Tests.cs') and file != "InstanceSerializer.cs": + cs_files.append(os.path.join(root, file)) + + # Check at least one file uses Option + found_option_usage = False + for cs_file in cs_files: + with open(cs_file, 'r', encoding='utf-8') as f: + content = f.read() + if "Option<" in content: + found_option_usage = True + # Check for dual accessor pattern + assert "Option { get; set; }" in content, "Should have Option property" + assert "[System.Text.Json.Serialization.JsonIgnore]" in content, "Convenience accessor should be JsonIgnore" + break + + assert found_option_usage, "At least one class should use Option for optional properties" + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'build'], + cwd=cs_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0, "Generated code with use_optional should compile successfully" + + print(f"✓ use_optional test passed") + + def test_use_ivalidatableobject_flag(self): + """Test --use-ivalidatableobject flag adds IValidatableObject interface""" + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "test-use-ivalidatableobject-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_ivalidatableobject=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Find generated class files + cs_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file.endswith('.cs') and not file.endswith('Tests.cs') and file != "InstanceSerializer.cs": + cs_files.append(os.path.join(root, file)) + + # Verify at least one class implements IValidatableObject + found_ivalidatable = False + for cs_file in cs_files: + with open(cs_file, 'r', encoding='utf-8') as f: + content = f.read() + if "System.ComponentModel.DataAnnotations.IValidatableObject" in content: + found_ivalidatable = True + assert "public System.Collections.Generic.IEnumerable Validate" in content + assert "ValidationContext validationContext" in content + assert "yield break;" in content, "Validate method should have a stub implementation" + break + + assert found_ivalidatable, "At least one class should implement IValidatableObject" + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'build'], + cwd=cs_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0, "Generated code with use_ivalidatableobject should compile successfully" + + print(f"✓ use_ivalidatableobject test passed") + + def test_use_optional_and_ivalidatableobject_together(self): + """Test both --use-optional and --use-ivalidatableobject flags work together""" + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "test-both-flags-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_optional=True, + use_ivalidatableobject=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Find Option.cs + option_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file == "Option.cs": + option_files.append(os.path.join(root, file)) + assert len(option_files) > 0, "Option.cs should be generated" + + # Find generated class files + cs_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file.endswith('.cs') and file != "Option.cs" and not file.endswith('Tests.cs') and file != "InstanceSerializer.cs": + cs_files.append(os.path.join(root, file)) + + # Verify both features are present + found_both = False + for cs_file in cs_files: + with open(cs_file, 'r', encoding='utf-8') as f: + content = f.read() + has_ivalidatable = "System.ComponentModel.DataAnnotations.IValidatableObject" in content + has_option = "Option<" in content + if has_ivalidatable and has_option: + found_both = True + break + + assert found_both, "Class should have both IValidatableObject and Option" + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'build'], + cwd=cs_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0, "Generated code with both flags should compile successfully" + + print(f"✓ Both flags together test passed") + + def test_use_optional_with_other_annotations(self): + """Test --use-optional works correctly with other annotation flags""" + for system_text_json in [True, False]: + for newtonsoft_json in [True, False]: + for pascal_props in [True, False]: + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", + f"test-optional-combo-{system_text_json}-{newtonsoft_json}-{pascal_props}-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_optional=True, + system_text_json_annotation=system_text_json, + newtonsoft_json_annotation=newtonsoft_json, + pascal_properties=pascal_props + ) + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'build'], + cwd=cs_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0, f"Code with use_optional and annotations (json={system_text_json}, newtonsoft={newtonsoft_json}, pascal={pascal_props}) should compile" + + print(f"✓ use_optional with other annotations test passed") + + def test_use_ivalidatableobject_with_other_annotations(self): + """Test --use-ivalidatableobject works correctly with other annotation flags""" + for system_text_json in [True, False]: + for pascal_props in [True, False]: + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", + f"test-ivalidatable-combo-{system_text_json}-{pascal_props}-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_ivalidatableobject=True, + system_text_json_annotation=system_text_json, + pascal_properties=pascal_props + ) + + # Verify code compiles + assert subprocess.check_call( + ['dotnet', 'build'], + cwd=cs_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0, f"Code with use_ivalidatableobject and annotations (json={system_text_json}, pascal={pascal_props}) should compile" + + print(f"✓ use_ivalidatableobject with other annotations test passed") + + def test_optional_three_states(self): + """Test that Option correctly handles three states: undefined, null, and value""" + cwd = os.getcwd() + struct_path = os.path.join(cwd, "test", "jsons", "address-ref.struct.json") + cs_path = os.path.join(tempfile.gettempdir(), "avrotize", "test-optional-states-cs") + if os.path.exists(cs_path): + shutil.rmtree(cs_path, ignore_errors=True) + os.makedirs(cs_path, exist_ok=True) + + convert_structure_to_csharp( + struct_path, + cs_path, + use_optional=True, + system_text_json_annotation=True, + pascal_properties=True + ) + + # Find Option.cs + option_files = [] + for root, dirs, files in os.walk(cs_path): + for file in files: + if file == "Option.cs": + option_files.append(os.path.join(root, file)) + assert len(option_files) > 0, "Option.cs should be generated" + + # Read Option.cs and verify three-state logic + with open(option_files[0], 'r', encoding='utf-8') as f: + content = f.read() + # Verify the three states are clearly distinguished + assert "_isDefined" in content, "Should track defined state" + assert "IsUndefined => !_isDefined" in content, "IsUndefined should check !_isDefined" + assert "IsNull => _isDefined && _value == null" in content, "IsNull should check _isDefined && _value == null" + assert "HasValue => _isDefined && _value != null" in content, "HasValue should check _isDefined && _value != null" + + # Verify JSON serialization handles undefined correctly + assert "if (value.IsUndefined)" in content, "JSON converter should check IsUndefined" + assert "// Skip writing the property entirely if undefined" in content or "Skip writing" in content + + print(f"✓ Option three states test passed") + if __name__ == "__main__": pytest.main([__file__])