Improve property ordering, null handling, and string interning

Refactored property enumeration in AcBinarySourceGenerator to match runtime ordering and filtering using a new helper. Null checks for reference types are now unconditional in generated code. Changed default string interning mode to All. Added InternalsVisibleTo for FruitBank.Common. Writer attribute checks now only apply to source-defined types.
This commit is contained in:
Loretta 2026-03-07 13:37:49 +01:00
parent d900442468
commit e0f546dde6
3 changed files with 79 additions and 95 deletions

View File

@ -79,21 +79,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
} }
} }
foreach (var member in typeSymbol.GetMembers()) foreach (var p in GetAllSerializablePropertySymbols(typeSymbol))
{ {
if (member is IPropertySymbol p && // String interning attribútum detektálás (null = no attr, true/false = explicit)
p.DeclaredAccessibility == Accessibility.Public &&
p.GetMethod != null && p.SetMethod != null &&
!p.IsIndexer && !p.IsStatic)
{
var hasIgnore = p.GetAttributes().Any(a =>
{
var name = a.AttributeClass?.Name ?? "";
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
});
if (hasIgnore) continue;
// String interning attribútum detektálás (null = no attr, true/false = explicit)
bool? stringInternAttr = null; bool? stringInternAttr = null;
if (!enableInternString) if (!enableInternString)
{ {
@ -137,8 +125,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
? namedPropType.OriginalDefinition ? namedPropType.OriginalDefinition
: p.Type; : p.Type;
hasGenWriter = resolvedType.GetAttributes().Any(a => hasGenWriter = resolvedType.Locations.Any(l => l.IsInSource)
a.AttributeClass?.ToDisplayString() == AttributeName); && resolvedType.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (hasGenWriter) if (hasGenWriter)
{ {
@ -236,8 +225,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
{ {
var resolvedElem = elemType is INamedTypeSymbol namedElem var resolvedElem = elemType is INamedTypeSymbol namedElem
? namedElem.OriginalDefinition : elemType; ? namedElem.OriginalDefinition : elemType;
elemHasGenWriter = resolvedElem.GetAttributes().Any(a => elemHasGenWriter = resolvedElem.Locations.Any(l => l.IsInSource)
a.AttributeClass?.ToDisplayString() == AttributeName); && resolvedElem.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (elemHasGenWriter) if (elemHasGenWriter)
{ {
// Read element type's EnableMetadataFeature // Read element type's EnableMetadataFeature
@ -297,8 +287,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
if (dictValueKind == PropertyTypeKind.Complex) if (dictValueKind == PropertyTypeKind.Complex)
{ {
var resolvedValue = valueType is INamedTypeSymbol nvt ? nvt.OriginalDefinition : valueType; var resolvedValue = valueType is INamedTypeSymbol nvt ? nvt.OriginalDefinition : valueType;
dictValueHasGenWriter = resolvedValue.GetAttributes().Any(a => dictValueHasGenWriter = resolvedValue.Locations.Any(l => l.IsInSource)
a.AttributeClass?.ToDisplayString() == AttributeName); && resolvedValue.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (dictValueHasGenWriter) if (dictValueHasGenWriter)
{ {
var vfn = BuildFlatName((INamedTypeSymbol)resolvedValue); var vfn = BuildFlatName((INamedTypeSymbol)resolvedValue);
@ -342,7 +333,6 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
propEnableMetadata, elemEnableMetadata, propEnableMetadata, elemEnableMetadata,
childNeedsIdScan, childNeedsAllRefScan, childNeedsInternScan, childNeedsIdScan, childNeedsAllRefScan, childNeedsInternScan,
elemNeedsIdScan, elemNeedsAllRefScan, elemNeedsInternScan)); elemNeedsIdScan, elemNeedsAllRefScan, elemNeedsInternScan));
}
} }
// IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering // IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering
@ -361,7 +351,8 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
} }
} }
properties.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); // Properties are already in runtime-matching order from GetAllSerializablePropertySymbols:
// derived → base, each level sorted alphabetically (matches TypeMetadataBase.GetUnfilteredProperties).
var className = BuildFlatName(typeSymbol); var className = BuildFlatName(typeSymbol);
var typeNameHash = ComputeFnvHash(typeSymbol.Name); var typeNameHash = ComputeFnvHash(typeSymbol.Name);
@ -1214,16 +1205,10 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var writer = p.WriterClassName; var writer = p.WriterClassName;
var nextDepth = "depth + 1"; var nextDepth = "depth + 1";
if (p.IsNullable) // Reference type properties can always be null at runtime regardless of nullable annotation
{ sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); sb.AppendLine($"{i}else");
sb.AppendLine($"{i}else"); sb.AppendLine($"{i}{{");
sb.AppendLine($"{i}{{");
}
else
{
sb.AppendLine($"{i}{{");
}
// MaxDepth check — matches WriteObjectGenerated // MaxDepth check — matches WriteObjectGenerated
sb.AppendLine($"{i} if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);"); sb.AppendLine($"{i} if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);");
@ -1351,19 +1336,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
{ {
var writer = p.ElementWriterClassName; var writer = p.ElementWriterClassName;
if (p.IsNullable) // Reference type collections can always be null at runtime regardless of nullable annotation
{ sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); sb.AppendLine($"{i}else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);");
sb.AppendLine($"{i}else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);"); sb.AppendLine($"{i}else");
sb.AppendLine($"{i}else"); sb.AppendLine($"{i}{{");
sb.AppendLine($"{i}{{");
}
else
{
sb.AppendLine($"{i}if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
}
sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Array);"); sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Array);");
@ -1587,19 +1564,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var keyType = p.DictKeyTypeName ?? "object"; var keyType = p.DictKeyTypeName ?? "object";
var valType = p.DictValueTypeName ?? "object"; var valType = p.DictValueTypeName ?? "object";
if (p.IsNullable) // Reference type dictionaries can always be null at runtime regardless of nullable annotation
{ sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); sb.AppendLine($"{i}else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);");
sb.AppendLine($"{i}else if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);"); sb.AppendLine($"{i}else");
sb.AppendLine($"{i}else"); sb.AppendLine($"{i}{{");
sb.AppendLine($"{i}{{");
}
else
{
sb.AppendLine($"{i}if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
}
sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Dictionary);"); sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Dictionary);");
sb.AppendLine($"{i} context.WriteVarUInt((uint){a}.Count);"); sb.AppendLine($"{i} context.WriteVarUInt((uint){a}.Count);");
@ -2749,21 +2718,8 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var needsInternScan = false; var needsInternScan = false;
// Check properties for string interning or complex children // Check properties for string interning or complex children
foreach (var member in type.GetMembers()) foreach (var p in GetAllSerializablePropertySymbols(type))
{ {
if (member is not IPropertySymbol p ||
p.DeclaredAccessibility != Accessibility.Public ||
p.GetMethod == null || p.SetMethod == null ||
p.IsIndexer || p.IsStatic)
continue;
var hasIgnore = p.GetAttributes().Any(a =>
{
var name = a.AttributeClass?.Name ?? "";
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
});
if (hasIgnore) continue;
// Early exit: if all flags are already true, no need to check more properties // Early exit: if all flags are already true, no need to check more properties
if (needsIdScan && needsAllRefScan && needsInternScan) break; if (needsIdScan && needsAllRefScan && needsInternScan) break;
@ -2850,35 +2806,62 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
/// <summary> /// <summary>
/// Computes FNV-1a hashes for all serializable properties of a child type. /// Computes FNV-1a hashes for all serializable properties of a child type.
/// Property filtering and ordering matches runtime TypeMetadataBase exactly: /// Property filtering and ordering matches runtime TypeMetadataBase exactly:
/// public get+set, non-indexer, non-static, no ignore attributes, sorted alphabetically. /// derived → base, each level sorted alphabetically, with ignore attribute filtering.
/// </summary> /// </summary>
private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType) private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType)
{ {
var propNames = new List<string>(); // Use hierarchy-walking helper — order matches runtime TypeMetadataBase
foreach (var member in resolvedType.GetMembers()) var props = GetAllSerializablePropertySymbols(resolvedType);
{ return props.Select(p => ComputeFnvHash(p.Name)).ToArray();
if (member is IPropertySymbol cp &&
cp.DeclaredAccessibility == Accessibility.Public &&
cp.GetMethod != null && cp.SetMethod != null &&
!cp.IsIndexer && !cp.IsStatic)
{
var hasIgnore = cp.GetAttributes().Any(a =>
{
var name = a.AttributeClass?.Name ?? "";
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
});
if (hasIgnore) continue;
propNames.Add(cp.Name);
}
}
propNames.Sort(StringComparer.Ordinal);
return propNames.Select(ComputeFnvHash).ToArray();
} }
#endregion #endregion
/// <summary>
/// Collects all serializable property symbols from the full inheritance hierarchy.
/// Order matches runtime TypeMetadataBase.GetUnfilteredProperties exactly:
/// derived → base, each level sorted alphabetically by name.
/// Filters: public, get+set, non-indexer, non-static, no ignore attributes.
/// Deduplicates by name (most-derived override wins).
/// </summary>
private static List<IPropertySymbol> GetAllSerializablePropertySymbols(ITypeSymbol typeSymbol)
{
var result = new List<IPropertySymbol>();
var seen = new HashSet<string>();
for (var currentType = typeSymbol as INamedTypeSymbol;
currentType != null && currentType.SpecialType != SpecialType.System_Object;
currentType = currentType.BaseType)
{
var levelProps = new List<IPropertySymbol>();
foreach (var member in currentType.GetMembers())
{
if (member is IPropertySymbol p &&
p.DeclaredAccessibility == Accessibility.Public &&
p.GetMethod != null && p.SetMethod != null &&
!p.IsIndexer && !p.IsStatic &&
seen.Add(p.Name)) // dedup: most-derived wins
{
var hasIgnore = p.GetAttributes().Any(a =>
{
var name = a.AttributeClass?.Name ?? "";
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
});
if (hasIgnore) continue;
levelProps.Add(p);
}
}
// Sort each level alphabetically — matches runtime OrderBy(p => p.Name, Ordinal)
levelProps.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
result.AddRange(levelProps);
}
return result;
}
#region Type analysis #region Type analysis
private static bool IsNullableVT(ITypeSymbol t) => private static bool IsNullableVT(ITypeSymbol t) =>

View File

@ -4,3 +4,4 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AyCode.Core.Tests")] [assembly: InternalsVisibleTo("AyCode.Core.Tests")]
[assembly: InternalsVisibleTo("AyCode.Core.Tests.Internal")] [assembly: InternalsVisibleTo("AyCode.Core.Tests.Internal")]
[assembly: InternalsVisibleTo("AyCode.Benchmark")] [assembly: InternalsVisibleTo("AyCode.Benchmark")]
[assembly: InternalsVisibleTo("FruitBank.Common")]

View File

@ -99,7 +99,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// All: All strings within length limits are interned (legacy behavior). /// All: All strings within length limits are interned (legacy behavior).
/// Default: All /// Default: All
/// </summary> /// </summary>
public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.Attribute; public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.All;
/// <summary> /// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode). /// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).