Add cross-type deserialization and property mapping support

- Enable deserialization and population from TSource to TDest with automatic or custom property mapping (by name or via PropertyMapper).
- Add PropertyMapperDelegate and options for custom mapping logic.
- Implement PropertyMappingCache and BinaryIndexMappingCache for efficient, thread-safe mapping reuse.
- Ensure stable, inheritance-aware property ordering in TypeMetadataBase for reliable cross-type mapping.
- Add CrossTypeDeserializerBase utility for shared cross-type logic.
- Add AcBinaryDeserializer.CrossType with new Deserialize<TSource, TDest> and Populate<TSource, TDest> overloads.
- Update settings.local.json to allow additional Bash commands for development.
- Improve documentation and add missing using directives.
This commit is contained in:
Loretta 2026-01-02 07:45:42 +01:00
parent 28a818b1ae
commit f388afcede
6 changed files with 701 additions and 14 deletions

View File

@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet test:*)"
"Bash(dotnet test:*)",
"Bash(dotnet run:*)",
"Bash(dir /B /O-D *.log)"
]
}
}

View File

@ -4,6 +4,7 @@ using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Expressions;
using AyCode.Core.Serializers.Jsons;
using LExpression = System.Linq.Expressions.Expression;
using LExpressionType = System.Linq.Expressions.ExpressionType;
@ -775,6 +776,187 @@ public static class AcSerializerCommon
#endregion
#region Property Mapping for Cross-Type Operations
/// <summary>
/// Maps property indices or names from source type to destination type.
/// Supports both name-based matching and custom PropertyMapper callbacks.
/// Used by cross-type deserialization (Deserialize&lt;TSource, TDest&gt;).
/// Thread-safe and cached for performance.
/// </summary>
public sealed class PropertyMappingCache
{
private readonly ConcurrentDictionary<(Type Source, Type Dest), PropertyMappingInfo> _cache = new();
/// <summary>
/// Gets or builds property mapping between two types.
/// Result is cached for subsequent calls.
/// </summary>
public PropertyMappingInfo GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, object> getMetadata)
{
var key = (sourceType, destType);
return _cache.GetOrAdd(key, _ => BuildMapping(sourceType, destType, customMapper, getMetadata));
}
private static PropertyMappingInfo BuildMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, object> getMetadata)
{
var sourceProps = sourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead)
.ToArray();
var destProps = destType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite)
.ToDictionary(p => p.Name, StringComparer.Ordinal);
var mappings = new List<(PropertyInfo Source, PropertyInfo Dest)>();
foreach (var sourceProp in sourceProps)
{
PropertyInfo? destProp = null;
// Use custom mapper if provided
if (customMapper != null)
{
destProp = customMapper(sourceProp, destType);
}
else
{
// Default: match by name
destProps.TryGetValue(sourceProp.Name, out destProp);
}
if (destProp != null && AreTypesCompatible(sourceProp.PropertyType, destProp.PropertyType))
{
mappings.Add((sourceProp, destProp));
}
}
return new PropertyMappingInfo(mappings.ToArray());
}
/// <summary>
/// Checks if two property types are compatible for mapping.
/// Handles exact match, inheritance, nullable unwrapping, and numeric conversions.
/// </summary>
private static bool AreTypesCompatible(Type sourceType, Type destType)
{
// Exact match
if (sourceType == destType) return true;
// Assignable (inheritance, interfaces)
if (destType.IsAssignableFrom(sourceType)) return true;
// Unwrap nullable types
var sourceUnderlying = Nullable.GetUnderlyingType(sourceType) ?? sourceType;
var destUnderlying = Nullable.GetUnderlyingType(destType) ?? destType;
if (sourceUnderlying == destUnderlying) return true;
// Numeric conversions (int -> long, float -> double, etc.)
if (IsNumericType(sourceUnderlying) && IsNumericType(destUnderlying))
return true;
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsNumericType(Type type)
{
return type == typeof(byte) || type == typeof(sbyte) ||
type == typeof(short) || type == typeof(ushort) ||
type == typeof(int) || type == typeof(uint) ||
type == typeof(long) || type == typeof(ulong) ||
type == typeof(float) || type == typeof(double) ||
type == typeof(decimal);
}
}
/// <summary>
/// Contains property mapping information for a source->destination type pair.
/// Immutable and thread-safe.
/// </summary>
public sealed class PropertyMappingInfo
{
public PropertyMappingInfo(IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> mappings)
{
Mappings = mappings;
}
/// <summary>
/// List of source->destination property pairs.
/// </summary>
public IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> Mappings { get; }
}
/// <summary>
/// Binary-specific index-to-index mapping for cross-type deserialization.
/// Maps source PropertyIndex to destination PropertyIndex.
/// Thread-safe and cached.
/// </summary>
public sealed class BinaryIndexMappingCache
{
private readonly ConcurrentDictionary<(Type Source, Type Dest), int[]> _cache = new();
/// <summary>
/// Gets or builds index mapping from source type to destination type.
/// Returns array where index is source PropertyIndex, value is destination PropertyIndex (-1 if no mapping).
/// </summary>
public int[] GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, PropertyInfo[]> getOrderedProperties)
{
var key = (sourceType, destType);
return _cache.GetOrAdd(key, _ => BuildIndexMapping(sourceType, destType, customMapper, getOrderedProperties));
}
private static int[] BuildIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, PropertyInfo[]> getOrderedProperties)
{
var sourceProps = getOrderedProperties(sourceType);
var destProps = getOrderedProperties(destType);
// Build lookup: property name -> destination index
var destIndexByName = new Dictionary<string, int>(destProps.Length, StringComparer.Ordinal);
for (int i = 0; i < destProps.Length; i++)
{
destIndexByName[destProps[i].Name] = i;
}
// Map source index -> destination index
var mapping = new int[sourceProps.Length];
for (int srcIdx = 0; srcIdx < sourceProps.Length; srcIdx++)
{
var sourceProp = sourceProps[srcIdx];
PropertyInfo? destProp = null;
// Use custom mapper if provided
if (customMapper != null)
{
destProp = customMapper(sourceProp, destType);
}
else
{
// Default: match by name
if (destIndexByName.TryGetValue(sourceProp.Name, out var destIdx))
{
destProp = destProps[destIdx];
}
}
if (destProp != null && destIndexByName.TryGetValue(destProp.Name, out var mappedIdx))
{
mapping[srcIdx] = mappedIdx;
}
else
{
mapping[srcIdx] = -1; // Skip this property
}
}
return mapping;
}
}
#endregion
#region Deserialization Reference Tracking
/// <summary>

