diff --git a/NewType.Generator/AliasAttributeSource.cs b/NewType.Generator/AliasAttributeSource.cs index debf22e..b66acbc 100644 --- a/NewType.Generator/AliasAttributeSource.cs +++ b/NewType.Generator/AliasAttributeSource.cs @@ -30,6 +30,8 @@ internal enum NewtypeOptions NoImplicitUnwrap = 2, /// Suppress forwarding constructors from T. NoConstructorForwarding = 4, + /// Emit System.Text.Json and TypeConverter serialization support. + Serializable = 8, /// Suppress both implicit conversions. NoImplicitConversions = NoImplicitWrap | NoImplicitUnwrap, /// Suppress implicit conversions and constructor forwarding. diff --git a/NewType.Generator/AliasCodeGenerator.cs b/NewType.Generator/AliasCodeGenerator.cs index cc75f93..c57b342 100644 --- a/NewType.Generator/AliasCodeGenerator.cs +++ b/NewType.Generator/AliasCodeGenerator.cs @@ -44,6 +44,7 @@ public string Generate() AppendToString(); if (!_model.IsRecord) AppendGetHashCode(); + AppendSerializationConverters(); AppendTypeClose(); AppendNamespaceClose(); @@ -118,6 +119,12 @@ private void AppendTypeOpen() (true, false) => "class", (true, true) => "record class", }; + if (_model.EmitSerialization) + { + _sb.AppendLine($"{indent}[global::System.ComponentModel.TypeConverter(typeof({_model.TypeName}.NewtypeTypeConverter))]"); + _sb.AppendLine($"{indent}[global::System.Text.Json.Serialization.JsonConverter(typeof({_model.TypeName}.NewtypeJsonConverter))]"); + } + _sb.AppendLine($"{indent}{accessMod}{readonlyMod}partial {typeKeyword} {_model.TypeName} : {interfaceList}"); _sb.AppendLine($"{indent}{{"); } @@ -305,6 +312,21 @@ private void AppendUnaryOperators() ? _model.TypeName : op.ReturnTypeFullName; + // ++ and -- mutate their operand; _value is readonly, so increment a local copy + // instead of the field directly (which would be CS0191). This path is reached for + // types that define op_Increment/op_Decrement as real operators (e.g. decimal). + if (op.Name is "op_Increment" or "op_Decrement") + { + AppendMethodImplAttribute(indent); + _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value)"); + _sb.AppendLine($"{indent}{{"); + _sb.AppendLine($"{indent} var v = value._value;"); + _sb.AppendLine($"{indent} return {WrapIfAlias(op.ReturnIsAliasedType, $"{opSymbol}v")};"); + _sb.AppendLine($"{indent}}}"); + _sb.AppendLine(); + continue; + } + var expr = WrapIfAlias(op.ReturnIsAliasedType, $"{opSymbol}value._value"); AppendMethodImplAttribute(indent); _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value) => {expr};"); @@ -661,6 +683,82 @@ private void AppendGetHashCode() _sb.AppendLine(); } + /// + /// Emits a nested TypeConverter and System.Text.Json converter when serialization is opted in. + /// Both delegate the underlying value to the aliased type's own serializer and construct a fresh + /// instance, so they work for every newtype form including readonly structs. + /// + private void AppendSerializationConverters() + { + if (!_model.EmitSerialization) + return; + + var indent = GetMemberIndent(); + var inner = indent + " "; + var body = indent + " "; + var t = _model.AliasedTypeFullName; + var name = _model.TypeName; + var minimal = _model.AliasedTypeMinimalName; + + _sb.AppendLine($"{indent}#region Serialization"); + _sb.AppendLine(); + + // TypeConverter — covers Newtonsoft.Json, ASP.NET model binding, configuration binding, etc. + _sb.AppendLine($"{indent}/// Converts {name} to and from {minimal} and string."); + _sb.AppendLine($"{indent}public sealed class NewtypeTypeConverter : global::System.ComponentModel.TypeConverter"); + _sb.AppendLine($"{indent}{{"); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override bool CanConvertFrom(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Type sourceType)"); + _sb.AppendLine($"{body}=> sourceType == typeof(string) || sourceType == typeof({t}) || base.CanConvertFrom(context, sourceType);"); + _sb.AppendLine(); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override bool CanConvertTo(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Type? destinationType)"); + _sb.AppendLine($"{body}=> destinationType == typeof(string) || destinationType == typeof({t}) || base.CanConvertTo(context, destinationType);"); + _sb.AppendLine(); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override object? ConvertFrom(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Globalization.CultureInfo? culture, object value)"); + _sb.AppendLine($"{inner}{{"); + _sb.AppendLine($"{body}if (value is {t} unwrapped) return new {name}(unwrapped);"); + _sb.AppendLine($"{body}if (value is string text)"); + _sb.AppendLine($"{body}{{"); + _sb.AppendLine($"{body} var converted = global::System.ComponentModel.TypeDescriptor.GetConverter(typeof({t})).ConvertFromString(context, culture, text);"); + _sb.AppendLine($"{body} if (converted is null) return null;"); + _sb.AppendLine($"{body} return new {name}(({t})converted);"); + _sb.AppendLine($"{body}}}"); + _sb.AppendLine($"{body}return base.ConvertFrom(context, culture, value);"); + _sb.AppendLine($"{inner}}}"); + _sb.AppendLine(); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override object? ConvertTo(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Globalization.CultureInfo? culture, object? value, global::System.Type destinationType)"); + _sb.AppendLine($"{inner}{{"); + _sb.AppendLine($"{body}if (value is {name} wrapped)"); + _sb.AppendLine($"{body}{{"); + _sb.AppendLine($"{body} if (destinationType == typeof({t})) return wrapped._value;"); + _sb.AppendLine($"{body} if (destinationType == typeof(string)) return global::System.ComponentModel.TypeDescriptor.GetConverter(typeof({t})).ConvertToString(context, culture, wrapped._value);"); + _sb.AppendLine($"{body}}}"); + _sb.AppendLine($"{body}return base.ConvertTo(context, culture, value, destinationType);"); + _sb.AppendLine($"{inner}}}"); + _sb.AppendLine($"{indent}}}"); + _sb.AppendLine(); + + // System.Text.Json converter — serializes as the underlying value. + _sb.AppendLine($"{indent}/// Serializes {name} as its underlying {minimal} for System.Text.Json."); + _sb.AppendLine($"{indent}public sealed class NewtypeJsonConverter : global::System.Text.Json.Serialization.JsonConverter<{name}>"); + _sb.AppendLine($"{indent}{{"); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override {name} Read(ref global::System.Text.Json.Utf8JsonReader reader, global::System.Type typeToConvert, global::System.Text.Json.JsonSerializerOptions options)"); + _sb.AppendLine($"{body}=> new {name}(global::System.Text.Json.JsonSerializer.Deserialize<{t}>(ref reader, options)!);"); + _sb.AppendLine(); + _sb.AppendLine($"{inner}/// "); + _sb.AppendLine($"{inner}public override void Write(global::System.Text.Json.Utf8JsonWriter writer, {name} value, global::System.Text.Json.JsonSerializerOptions options)"); + _sb.AppendLine($"{body}=> global::System.Text.Json.JsonSerializer.Serialize(writer, value._value, options);"); + _sb.AppendLine($"{indent}}}"); + _sb.AppendLine(); + + _sb.AppendLine($"{indent}#endregion"); + _sb.AppendLine(); + } + private void AppendForwardedConstructor(string indent, ConstructorInfo ctor) { var parameters = FormatConstructorParameters(ctor); diff --git a/NewType.Generator/AliasModel.cs b/NewType.Generator/AliasModel.cs index 2584186..0756cb3 100644 --- a/NewType.Generator/AliasModel.cs +++ b/NewType.Generator/AliasModel.cs @@ -40,6 +40,7 @@ internal readonly record struct AliasModel( bool SuppressImplicitWrap, bool SuppressImplicitUnwrap, bool SuppressConstructorForwarding, + bool EmitSerialization, int MethodImplValue, // Members diff --git a/NewType.Generator/AliasModelExtractor.cs b/NewType.Generator/AliasModelExtractor.cs index da34af1..cefcb93 100644 --- a/NewType.Generator/AliasModelExtractor.cs +++ b/NewType.Generator/AliasModelExtractor.cs @@ -16,6 +16,7 @@ internal static class AliasModelExtractor private const int OptionsNoImplicitWrap = 1; private const int OptionsNoImplicitUnwrap = 2; private const int OptionsNoConstructorForwarding = 4; + private const int OptionsSerializable = 8; public static AliasModel? Extract( GeneratorAttributeSyntaxContext context, @@ -76,6 +77,7 @@ internal static class AliasModelExtractor SuppressImplicitWrap: (options & OptionsNoImplicitWrap) != 0, SuppressImplicitUnwrap: (options & OptionsNoImplicitUnwrap) != 0, SuppressConstructorForwarding: (options & OptionsNoConstructorForwarding) != 0, + EmitSerialization: (options & OptionsSerializable) != 0, MethodImplValue: methodImpl, BinaryOperators: binaryOperators, UnaryOperators: unaryOperators, diff --git a/NewType.Generator/NUGET.md b/NewType.Generator/NUGET.md index fccae62..e39099b 100644 --- a/NewType.Generator/NUGET.md +++ b/NewType.Generator/NUGET.md @@ -101,9 +101,27 @@ public readonly partial struct RelaxedId; | `NoConstructorForwarding` | Suppress forwarded constructors from `T` | | `NoImplicitConversions` | `NoImplicitWrap \| NoImplicitUnwrap` | | `Opaque` | `NoImplicitConversions \| NoConstructorForwarding` | +| `Serializable` | Generate `System.Text.Json` + `TypeConverter` serialization support | With any option, the primary constructor (`new Alias(T value)`) and `.Value` property are always available. +## Serialization + +By default a newtype serializes like any struct — which usually isn't what you want. Opt in with `NewtypeOptions.Serializable` to make it serialize as its underlying value: + +```csharp +[newtype(Options = NewtypeOptions.Serializable)] +public readonly partial struct ContractId; +``` + +```csharp +JsonSerializer.Serialize(new ContractId("CTR-002")); // "CTR-002" +``` + +This generates a `[JsonConverter]` (for `System.Text.Json`) and a `[TypeConverter]` (for `Newtonsoft.Json`, ASP.NET model binding, configuration binding, dictionary keys, etc.). Both delegate the underlying value to `T`'s own serializer, so any serializable `T` works. + +> `XmlSerializer` round-tripping is not supported for `readonly struct` newtypes — it populates instances in place, which an immutable struct forbids. + ## Extending Your Types Since the alias is a `partial` type, you can add custom members: @@ -129,6 +147,10 @@ Enable generated file output in your project: Then inspect `obj/Debug/net10.0/GeneratedFiles/` after building. +## Naming + +The attribute is intentionally lowercase `newtype`: it mirrors the `newtype` namespace, the NuGet package id, and Haskell's `newtype` keyword. The class itself keeps the conventional `Attribute` suffix internally (`newtypeAttribute`), so both `[newtype]` and the `[newtype(typeof(T))]` fallback bind correctly. + ## Requirements - .NET 8+ SDK diff --git a/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs b/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs index 87102f7..5059466 100644 --- a/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs +++ b/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs @@ -181,4 +181,75 @@ public void NonGeneric_Attribute_Works() Assert.Contains("private readonly int _value;", text); Assert.Contains("public TestId(int value)", text); } + + [Fact] + public void Generates_Output_For_Decimal_Alias_WithoutReadonlyFieldError() + { + // Issue #5: decimal defines op_Increment/op_Decrement as real operators, so the generated + // ++/-- must increment a local copy rather than assign the readonly _value field (CS0191). + // RunGenerator asserts the output compiles without errors or warnings. + const string source = """ + using newtype; + + [newtype] + public readonly partial struct Money; + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + var text = result.Results[0].GeneratedSources + .Single(s => s.HintName.EndsWith("Money.g.cs")) + .SourceText.ToString(); + + Assert.Contains("operator ++(Money value)", text); + Assert.Contains("operator --(Money value)", text); + Assert.Contains("var v = value._value;", text); + // The buggy form that mutated the readonly field must not appear. + Assert.DoesNotContain("++value._value", text); + Assert.DoesNotContain("--value._value", text); + } + + [Fact] + public void Generates_Serialization_Support_When_Opted_In() + { + // Issue #4 + const string source = """ + using newtype; + + [newtype(Options = NewtypeOptions.Serializable)] + public readonly partial struct ContractId; + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + var text = result.Results[0].GeneratedSources + .Single(s => s.HintName.EndsWith("ContractId.g.cs")) + .SourceText.ToString(); + + Assert.Contains("[global::System.ComponentModel.TypeConverter(typeof(ContractId.NewtypeTypeConverter))]", text); + Assert.Contains("[global::System.Text.Json.Serialization.JsonConverter(typeof(ContractId.NewtypeJsonConverter))]", text); + Assert.Contains("public sealed class NewtypeTypeConverter", text); + Assert.Contains("public sealed class NewtypeJsonConverter", text); + } + + [Fact] + public void No_Serialization_Support_By_Default() + { + const string source = """ + using newtype; + + [newtype] + public readonly partial struct PlainId; + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + var text = result.Results[0].GeneratedSources + .Single(s => s.HintName.EndsWith("PlainId.g.cs")) + .SourceText.ToString(); + + Assert.DoesNotContain("NewtypeJsonConverter", text); + Assert.DoesNotContain("NewtypeTypeConverter", text); + Assert.DoesNotContain("System.Text.Json", text); + } } diff --git a/NewType.Tests/PrimitiveTests.cs b/NewType.Tests/PrimitiveTests.cs index 90868e6..6cccfea 100644 --- a/NewType.Tests/PrimitiveTests.cs +++ b/NewType.Tests/PrimitiveTests.cs @@ -289,6 +289,67 @@ public void IsActive_ToString() Assert.Equal("False", b.ToString()); } + // --- Amount (decimal) — issue #5 regression: ++/-- must not assign the readonly field --- + + [Fact] + public void Amount_ImplicitConversion_RoundTrip() + { + Amount a = 5.5m; + decimal raw = a; + Assert.Equal(5.5m, raw); + } + + [Fact] + public void Amount_Increment() + { + Amount a = new(5m); + a++; + Assert.Equal(6m, a.Value); + ++a; + Assert.Equal(7m, a.Value); + } + + [Fact] + public void Amount_Decrement() + { + Amount a = new(5m); + a--; + Assert.Equal(4m, a.Value); + --a; + Assert.Equal(3m, a.Value); + } + + [Fact] + public void Amount_Arithmetic() + { + Amount a = 10m; + Amount b = 3m; + + Assert.Equal(13m, (decimal) (a + b)); + Assert.Equal(7m, (decimal) (a - b)); + Assert.Equal(30m, (decimal) (a * b)); + } + + [Fact] + public void Amount_UnaryOperators() + { + Amount a = 5m; + Assert.Equal(-5m, (decimal) (-a)); + Assert.Equal(5m, (decimal) (+a)); + } + + [Fact] + public void Amount_Comparison() + { + Amount low = 1m; + Amount high = 100m; + + Assert.True(low < high); + Assert.True(high > low); + Assert.True(low <= high); + Assert.True(high >= low); + } + // --- Ref / ECS patterns with primitives --- [Fact] diff --git a/NewType.Tests/SerializationTests.cs b/NewType.Tests/SerializationTests.cs new file mode 100644 index 0000000..3ad8261 --- /dev/null +++ b/NewType.Tests/SerializationTests.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Text.Json; +using Xunit; + +namespace newtype.tests; + +/// +/// Issue #4 — newtypes opted into NewtypeOptions.Serializable round-trip through +/// System.Text.Json (via a generated JsonConverter) and TypeConverter-based serializers. +/// +public class SerializationTests +{ + // --- System.Text.Json --- + + [Fact] + public void Json_String_SerializesAsBareValue() + { + var json = JsonSerializer.Serialize(new SerialId("CTR-002")); + Assert.Equal("\"CTR-002\"", json); + } + + [Fact] + public void Json_String_RoundTrips() + { + var original = new SerialId("CTR-002"); + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + Assert.Equal(original, restored); + Assert.Equal("CTR-002", restored.Value); + } + + [Fact] + public void Json_Int_SerializesAsBareNumber() + { + var json = JsonSerializer.Serialize(new SerialCount(42)); + Assert.Equal("42", json); + + var restored = JsonSerializer.Deserialize(json); + Assert.Equal(42, restored.Value); + } + + [Fact] + public void Json_AsPropertyOnContainingObject() + { + // Mirrors issue #4: a newtype used as a property serializes its underlying value + // rather than an empty element. + var order = new Order { Contract = new SerialId("CTR-002") }; + + var json = JsonSerializer.Serialize(order); + Assert.Equal("{\"Contract\":\"CTR-002\"}", json); + + var restored = JsonSerializer.Deserialize(json); + Assert.NotNull(restored); + Assert.Equal("CTR-002", restored!.Contract.Value); + } + + private sealed class Order + { + public SerialId Contract { get; set; } + } + + // --- TypeConverter (Newtonsoft.Json, ASP.NET model binding, configuration binding, ...) --- + + [Fact] + public void TypeConverter_IsDiscoverable() + { + var converter = TypeDescriptor.GetConverter(typeof(SerialId)); + Assert.IsType(converter); + } + + [Fact] + public void TypeConverter_String_RoundTrips() + { + var converter = TypeDescriptor.GetConverter(typeof(SerialId)); + + Assert.True(converter.CanConvertFrom(typeof(string))); + Assert.True(converter.CanConvertTo(typeof(string))); + + var text = converter.ConvertToString(new SerialId("abc")); + Assert.Equal("abc", text); + + var value = converter.ConvertFromString("abc"); + Assert.Equal(new SerialId("abc"), value); + } + + [Fact] + public void TypeConverter_Int_RoundTrips() + { + var converter = TypeDescriptor.GetConverter(typeof(SerialCount)); + + var text = converter.ConvertToString(new SerialCount(7)); + Assert.Equal("7", text); + + var value = converter.ConvertFromString("7"); + Assert.Equal(new SerialCount(7), value); + } + + [Fact] + public void NonSerializable_Newtype_HasNoCustomConverter() + { + // Name is a plain [newtype] without the Serializable flag, so no [TypeConverter] + // is generated and TypeDescriptor falls back to the default base converter. Any generated + // converter would derive from TypeConverter, so this exact-type check enforces the gate. + var converter = TypeDescriptor.GetConverter(typeof(Name)); + Assert.IsType(converter); + } +} diff --git a/NewType.Tests/Types.cs b/NewType.Tests/Types.cs index 7f3673a..6457a71 100644 --- a/NewType.Tests/Types.cs +++ b/NewType.Tests/Types.cs @@ -41,6 +41,17 @@ namespace newtype.tests; [newtype] public readonly partial struct Tint; +// decimal — exercises forwarding of user-defined op_Increment/op_Decrement (issue #5) +[newtype] +public readonly partial struct Amount; + +// serialization opt-in — System.Text.Json + TypeConverter (issue #4) +[newtype(Options = NewtypeOptions.Serializable)] +public readonly partial struct SerialId; + +[newtype(Options = NewtypeOptions.Serializable)] +public readonly partial struct SerialCount; + // record struct variants [newtype] public partial record struct Score; diff --git a/README.md b/README.md index 95af4f6..008ba3a 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,27 @@ public readonly partial struct RelaxedId; | `NoConstructorForwarding` | Suppress forwarded constructors from `T` | | `NoImplicitConversions` | `NoImplicitWrap \| NoImplicitUnwrap` | | `Opaque` | `NoImplicitConversions \| NoConstructorForwarding` | +| `Serializable` | Generate `System.Text.Json` + `TypeConverter` serialization support | With any option, the primary constructor (`new Alias(T value)`) and `.Value` property are always available. +## Serialization + +By default a newtype serializes like any struct — which usually isn't what you want. Opt in with `NewtypeOptions.Serializable` to make it serialize as its underlying value: + +```csharp +[newtype(Options = NewtypeOptions.Serializable)] +public readonly partial struct ContractId; +``` + +```csharp +JsonSerializer.Serialize(new ContractId("CTR-002")); // "CTR-002" +``` + +This generates a `[JsonConverter]` (for `System.Text.Json`) and a `[TypeConverter]` (for `Newtonsoft.Json`, ASP.NET model binding, configuration binding, dictionary keys, etc.). Both delegate the underlying value to `T`'s own serializer, so any serializable `T` works. + +> `XmlSerializer` round-tripping is not supported for `readonly struct` newtypes — it populates instances in place, which an immutable struct forbids. + ## Extending Your Types Since the alias is a `partial` type, you can add custom members: @@ -133,6 +151,10 @@ public readonly partial struct Position Then inspect `obj/Debug/net10.0/GeneratedFiles/` after building. +## Naming + +The attribute is intentionally lowercase `newtype`: it mirrors the `newtype` namespace, the NuGet package id, and Haskell's `newtype` keyword. The class itself keeps the conventional `Attribute` suffix internally (`newtypeAttribute`), so both `[newtype]` and the `[newtype(typeof(T))]` fallback bind correctly. + ## Requirements - .NET 8+ SDK