From dcd9783b3b21b0177357d2c62448fcbb81145aaa Mon Sep 17 00:00:00 2001 From: Loretta Date: Fri, 20 Feb 2026 08:48:16 +0100 Subject: [PATCH] Feature flags for serialization: fine-grained control AcBinarySourceGenerator now reads feature flags from [AcBinarySerializable], enabling selective code generation for ID tracking, reference handling, and string interning. Property ordering is always alphabetical, removing "Id"-first sorting for IId types. Reference tracking code is only emitted when features are enabled. TypeMetadataBase and AcBinarySerializer runtime logic now respect these flags. Default options updated: ReferenceHandlingMode is All, UseMetadata is false. Test models explicitly disable all features. Comments and code structure improved for clarity. --- .../AcBinarySourceGenerator.cs | 91 +++++++++++-------- .../TestModels/SharedTestModels.cs | 44 ++++----- .../Serializers/AcSerializerOptions.cs | 2 +- ...ySerializer.BinarySerializeTypeMetadata.cs | 11 ++- .../Binaries/AcBinarySerializerOptions.cs | 2 +- AyCode.Core/Serializers/TypeMetadataBase.cs | 32 ++++--- 6 files changed, 102 insertions(+), 80 deletions(-) diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index 8116495..8542828 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -41,6 +41,33 @@ public class AcBinarySourceGenerator : IIncrementalGenerator : typeSymbol.ContainingNamespace.ToDisplayString(); var properties = new List(); + + // Read feature flags from [AcBinarySerializable] — disabled features eliminate + // corresponding code blocks from generated ScanObject/WriteProperties. + var enableIdTracking = true; + var enableRefHandling = true; + var enableInternString = true; + var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == AttributeName); + if (binarySerializableAttr != null) + { + if (binarySerializableAttr.ConstructorArguments.Length == 1) + { + // Single bool ctor: AcBinarySerializable(enableAllFeatures) + var all = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; + enableIdTracking = all; + enableRefHandling = all; + enableInternString = all; + } + else if (binarySerializableAttr.ConstructorArguments.Length == 4) + { + // Four bool ctor: (metadata, idTracking, refHandling, internString) + enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; + enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; + enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!; + } + } + foreach (var member in typeSymbol.GetMembers()) { if (member is IPropertySymbol p && @@ -57,7 +84,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator // String interning attribútum detektálás (null = no attr, true/false = explicit) bool? stringInternAttr = null; - if (GetKind(p.Type) == PropertyTypeKind.String) + if (!enableInternString) + { + stringInternAttr = false; + } + else if (GetKind(p.Type) == PropertyTypeKind.String) { var attr = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute"); if (attr != null && attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Primitive) @@ -202,32 +233,27 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } // IId: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering + // If EnableIdTrackingFeature == false, skip IId detection entirely → isIId = false var isIId = false; string? idTypeName = null; - var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i => - i.IsGenericType && - i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); - if (iidInterface != null) + if (enableIdTracking) { - isIId = true; - idTypeName = iidInterface.TypeArguments[0].ToDisplayString(); + var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i => + i.IsGenericType && + i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); + if (iidInterface != null) + { + isIId = true; + idTypeName = iidInterface.TypeArguments[0].ToDisplayString(); + } } - if (isIId) - properties.Sort((a, b) => - { - var aIsId = a.Name == "Id" ? 0 : 1; - var bIsId = b.Name == "Id" ? 0 : 1; - if (aIsId != bIsId) return aIsId.CompareTo(bIsId); - return string.Compare(a.Name, b.Name, StringComparison.Ordinal); - }); - else - properties.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + properties.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); var className = BuildFlatName(typeSymbol); var typeNameHash = ComputeFnvHash(typeSymbol.Name); var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray(); - return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, typeNameHash, propertyNameHashes); + return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes); } private static void Execute(ImmutableArray classes, SourceProductionContext context) @@ -259,7 +285,8 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine("{"); sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedWriter Instance = new();"); sb.AppendLine($" internal static readonly int s_metadataSlot = AcBinarySerializer.AllocateMetadataSlot();"); - sb.AppendLine($" internal static readonly int s_trackingSlot = AcBinarySerializer.AllocateTrackingSlot();"); + if (ci.IsIId || ci.EnableRefHandling) + sb.AppendLine($" internal static readonly int s_trackingSlot = AcBinarySerializer.AllocateTrackingSlot();"); sb.AppendLine($" internal static readonly int s_typeNameHash = {ci.TypeNameHash};"); sb.Append( $" internal static readonly int[] s_propertyHashes = new int[] {{ "); sb.Append(string.Join(", ", ci.PropertyNameHashes)); @@ -312,6 +339,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);"); // Self ref tracking — matches runtime ScanValue UseTypeReferenceHandling block + // Only emitted when the corresponding feature flag is enabled. if (ci.IsIId) { // IId type: track when ReferenceHandling != None @@ -329,7 +357,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine(" return;"); sb.AppendLine(" }"); } - else + else if (ci.EnableRefHandling) { // Non-IId type: track when ReferenceHandling == All sb.AppendLine(); @@ -1065,7 +1093,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator /// /// Computes FNV-1a hashes for all serializable properties of a child type. /// Property filtering and ordering matches runtime TypeMetadataBase exactly: - /// public get+set, non-indexer, non-static, no ignore attributes, sorted (Id first if IId, then alphabetical). + /// public get+set, non-indexer, non-static, no ignore attributes, sorted alphabetically. /// private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType) { @@ -1087,20 +1115,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } } - var childIsIId = resolvedType.AllInterfaces.Any(ifc => - ifc.IsGenericType && - ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); - - if (childIsIId) - propNames.Sort((a, b) => - { - var ai = a == "Id" ? 0 : 1; - var bi = b == "Id" ? 0 : 1; - if (ai != bi) return ai.CompareTo(bi); - return string.Compare(a, b, StringComparison.Ordinal); - }); - else - propNames.Sort(StringComparer.Ordinal); + propNames.Sort(StringComparer.Ordinal); return propNames.Select(ComputeFnvHash).ToArray(); } @@ -1201,12 +1216,14 @@ internal sealed class SerializableClassInfo public bool IsIId { get; } /// The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise public string? IdTypeName { get; } + /// True if EnableRefHandlingFeature is enabled — controls non-IId All mode tracking code emission. + public bool EnableRefHandling { get; } /// FNV-1a hash of ClassName (matches runtime SourceType.Name hash) public int TypeNameHash { get; } /// FNV-1a hash of each property name, in property order public int[] PropertyNameHashes { get; } - public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, int typeNameHash, int[] propertyNameHashes) - { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; } + public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes) + { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; } } internal sealed class PropInfo diff --git a/AyCode.Core.Tests/TestModels/SharedTestModels.cs b/AyCode.Core.Tests/TestModels/SharedTestModels.cs index 906c765..0d4f97e 100644 --- a/AyCode.Core.Tests/TestModels/SharedTestModels.cs +++ b/AyCode.Core.Tests/TestModels/SharedTestModels.cs @@ -55,7 +55,7 @@ public enum TestUserRole /// Implements IId<int> for semantic $id/$ref serialization. /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class SharedTag : IId { @@ -80,7 +80,7 @@ public partial class SharedTag : IId /// Shared category - for hierarchical cross-reference testing. /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class SharedCategory : IId { @@ -106,7 +106,7 @@ public partial class SharedCategory : IId /// Shared user reference - appears in many places to test $ref deduplication. /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class SharedUser : IId { @@ -136,7 +136,7 @@ public partial class SharedUser : IId /// User preferences - non-IId nested object /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class UserPreferences { @@ -162,7 +162,7 @@ public partial class UserPreferences /// Does NOT implement IId, so uses standard Newtonsoft reference tracking. /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class MetadataInfo { @@ -190,7 +190,7 @@ public partial class MetadataInfo /// Level 1: Main order - root of the hierarchy /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class TestOrder : IId { @@ -250,7 +250,7 @@ public partial class TestOrder : IId /// Level 2: Order item with pallets /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class TestOrderItem : IId { @@ -290,7 +290,7 @@ public partial class TestOrderItem : IId /// Level 3: Pallet containing measurements /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class TestPallet : IId { @@ -333,7 +333,7 @@ public partial class TestPallet : IId /// Level 4: Measurement with multiple points /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class TestMeasurement : IId { @@ -368,7 +368,7 @@ public partial class TestMeasurement : IId /// Level 5: Deepest level - measurement point /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] [MessagePackObject] public partial class TestMeasurementPoint : IId { @@ -402,7 +402,7 @@ public partial class TestMeasurementPoint : IId /// /// Order with Guid Id - for testing Guid-based IId /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class TestGuidOrder : IId { public Guid Id { get; set; } @@ -414,7 +414,7 @@ public class TestGuidOrder : IId /// /// Item with Guid Id /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class TestGuidItem : IId { public Guid Id { get; set; } @@ -430,7 +430,7 @@ public class TestGuidItem : IId /// Simulates NopCommerce GenericAttribute - stores key-value pairs where DateTime values /// are stored as strings in the database. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class TestGenericAttribute { public int Id { get; set; } @@ -442,7 +442,7 @@ public class TestGenericAttribute /// DTO with GenericAttributes collection - simulates OrderDto with string-stored DateTime values. /// This reproduces the production bug where Binary serialization was thought to corrupt DateTime strings. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class TestDtoWithGenericAttributes : IId { public int Id { get; set; } @@ -453,7 +453,7 @@ public class TestDtoWithGenericAttributes : IId /// /// Order with nullable collections for null vs empty testing /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class TestOrderWithNullableCollections { public int Id { get; set; } @@ -466,7 +466,7 @@ public class TestOrderWithNullableCollections /// Class with all primitive types for WASM/serialization testing /// [MemoryPackable] -[AcBinarySerializable] +[AcBinarySerializable(false)] public partial class PrimitiveTestClass { public int IntValue { get; set; } @@ -489,7 +489,7 @@ public partial class PrimitiveTestClass /// Class with extended primitive types for full serializer coverage. /// Includes DateTimeOffset, TimeSpan, Dictionary, null properties. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ExtendedPrimitiveTestClass { public int Id { get; set; } @@ -519,7 +519,7 @@ public class ExtendedPrimitiveTestClass /// /// Class with array of objects containing null items for WriteNull coverage /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ObjectWithNullItems { public int Id { get; set; } @@ -534,7 +534,7 @@ public class ObjectWithNullItems /// "Server-side" DTO with extra properties that the "client" doesn't know about. /// Used to test SkipValue functionality when deserializing unknown properties. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ServerCustomerDto : IId { public int Id { get; set; } @@ -567,7 +567,7 @@ public class ServerCustomerDto : IId /// the deserializer must skip unknown properties correctly /// while still maintaining string intern table consistency. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ClientCustomerDto : IId { public int Id { get; set; } @@ -581,7 +581,7 @@ public class ClientCustomerDto : IId /// Server DTO with nested objects that client doesn't know about. /// Tests skipping complex nested structures. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ServerOrderWithExtras : IId { public int Id { get; set; } @@ -602,7 +602,7 @@ public class ServerOrderWithExtras : IId /// /// Client version of the order - doesn't have Customer/RelatedCustomers properties. /// -[AcBinarySerializable] +[AcBinarySerializable(false)] public class ClientOrderSimple : IId { public int Id { get; set; } diff --git a/AyCode.Core/Serializers/AcSerializerOptions.cs b/AyCode.Core/Serializers/AcSerializerOptions.cs index eecb9f8..e03a52a 100644 --- a/AyCode.Core/Serializers/AcSerializerOptions.cs +++ b/AyCode.Core/Serializers/AcSerializerOptions.cs @@ -19,7 +19,7 @@ public abstract class AcSerializerOptions set => _referenceHandling = value; } - private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.OnlyId; + private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.All; private readonly byte _maxDepth = byte.MaxValue; private readonly bool _throwOnCircularReference = true; diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs index 9903c31..8b12614 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs @@ -80,7 +80,8 @@ public static partial class AcBinarySerializer for (var i = 0; i < Properties.Length; i++) { var prop = Properties[i]; - if (prop.IsComplexType || (EnableInternString && prop.AccessorType == PropertyAccessorType.String)) + if ((prop.IsComplexType && !(prop.IsStringCollectionProperty && !EnableInternString)) || + (EnableInternString && prop.AccessorType == PropertyAccessorType.String)) list.Add(prop); } @@ -125,6 +126,7 @@ public static partial class AcBinarySerializer // Read [AcBinarySerializable] once per type — passed to property accessors var serializableAttr = type.GetCustomAttribute(inherit: false); EnableInternString = serializableAttr == null || serializableAttr.EnableInternStringFeature; + var enableRefHandling = serializableAttr == null || serializableAttr.EnableRefHandlingFeature; Properties = new BinaryPropertyAccessor[orderedProperties.Length]; var complexCount = 0; @@ -147,10 +149,9 @@ public static partial class AcBinarySerializer HasComplexProperties = complexCount > 0; // Type needs reference tracking if: - // 1. It's IId (can be deduplicated by Id) - // 2. It has complex properties (children could be shared) - // 3. It's not a primitive/string (could be referenced multiple times) - NeedsReferenceTracking = IsIId || HasComplexProperties || !IsPrimitiveType; + // 1. It's IId (can be deduplicated by Id) — already false if EnableIdTrackingFeature == false + // 2. EnableRefHandlingFeature enabled AND (has complex properties or non-primitive) + NeedsReferenceTracking = IsIId || (enableRefHandling && (HasComplexProperties || !IsPrimitiveType)); // Fast check: only look for generated serializer if type has [AcBinarySerializable] attribute if (false && type.IsDefined(typeof(AcBinarySerializableAttribute), inherit: false)) diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs index e0fff73..6b01f75 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs @@ -82,7 +82,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions /// allowing the deserializer to match properties by name between different types. /// Default: false (no overhead) /// - public bool UseMetadata { get; set; } = true; + public bool UseMetadata { get; set; } = false; public bool UseGeneratedCode { get; set; } = true; diff --git a/AyCode.Core/Serializers/TypeMetadataBase.cs b/AyCode.Core/Serializers/TypeMetadataBase.cs index 45bb4f8..5c9123d 100644 --- a/AyCode.Core/Serializers/TypeMetadataBase.cs +++ b/AyCode.Core/Serializers/TypeMetadataBase.cs @@ -1,5 +1,6 @@ using AyCode.Core.Helpers; using AyCode.Core.Interfaces; +using AyCode.Core.Serializers.Attributes; using System; using System.Collections; using System.Collections.Concurrent; @@ -185,6 +186,7 @@ public abstract class TypeMetadataBase { SourceType = type; _ignorePropertyFilter = ignorePropertyFilter; + CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type); // Pre-compute property arrays - no dictionary lookup needed later! @@ -192,6 +194,7 @@ public abstract class TypeMetadataBase var allReadable = GetUnfilteredProperties(type, requiresWrite: false) .Where(p => !ignorePropertyFilter(p)) .ToArray(); + ReadableProperties = allReadable; WritableProperties = allReadable.Where(p => p.CanWrite).ToArray(); @@ -211,11 +214,18 @@ public abstract class TypeMetadataBase } } - // Cache IId info at construction time - no runtime reflection needed later! - var idInfo = GetIdInfo(type); - IsIId = idInfo.IsId; - IdType = idInfo.IdType; - + var serializableAttr = type.GetCustomAttribute(inherit: false); + if (serializableAttr is { EnableIdTrackingFeature: false }) + { + IsIId = false; + } + else + { + // Cache IId info at construction time - no runtime reflection needed later! + var idInfo = GetIdInfo(type); + IsIId = idInfo.IsId; + IdType = idInfo.IdType; + } if (IsIId) { @@ -279,7 +289,7 @@ public abstract class TypeMetadataBase return requiresWrite ? WritableProperties : ReadableProperties; } - // Id properties are always at index 0 (sorted first) in UnfilteredPropertiesGlobalCache! + // Id properties are sorted alphabetically like all other properties public const string IdPropertyName = nameof(IId.Id); private static List GetUnfilteredProperties(Type type, bool requiresWrite) @@ -287,12 +297,9 @@ public abstract class TypeMetadataBase return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key => { var (t, needsWrite) = key; - - // Check if type implements IId - if so, Id property will be first - var isIId = GetIdInfo(t).IsId; // Collect properties from inheritance hierarchy (derived -> base order) - // Sort: IId types have Id first, then alphabetical + // Sorted alphabetically for deterministic property index ordering var allProperties = new List(); for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) @@ -304,10 +311,7 @@ public abstract class TypeMetadataBase (!needsWrite || p.CanWrite) && p.GetIndexParameters().Length == 0 && !IsUnsupportedPropertyType(p.PropertyType)) - // IId: Id first (0), then alphabetical (1) - // Non-IId: all alphabetical - .OrderBy(p => isIId && p.Name == IdPropertyName ? 0 : 1) - .ThenBy(static p => p.Name, StringComparer.Ordinal); + .OrderBy(static p => p.Name, StringComparer.Ordinal); allProperties.AddRange(levelProperties); }