View File

@ -0,0 +1,384 @@
using System.Collections;
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Jsons;
using static AyCode.Core.Serializers.AcSerializerCommon;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
private static readonly BinaryIndexMappingCache IndexMappingCache = new();
/// <summary>
/// Helper to get index mapping for cross-type operations.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper)
=> IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, CrossTypeDeserializerBase.GetOrderedProperties);
#region Cross-Type Deserialization
/// <summary>
/// Deserializes binary data from TSource type to TDest type.
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
/// Use when binary was serialized as TSource but you want to deserialize into TDest.
/// </summary>
/// <typeparam name="TSource">The source type that was serialized</typeparam>
/// <typeparam name="TDest">The destination type to deserialize into</typeparam>
/// <param name="data">Binary data to deserialize</param>
/// <returns>Deserialized instance of TDest</returns>
public static TDest? Deserialize<TSource, TDest>(ReadOnlySpan<byte> data)
=> Deserialize<TSource, TDest>(data, AcBinarySerializerOptions.Default);
/// <summary>
/// Deserializes binary data from TSource type to TDest type with options.
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
/// </summary>
/// <typeparam name="TSource">The source type that was serialized</typeparam>
/// <typeparam name="TDest">The destination type to deserialize into</typeparam>
/// <param name="data">Binary data to deserialize</param>
/// <param name="options">Deserialization options (use PropertyMapper for custom mapping)</param>
/// <returns">Deserialized instance of TDest</returns>
public static TDest? Deserialize<TSource, TDest>(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)
{
// Early exit checks
if (CrossTypeDeserializerBase.IsEmptyData(data, BinaryTypeCode.Null))
return default;
var sourceType = typeof(TSource);
var destType = typeof(TDest);
// Fast path: same type
if (CrossTypeDeserializerBase.IsSameType(sourceType, destType))
return Deserialize<TDest>(data, options);
// Cross-type path: use index mapping
var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper);
var context = new BinaryDeserializationContext(data, options);
try
{
context.ReadHeader();
var result = ReadValueWithMapping(ref context, destType, indexMapping, 0);
return (TDest?)result;
}
catch (AcBinaryDeserializationException)
{
throw;
}
catch (Exception ex)
{
throw new AcBinaryDeserializationException(
$"Failed to deserialize binary data from '{sourceType.Name}' to '{destType.Name}': {ex.Message}",
context.Position, destType, ex);
}
}
/// <summary>
/// Deserializes binary data from TSource type to TDest type (byte array overload).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TDest? Deserialize<TSource, TDest>(byte[] data)
=> Deserialize<TSource, TDest>(data.AsSpan());
/// <summary>
/// Deserializes binary data from TSource type to TDest type with options (byte array overload).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TDest? Deserialize<TSource, TDest>(byte[] data, AcBinarySerializerOptions options)
=> Deserialize<TSource, TDest>(data.AsSpan(), options);
#endregion
#region Cross-Type Populate
/// <summary>
/// Populates existing TDest instance with data serialized as TSource.
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
/// </summary>
/// <typeparam name="TSource">The source type that was serialized</typeparam>
/// <typeparam name="TDest">The destination type to populate</typeparam>
/// <param name="data">Binary data to deserialize</param>
/// <param name="target">Existing instance to populate</param>
public static void Populate<TSource, TDest>(ReadOnlySpan<byte> data, TDest target)
where TDest : class
=> Populate<TSource, TDest>(data, target, AcBinarySerializerOptions.Default);
/// <summary>
/// Populates existing TDest instance with data serialized as TSource with options.
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
/// </summary>
/// <typeparam name="TSource">The source type that was serialized</typeparam>
/// <typeparam name="TDest">The destination type to populate</typeparam>
/// <param name="data">Binary data to deserialize</param>
/// <param name="target">Existing instance to populate</param>
/// <param name="options">Deserialization options (use PropertyMapper for custom mapping)</param>
public static void Populate<TSource, TDest>(ReadOnlySpan<byte> data, TDest target, AcBinarySerializerOptions options)
where TDest : class
{
// Validation
CrossTypeDeserializerBase.ValidatePopulateTarget(target);
// Early exit checks
if (CrossTypeDeserializerBase.IsEmptyData(data, BinaryTypeCode.Null))
return;
var sourceType = typeof(TSource);
var destType = typeof(TDest);
// Fast path: same type
if (CrossTypeDeserializerBase.IsSameType(sourceType, destType))
{
Populate(data, target, options);
return;
}
// Cross-type path: use index mapping
var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper);
var context = new BinaryDeserializationContext(data, options);
try
{
context.ReadHeader();
var typeCode = context.PeekByte();
if (typeCode == BinaryTypeCode.Null)
{
context.ReadByte(); // consume null
return;
}
if (typeCode == BinaryTypeCode.Object)
{
context.ReadByte(); // consume Object marker
PopulateObjectWithMapping(ref context, target, destType, indexMapping, 0);
}
else
{
throw new AcBinaryDeserializationException(
$"Expected Object type code but got {typeCode}",
context.Position, destType);
}
}
catch (AcBinaryDeserializationException)
{
throw;
}
catch (Exception ex)
{
throw new AcBinaryDeserializationException(
$"Failed to populate target of type '{destType.Name}' with data from '{sourceType.Name}': {ex.Message}",
context.Position, destType, ex);
}
}
/// <summary>
/// Populates existing TDest instance with data serialized as TSource (byte array overload).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Populate<TSource, TDest>(byte[] data, TDest target)
where TDest : class
=> Populate<TSource, TDest>(data.AsSpan(), target);
/// <summary>
/// Populates existing TDest instance with data serialized as TSource with options (byte array overload).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Populate<TSource, TDest>(byte[] data, TDest target, AcBinarySerializerOptions options)
where TDest : class
=> Populate<TSource, TDest>(data.AsSpan(), target, options);
#endregion
#region Helper Methods
/// <summary>
/// Reads a value with index mapping applied.
/// Maps source PropertyIndex to destination PropertyIndex using the provided mapping.
/// </summary>
private static object? ReadValueWithMapping(ref BinaryDeserializationContext context, Type destType, int[] indexMapping, int depth)
{
var typeCode = context.ReadByte();
return typeCode switch
{
BinaryTypeCode.Null => null,
BinaryTypeCode.Object => ReadObjectWithMapping(ref context, destType, indexMapping, depth),
_ => ReadValue(ref context, destType, depth) // Primitives, arrays, etc. use normal path
};
}
/// <summary>
/// Reads an object using index mapping for property resolution.
/// </summary>
private static object? ReadObjectWithMapping(ref BinaryDeserializationContext context, Type destType, int[] indexMapping, int depth)
{
var metadata = GetTypeMetadata(destType);
// Handle reference ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId < 0)
{
// Object reference - get existing object
return context.GetReferencedObject(-refId);
}
// New object with ID - will be registered below
var instance = CreateInstance(destType, metadata);
if (instance != null && refId > 0)
{
context.RegisterObject(refId, instance);
}
PopulateObjectWithMapping(ref context, instance!, destType, indexMapping, depth);
return instance;
}
// No reference handling
var obj = CreateInstance(destType, metadata);
if (obj != null)
{
PopulateObjectWithMapping(ref context, obj, destType, indexMapping, depth);
}
return obj;
}
/// <summary>
/// Populates an object using index mapping.
/// Source property indices are remapped to destination indices.
/// </summary>
private static void PopulateObjectWithMapping(
ref BinaryDeserializationContext context,
object target,
Type destType,
int[] indexMapping,
int depth)
{
var metadata = GetTypeMetadata(destType);
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
for (int i = 0; i < propertyCount; i++)
{
var sourcePropIndex = (int)context.ReadVarUInt();
// Map source index to destination index
var destPropIndex = MapPropertyIndex(sourcePropIndex, indexMapping);
if (destPropIndex == -1)
{
// No mapping - skip this property
SkipValue(ref context);
continue;
}
// Get destination property by mapped index
var propInfo = metadata.GetPropertyByIndex(destPropIndex);
if (propInfo == null)
{
// Destination property not found - skip
SkipValue(ref context);
continue;
}
// Reuse common populate logic
PopulatePropertyValue(ref context, target, propInfo, nextDepth, sourcePropIndex, destPropIndex, i, propertyCount, depth);
}
}
/// <summary>
/// Maps source property index to destination index using mapping array.
/// Returns -1 if no mapping exists.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int MapPropertyIndex(int sourcePropIndex, int[] indexMapping)
{
if ((uint)sourcePropIndex < (uint)indexMapping.Length)
return indexMapping[sourcePropIndex];
return -1; // Out of range - skip
}
/// <summary>
/// Common logic for populating a single property value.
/// Shared between normal populate and cross-type populate.
/// </summary>
private static void PopulatePropertyValue(
ref BinaryDeserializationContext context,
object target,
BinaryPropertySetterInfo propInfo,
int nextDepth,
int sourcePropIndex,
int destPropIndex,
int currentIndex,
int totalCount,
int depth)
{
var peekCode = context.PeekByte();
// Optimization: Skip null values early
if (peekCode == BinaryTypeCode.Null)
{
context.ReadByte(); // consume Null marker
propInfo.SetValue(target, null);
return;
}
// Handle nested complex objects - reuse existing if available
if (peekCode == BinaryTypeCode.Object && propInfo.IsComplexType)
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
return;
}
}
// Handle collections - reuse existing collection and populate items
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
return;
}
}
// Default: read value and set
var positionBeforeRead = context.Position;
try
{
if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
return;
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
catch (InvalidCastException ex)
{
var targetTypeName = target.GetType().Name;
var indexInfo = sourcePropIndex == destPropIndex
? $"propIndex={sourcePropIndex}"
: $"sourcePropIndex={sourcePropIndex}, destPropIndex={destPropIndex}";
throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propInfo.Name}' (index {currentIndex}/{totalCount}, {indexInfo}) on '{targetTypeName}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
$"Depth: {depth}. " +
$"Error: {ex.Message}",
positionBeforeRead,
propInfo.PropertyType,
ex);
}
}
#endregion
}

