diff --git a/README.md b/README.md index b8b8746..7830669 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,24 @@ Animal pet = new Dog { Name = "Buddy", Breed = "Labrador" }; Animal clone = pet.FastDeepClone(); // Returns a cloned Dog ``` +For non-abstract base classes, you can opt into the same runtime subtype dispatch behavior: + +```cs +[FastClonerClonable(IncludeSubtypes = true)] +public class Device +{ + public string Name { get; set; } +} + +public class Phone : Device +{ + public string OS { get; set; } +} + +Device device = new Phone { Name = "Pixel", OS = "Android" }; +Device clone = device.FastDeepClone(); // Returns a cloned Phone +``` + ### Explicitly Including Types When a type is only used dynamically (not visible at compile time), use `[FastClonerInclude]` to ensure the generator creates cloning code for it: diff --git a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs index 569fd3a..431858b 100644 --- a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs +++ b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs @@ -9,6 +9,21 @@ namespace FastCloner.SourceGenerator.Shared; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public class FastClonerClonableAttribute : Attribute { + /// + /// Gets or sets whether subtype dispatch should be generated for this type. + /// When true, generated clone code dispatches by runtime type and clones known subtypes similarly to abstract roots. + /// This is ignored for abstract types, which always have subtype dispatch. + /// This is ignored for structs, which cannot have subtypes. + /// This is ignored for sealed classes, which cannot be subclassed. + /// + /// + /// + /// [FastClonerClonable(IncludeSubtypes = true)] + /// public class BaseType { } + /// + /// + public bool IncludeSubtypes { get; set; } + /// /// Initializes a new instance of the FastClonerClonableAttribute. /// diff --git a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs index 09cee05..66a2eb4 100644 --- a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs +++ b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs @@ -208,7 +208,9 @@ private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName return; } - if (_context.Model.IsAbstract) + bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes); + + if (needsSubtypeDispatcher) { sb.AppendLine(" return InternalFastDeepClone(source, null);"); } @@ -274,11 +276,19 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam sb.AppendLine(" if (source == null) return null;"); } - if (_context.Model.IsAbstract) + bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes); + if (needsSubtypeDispatcher) { - WriteAbstractTypeDispatcher(typeName); + WriteSubtypeDispatcher(typeName, fullTypeName, includeRootTypeGuard: !_context.Model.IsAbstract); + if (_context.Model.IsAbstract) + { + sb.AppendLine(" }"); + sb.AppendLine(); + return; + } } - else if (_context.NeedsStateTracking) + + if (_context.NeedsStateTracking) { _context.NeedsStateClass = true; sb.AppendLine(" var localState = state ?? new FcGeneratedCloneState();"); @@ -309,7 +319,7 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam sb.AppendLine(); } - private void WriteAbstractTypeDispatcher(string typeName) + private void WriteSubtypeDispatcher(string typeName, string fullTypeName, bool includeRootTypeGuard) { StringBuilder sb = _context.Source; EquatableArray derivedTypes = _context.Model.DerivedTypes; @@ -319,6 +329,14 @@ private void WriteAbstractTypeDispatcher(string typeName) sb.AppendLine(" var runtimeType = source.GetType();"); sb.AppendLine(); + string indent = " "; + if (includeRootTypeGuard) + { + sb.AppendLine($" if (runtimeType != typeof({fullTypeName}))"); + sb.AppendLine(" {"); + indent = " "; + } + foreach (TypeModel? derivedType in derivedTypes) { string derivedTypeName = derivedType.FullyQualifiedName; @@ -331,29 +349,35 @@ private void WriteAbstractTypeDispatcher(string typeName) extensionClassName = $"{derivedType.Name}FastDeepCloneExtensions"; } - sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))"); - sb.AppendLine($" return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);"); + sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))"); + sb.AppendLine($"{indent} return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);"); } else { string helperName = $"Clone{GetSafeTypeName(derivedType.Name)}"; _context.RegisterDerivedTypeHelper(derivedType, helperName); - sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))"); - sb.AppendLine($" return ({typeName}){helperName}(({derivedTypeName})source, state);"); + sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))"); + sb.AppendLine($"{indent} return ({typeName}){helperName}(({derivedTypeName})source, state);"); } sb.AppendLine(); } if (_context.IsFastClonerAvailable) { - sb.AppendLine($" return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;"); + sb.AppendLine($"{indent}return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;"); } else { - sb.AppendLine($" throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +"); - sb.AppendLine(" \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +"); - sb.AppendLine(" \"or install the FastCloner NuGet package for runtime fallback.\");"); + sb.AppendLine($"{indent}throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +"); + sb.AppendLine($"{indent} \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +"); + sb.AppendLine($"{indent} \"or install the FastCloner NuGet package for runtime fallback.\");"); + } + + if (includeRootTypeGuard) + { + sb.AppendLine(" }"); + sb.AppendLine(); } } diff --git a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs index 6856d44..66dd3bc 100644 --- a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs +++ b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs @@ -137,11 +137,11 @@ private static TypeModel CreateMinimalTypeModel( RelatedTypes: EquatableArray.Empty, NestedTypes: EquatableArray.Empty, DerivedTypes: EquatableArray.Empty, - nullabilityEnabled, - trustNullability, + NullabilityEnabled: nullabilityEnabled, + TrustNullability: trustNullability, PreserveIdentity: null, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework); } @@ -332,10 +332,10 @@ private static TypeModel CreateMinimalTypeModel( trustNullability, preserveIdentity, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework, - new EquatableArray(circRefLog.ToArray())); + CircularAnalysisLog: new EquatableArray(circRefLog.ToArray())); } private static bool? GetPreserveIdentityFromType(INamedTypeSymbol symbol) diff --git a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs index 1d87917..22a9102 100644 --- a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs +++ b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs @@ -204,7 +204,7 @@ bool TryHandleComponent(ITypeSymbol componentType, out MemberModel? componentMem trustNullability, PreserveIdentity: null, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework); diff --git a/src/FastCloner.SourceGenerator/TypeModel.cs b/src/FastCloner.SourceGenerator/TypeModel.cs index 48db8b3..97de2c9 100644 --- a/src/FastCloner.SourceGenerator/TypeModel.cs +++ b/src/FastCloner.SourceGenerator/TypeModel.cs @@ -31,5 +31,6 @@ internal sealed record TypeModel( bool IsRefLikeType = false, // Whether the type is a ref struct (cannot be boxed/used as generic) bool HasParameterlessConstructor = true, // Whether the type has a public parameterless constructor (defaults to true for safety) bool CodeAnalysisAvailable = false, // Whether System.Diagnostics.CodeAnalysis attributes are available + bool IncludeSubtypes = false, // Whether subtype dispatch should be generated for this type TargetFramework TargetFramework = TargetFramework.NetStandard20, // Detected target framework for TFM-specific optimizations EquatableArray CircularAnalysisLog = default) : IEquatable; diff --git a/src/FastCloner.SourceGenerator/TypeModelFactory.cs b/src/FastCloner.SourceGenerator/TypeModelFactory.cs index ee9065c..0d2ec48 100644 --- a/src/FastCloner.SourceGenerator/TypeModelFactory.cs +++ b/src/FastCloner.SourceGenerator/TypeModelFactory.cs @@ -23,6 +23,8 @@ public static bool TryCreate( .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerSimulateNoRuntimeAttribute"); bool trustNullability = symbol.GetAttributes() .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerTrustNullabilityAttribute"); + bool requestedIncludeSubtypes = GetIncludeSubtypesFromType(symbol); + bool effectiveIncludeSubtypes = requestedIncludeSubtypes && !symbol.IsValueType && !symbol.IsSealed; bool? preserveIdentity = GetPreserveIdentityFromType(symbol); bool codeAnalysisAvailable = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null; @@ -211,12 +213,12 @@ public static bool TryCreate( bool isRefLikeType = TypeAnalyzer.IsRefStructType(symbol); EquatableArray derivedTypes = EquatableArray.Empty; - if (symbol.IsAbstract) + if (symbol.IsAbstract || effectiveIncludeSubtypes) { List derivedTypesList = DerivedTypeCollector.Collect(symbol, compilation, nullabilityEnabled, targetFramework); derivedTypes = new EquatableArray(derivedTypesList.ToArray()); - if (derivedTypesList.Count == 0 && !isFastClonerAvailable) + if (symbol.IsAbstract && derivedTypesList.Count == 0 && !isFastClonerAvailable) { error = Diagnostic.Create( new DiagnosticDescriptor( @@ -257,6 +259,7 @@ public static bool TryCreate( isRefLikeType, hasParameterlessConstructor, codeAnalysisAvailable, + effectiveIncludeSubtypes, targetFramework, new EquatableArray(circRefLog.ToArray())); @@ -283,4 +286,23 @@ public static bool TryCreate( } return null; } + + private static bool GetIncludeSubtypesFromType(INamedTypeSymbol symbol) + { + foreach (AttributeData attr in symbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != "FastCloner.SourceGenerator.Shared.FastClonerClonableAttribute") + continue; + + foreach (KeyValuePair namedArg in attr.NamedArguments) + { + if (namedArg is { Key: "IncludeSubtypes", Value.Value: bool includeSubtypes }) + return includeSubtypes; + } + + return false; + } + + return false; + } } diff --git a/src/FastCloner.Tests/AbstractClassTests.cs b/src/FastCloner.Tests/AbstractClassTests.cs index 3cffb85..3cceffe 100644 --- a/src/FastCloner.Tests/AbstractClassTests.cs +++ b/src/FastCloner.Tests/AbstractClassTests.cs @@ -20,6 +20,12 @@ public class ExternalBird : AbstractClassTests.Pet public double Wingspan { get; set; } public bool CanFly { get; set; } } + +public class ExternalWatch : AbstractClassTests.IncludedDevice +{ + public string? FirmwareVersion { get; set; } +} + [SourceGeneratorCompatible] public class AbstractClassTests { @@ -93,6 +99,18 @@ public abstract class Shape public string? Name { get; set; } } + [FastClonerClonable(IncludeSubtypes = true)] + public sealed class SealedDevice + { + public string? Name { get; set; } + } + + [FastClonerClonable(IncludeSubtypes = true)] + public struct StructDevice + { + public int Id { get; set; } + } + public abstract class Polygon : Shape { public int NumberOfSides { get; set; } @@ -152,6 +170,40 @@ public class Address #endregion + #region Test Classes - Concrete Base with IncludeSubtypes + + [FastClonerClonable(IncludeSubtypes = true)] + public class Device + { + public string? Name { get; set; } + } + + public class Phone : Device + { + public string? OperatingSystem { get; set; } + } + + [FastClonerClonable] + public class PlainDevice + { + public string? Name { get; set; } + } + + public class PlainPhone : PlainDevice + { + public string? OperatingSystem { get; set; } + } + + [FastClonerClonable(IncludeSubtypes = true)] + [FastClonerDisableAutoDiscovery] + [FastClonerInclude(typeof(ExternalWatch))] + public class IncludedDevice + { + public string? Name { get; set; } + } + + #endregion + #region Tests - Basic Abstract Class Cloning [Test] @@ -212,6 +264,111 @@ public async Task Abstract_Cat_Should_Clone_Correctly() await Assert.That(clonedCat.IsIndoor).IsTrue(); } + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithIncludeSubtypes_Should_Dispatch_To_Derived_Cloner() + { + // Arrange + Phone phone = new Phone + { + Name = "MyPhone", + OperatingSystem = "Android" + }; + + // Act - Clone via concrete base type + Device device = phone; + Device? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(phone); + + Phone clonedPhone = (Phone)clone!; + await Assert.That(clonedPhone.Name).IsEqualTo("MyPhone"); + await Assert.That(clonedPhone.OperatingSystem).IsEqualTo("Android"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithoutIncludeSubtypes_Should_Keep_Default_Behavior() + { + // Arrange + PlainPhone phone = new PlainPhone + { + Name = "LegacyPhone", + OperatingSystem = "Symbian" + }; + + // Act - Clone via concrete base type without IncludeSubtypes + PlainDevice device = phone; + PlainDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(phone); + await Assert.That(clone is PlainPhone).IsFalse(); + await Assert.That(clone!.Name).IsEqualTo("LegacyPhone"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithIncludeSubtypes_Should_Clone_Device_Instance() + { + // Arrange + Device device = new Device + { + Name = "BaseDevice" + }; + + // Act + Device? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(device); + await Assert.That(clone!.Name).IsEqualTo("BaseDevice"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task SealedClass_WithIncludeSubtypes_Should_Clone_Normally() + { + // Arrange + SealedDevice device = new SealedDevice + { + Name = "Sealed" + }; + + // Act + SealedDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(device); + await Assert.That(clone!.Name).IsEqualTo("Sealed"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task Struct_WithIncludeSubtypes_Should_Clone_Normally() + { + // Arrange + StructDevice device = new StructDevice + { + Id = 42 + }; + + // Act + StructDevice clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone.Id).IsEqualTo(42); + } + #endregion #region Tests - Abstract with Collections @@ -503,6 +660,31 @@ public async Task FastClonerInclude_Multiple_Types_Independence() await Assert.That(((ExternalBird)clones[2]!).CanFly).IsTrue(); } + [Test] + [SourceGeneratorCompatible] + public async Task IncludeSubtypes_WithFastClonerInclude_ExternalSubtype_Should_Clone() + { + // Arrange + ExternalWatch watch = new ExternalWatch + { + Name = "Pixel Watch", + FirmwareVersion = "1.2.3" + }; + + // Act - clone via concrete base with IncludeSubtypes=true + IncludedDevice device = watch; + IncludedDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(watch); + + ExternalWatch clonedWatch = (ExternalWatch)clone!; + await Assert.That(clonedWatch.Name).IsEqualTo("Pixel Watch"); + await Assert.That(clonedWatch.FirmwareVersion).IsEqualTo("1.2.3"); + } + #endregion #region Tests - Null Handling