View File

@ -0,0 +1,63 @@
using System.Reflection;
using AyCode.Core.Serializers.Jsons;
using static AyCode.Core.Serializers.TypeMetadataBase;
namespace AyCode.Core.Serializers;
/// <summary>
/// Utility class providing common cross-type deserialization functionality.
/// Shared by both JSON and Binary deserializers.
/// </summary>
public static class CrossTypeDeserializerBase
{
/// <summary>
/// Gets ordered properties for a type using stable PropertyIndex ordering.
/// Wrapper around TypeMetadataBase.GetSerializableProperties for consistency.
/// </summary>
public static PropertyInfo[] GetOrderedProperties(Type type)
=> GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
/// <summary>
/// Checks if two types are the same (fast path detection).
/// </summary>
public static bool IsSameType(Type sourceType, Type destType)
=> sourceType == destType;
/// <summary>
/// Validates that target object for populate is not null.
/// </summary>
public static void ValidatePopulateTarget<TDest>(TDest target) where TDest : class
{
if (target == null)
throw new ArgumentNullException(nameof(target));
}
/// <summary>
/// Checks if data is empty or null marker.
/// </summary>
public static bool IsEmptyData(ReadOnlySpan<byte> data, byte nullMarker)
{
if (data.Length == 0) return true;
if (data.Length == 1 && data[0] == nullMarker) return true;
return false;
}
/// <summary>
/// Checks if JSON data is effectively empty.
/// </summary>
public static bool IsEmptyJsonData(ReadOnlySpan<byte> data)
{
if (data.Length == 0) return true;
// Check for "null" (4 bytes)
if (data.Length == 4)
{
return data[0] == (byte)'n' &&
data[1] == (byte)'u' &&
data[2] == (byte)'l' &&
data[3] == (byte)'l';
}
return false;
}
}

View File

@ -1,3 +1,5 @@
using System.Reflection;
namespace AyCode.Core.Serializers.Jsons;
public enum AcSerializerType : byte
@ -6,9 +8,19 @@ public enum AcSerializerType : byte
Binary = 1,
}
/// <summary>
/// Delegate for custom property mapping during cross-type deserialization/population.
/// Enables mapping between different class hierarchies or renamed properties.
/// </summary>
/// <param name="sourceProperty">Property from the source type being deserialized</param>
/// <param name="destinationType">Target type being populated</param>
/// <returns>Mapped destination property, or null to skip this property</returns>
public delegate PropertyInfo? PropertyMapperDelegate(PropertyInfo sourceProperty, Type destinationType);
public abstract class AcSerializerOptions
{
public abstract AcSerializerType SerializerType { get; init; }
/// <summary>
/// Whether to use $id/$ref reference handling for circular references.
/// Default: true
@ -23,6 +35,22 @@ public abstract class AcSerializerOptions
/// Default: byte.MaxValue
/// </summary>
public byte MaxDepth { get; init; } = byte.MaxValue;
/// <summary>
/// Optional callback for custom property mapping during cross-type operations.
/// Used when deserializing/populating with Deserialize&lt;TSource, TDest&gt; or Populate&lt;TSource, TDest&gt;.
///
/// Use cases:
/// - Mapping between external DTOs and internal models (different class hierarchies)
/// - Handling property renames across versions
/// - Custom property pairing logic
///
/// If null (default), properties are matched by name.
/// Callback is invoked once during mapping build phase and result is cached.
///
/// Performance: ZERO overhead on same-type operations (Deserialize&lt;T&gt;).
/// </summary>
public PropertyMapperDelegate? PropertyMapper { get; init; }
}
/// <summary>

View File

@ -29,15 +29,20 @@ public abstract class TypeMetadataBase
}
/// <summary>
/// Gets serializable properties ordered alphabetically by name.
/// Gets serializable properties with stable property index ordering.
/// Properties are ordered hierarchy-aware (base class first, then derived)
/// and alphabetically within each level.
/// This ensures PropertyIndex remains stable across inheritance hierarchies:
/// - Base properties always get the same index in both base and derived types
/// - Supports safe deserialization of derived types into base types
/// - Enables cross-version compatibility when new derived properties are added
/// Results are cached per type and requiresWrite combination.
/// Ordering ensures deterministic serialization across all platforms (Windows, WASM, iOS, Android).
/// </summary>
/// <param name="type">The type to analyze.</param>
/// <param name="requiresRead">Whether the property must be readable (always true in practice).</param>
/// <param name="requiresWrite">Whether the property must be writable (true for deserialization).</param>
/// <returns>Array of properties ordered by name (cached).</returns>
protected static PropertyInfo[] GetSerializableProperties(
/// <returns>Array of properties with stable indices (cached).</returns>
public static PropertyInfo[] GetSerializableProperties(
Type type,
bool requiresRead = true,
bool requiresWrite = false)
@ -45,13 +50,36 @@ public abstract class TypeMetadataBase
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key =>
{
var (t, needsWrite) = key;
return t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
// Build inheritance hierarchy (base -> derived)
var hierarchy = new Stack<Type>();
var currentType = t;
while (currentType != null && currentType != typeof(object))
{
hierarchy.Push(currentType);
currentType = currentType.BaseType;
}
// Collect properties level by level (base first)
var allProperties = new List<PropertyInfo>();
while (hierarchy.Count > 0)
{
var levelType = hierarchy.Pop();
// Get properties declared at this level only
var levelProperties = levelType
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.CanRead &&
(!needsWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p))
.OrderBy(static p => p.Name, StringComparer.Ordinal)
.ToArray();
.OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level
allProperties.AddRange(levelProperties);
}
return allProperties.ToArray();
});
}
}