Refactor Toon serializer: modularize metadata & relations

Major refactor: split AcToonSerializer.MetaSection.cs into focused modules for meta writing, type/enum definitions, navigation, foreign key, validation, descriptions, placeholders, topological sort, and attribute detection. Extend ToonDescriptionAttribute with BusinessRule, TypeRelation, and RelatedTypes for richer metadata. Add ToonTypeRelation constants. Annotate all DTOs with ToonDescription for type relationships. Refactor TypeMetadataBase for customizable ignore filters. Update tests and settings. Improves maintainability, extensibility, and metadata accuracy.
This commit is contained in:
Loretta 2026-01-14 15:39:03 +01:00
parent 93d38d427f
commit de532c3bc7
25 changed files with 1955 additions and 1747 deletions

View File

@ -27,7 +27,8 @@
"Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -1\")", "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -1\")",
"Bash(timeout 30 dotnet run:*)", "Bash(timeout 30 dotnet run:*)",
"Bash(dotnet exec vstest:*)", "Bash(dotnet exec vstest:*)",
"Bash(dotnet new:*)" "Bash(dotnet new:*)",
"Bash(Remove-Item \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Core\\\\AyCode.Core\\\\Serializers\\\\Toons\\\\AcToonSerializer.RelationshipDetection.cs\")"
] ]
} }
} }

View File

@ -525,6 +525,37 @@ public static class JsonUtilities
Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute))); Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute)));
} }
/// <summary>
/// Checks if property has ToonIgnore attribute.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasToonIgnoreAttribute(PropertyInfo prop)
{
//return JsonIgnoreCache.GetOrAdd(prop, static p => Attribute.IsDefined(p, typeof(ToonIgnoreAttribute)));
return JsonIgnoreCache.GetOrAdd(prop, static p =>
Attribute.IsDefined(p, typeof(JsonIgnoreAttribute)) ||
Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute)));
}
/// <summary>
/// Check if a property type is unsupported for serialization.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsUnsupportedPropertyType(Type type)
{
if (type.IsByRef || type.IsByRefLike)
return true;
if (type.IsPointer)
return true;
if (typeof(Type).IsAssignableFrom(type) || typeof(MemberInfo).IsAssignableFrom(type))
return true;
return false;
}
/// <summary> /// <summary>
/// Checks if collection contains primitive elements. /// Checks if collection contains primitive elements.
/// </summary> /// </summary>

View File

@ -1,8 +1,9 @@
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Helpers;
using AyCode.Core.Serializers; using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Attributes; using AyCode.Core.Serializers.Attributes;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries; namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer public static partial class AcBinaryDeserializer
@ -28,9 +29,9 @@ public static partial class AcBinaryDeserializer
/// </summary> /// </summary>
public Type? GeneratedSerializerType { get; } public Type? GeneratedSerializerType { get; }
public BinaryDeserializeTypeMetadata(Type type) : base(type) public BinaryDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute)
{ {
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); var orderedProperties = GetSerializableProperties(type, requiresWrite: true);
PropertiesArray = new BinaryPropertySetterInfo[orderedProperties.Length]; PropertiesArray = new BinaryPropertySetterInfo[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++) for (var i = 0; i < orderedProperties.Length; i++)

View File

@ -11,10 +11,12 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Helper to get index mapping for cross-type operations. /// Helper to get index mapping for cross-type operations.
/// Uses cached PropertyInfo arrays from TypeMetadataBase.GetSerializableProperties.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper) private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper)
=> IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, DeserializeCrossTypeBase.GetOrderedProperties); => IndexMappingCache.GetOrBuild(sourceType, destType, customMapper,
type => GetTypeMetadata(type).GetSerializableProperties(type, requiresWrite: true));
#region Cross-Type Deserialization #region Cross-Type Deserialization

View File

@ -23,9 +23,9 @@ public static partial class AcBinarySerializer
/// </summary> /// </summary>
public Type? GeneratedSerializerType { get; } public Type? GeneratedSerializerType { get; }
public BinaryTypeMetadata(Type type) : base(type) public BinaryTypeMetadata(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) : base(type,ignorePropertyFilter)
{ {
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); var orderedProperties = GetSerializableProperties(type, requiresWrite: true);
Properties = new BinaryPropertyAccessor[orderedProperties.Length]; Properties = new BinaryPropertyAccessor[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++) for (var i = 0; i < orderedProperties.Length; i++)

View File

@ -1,4 +1,6 @@
using System.Buffers; using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Expressions;
using System.Buffers;
using System.Collections; using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Linq.Expressions; using System.Linq.Expressions;
@ -6,7 +8,6 @@ using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using AyCode.Core.Serializers.Expressions;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries; namespace AyCode.Core.Serializers.Binaries;
@ -1239,7 +1240,7 @@ public static partial class AcBinarySerializer
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryTypeMetadata GetTypeMetadata(Type type) private static BinaryTypeMetadata GetTypeMetadata(Type type)
=> BinaryTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryTypeMetadata(t)); => BinaryTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryTypeMetadata(t, HasJsonIgnoreAttribute));
// Type metadata helpers moved to AcBinarySerializer.BinaryTypeMetadata.cs // Type metadata helpers moved to AcBinarySerializer.BinaryTypeMetadata.cs

View File

@ -1,6 +1,5 @@
using System.Reflection; using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Jsons; using AyCode.Core.Serializers.Jsons;
using static AyCode.Core.Serializers.TypeMetadataBase;
namespace AyCode.Core.Serializers; namespace AyCode.Core.Serializers;
@ -8,14 +7,10 @@ namespace AyCode.Core.Serializers;
/// Utility class providing common cross-type deserialization functionality. /// Utility class providing common cross-type deserialization functionality.
/// Shared by both JSON and Binary deserializers. /// Shared by both JSON and Binary deserializers.
/// </summary> /// </summary>
public static class DeserializeCrossTypeBase public class DeserializeCrossTypeBase : TypeMetadataBase
{ {
/// <summary> protected DeserializeCrossTypeBase(Type type) : base(type, JsonUtilities.HasJsonIgnoreAttribute)
/// 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> /// <summary>
/// Checks if two types are the same (fast path detection). /// Checks if two types are the same (fast path detection).

View File

@ -29,7 +29,7 @@ public abstract class DeserializeTypeMetadataBase<TMetadata> : TypeMetadataBase<
/// </summary> /// </summary>
public Func<object, object?>? IdGetter { get; } public Func<object, object?>? IdGetter { get; }
protected DeserializeTypeMetadataBase(Type type) : base(type) protected DeserializeTypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) : base(type, ignorePropertyFilter)
{ {
// Cache IId info at construction time - no runtime reflection needed later! // Cache IId info at construction time - no runtime reflection needed later!
var idInfo = GetIdInfo(type); var idInfo = GetIdInfo(type);

View File

@ -1,9 +1,11 @@
using AyCode.Core.Helpers;
using AyCode.Core.Serializers;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Frozen; using System.Collections.Frozen;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using AyCode.Core.Serializers; using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Jsons; namespace AyCode.Core.Serializers.Jsons;
@ -22,9 +24,9 @@ public static partial class AcJsonDeserializer
public FrozenDictionary<string, PropertySetterInfo> PropertySettersFrozen { get; } public FrozenDictionary<string, PropertySetterInfo> PropertySettersFrozen { get; }
public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects) public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
public JsonDeserializeTypeMetadata(Type type) : base(type) public JsonDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute)
{ {
var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); var props = GetSerializableProperties(type, requiresWrite: true);
var propertySetters = new Dictionary<string, PropertySetterInfo>(props.Length, StringComparer.OrdinalIgnoreCase); var propertySetters = new Dictionary<string, PropertySetterInfo>(props.Length, StringComparer.OrdinalIgnoreCase);
var propsArray = new PropertySetterInfo[props.Length]; var propsArray = new PropertySetterInfo[props.Length];

View File

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using AyCode.Core.Helpers;
namespace AyCode.Core.Serializers.Jsons; namespace AyCode.Core.Serializers.Jsons;
@ -15,9 +16,9 @@ public static partial class AcJsonSerializer
{ {
public PropertyAccessor[] Properties { get; } public PropertyAccessor[] Properties { get; }
public JsonTypeMetadata(Type type) : base(type) public JsonTypeMetadata(Type type) : base(type, JsonUtilities.HasJsonIgnoreAttribute)
{ {
Properties = GetSerializableProperties(type, requiresRead: true, requiresWrite: false) Properties = GetSerializableProperties(type, requiresWrite: false)
.Select(p => new PropertyAccessor(p, type)) .Select(p => new PropertyAccessor(p, type))
.ToArray(); .ToArray();
} }

View File

@ -0,0 +1,155 @@
using System.Reflection;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
#region EF Core Attribute Detection (reflection-based, no dependency)
/// <summary>
/// Detect EF Core [Key] attribute via reflection (no EF Core dependency).
/// </summary>
private static bool TryGetEFCoreKey(PropertyInfo property)
{
var keyAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "KeyAttribute");
return keyAttr != null;
}
/// <summary>
/// Detect EF Core [ForeignKey("NavigationPropertyName")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreForeignKey(PropertyInfo property)
{
var fkAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "ForeignKeyAttribute");
if (fkAttr != null)
{
// Get the Name property value (navigation property name)
var nameProp = fkAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(fkAttr) as string;
}
return null;
}
/// <summary>
/// Detect EF Core [InverseProperty("PropertyName")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreInverseProperty(PropertyInfo property)
{
var inversePropAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "InversePropertyAttribute");
if (inversePropAttr != null)
{
var propertyProp = inversePropAttr.GetType().GetProperty("Property");
return propertyProp?.GetValue(inversePropAttr) as string;
}
return null;
}
/// <summary>
/// Detect EF Core [Table("name")] or [Table(Name = "name", Schema = "schema")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreTableName(Type type)
{
var tableAttr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "TableAttribute");
if (tableAttr != null)
{
// EF Core TableAttribute has "Name" property
var nameProp = tableAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(tableAttr) as string;
}
return null;
}
#endregion
#region Linq2Db Attribute Detection (reflection-based, no dependency)
/// <summary>
/// Detect Linq2Db [PrimaryKey] attribute via reflection (no Linq2Db dependency).
/// </summary>
private static bool TryGetLinq2DbPrimaryKey(PropertyInfo property)
{
var pkAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "PrimaryKeyAttribute");
return pkAttr != null;
}
/// <summary>
/// Detect Linq2Db [Association] attribute and determine navigation type.
/// Supports simple ThisKey/OtherKey associations. Expression-based associations are skipped.
/// </summary>
private static ToonRelationType? TryGetLinq2DbNavigation(PropertyInfo property)
{
var assocAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "AssociationAttribute");
if (assocAttr == null)
return null;
// Check if it's expression-based (has QueryExpressionMethod property set)
var queryExprMethod = assocAttr.GetType().GetProperty("QueryExpressionMethod")?.GetValue(assocAttr) as string;
if (!string.IsNullOrEmpty(queryExprMethod))
{
// Expression-based many-to-many - too complex, skip and fallback to convention
return null;
}
// Simple association with ThisKey/OtherKey
var thisKey = assocAttr.GetType().GetProperty("ThisKey")?.GetValue(assocAttr) as string;
var otherKey = assocAttr.GetType().GetProperty("OtherKey")?.GetValue(assocAttr) as string;
var propertyType = property.PropertyType;
// Check if it's a collection (OneToMany or ManyToMany)
if (typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string))
{
// Collection with ThisKey + OtherKey -> could be ManyToMany or OneToMany
// If both ThisKey and OtherKey are set -> likely ManyToMany
// If only ThisKey -> OneToMany
if (!string.IsNullOrEmpty(thisKey) && !string.IsNullOrEmpty(otherKey))
{
// Could be ManyToMany, but without junction table info, treat as OneToMany
return ToonRelationType.OneToMany;
}
else
{
return ToonRelationType.OneToMany;
}
}
else
{
// Single object -> ManyToOne or OneToOne
// Typically ManyToOne
return ToonRelationType.ManyToOne;
}
}
/// <summary>
/// Detect Linq2Db [Table(Name = "name")] attribute via reflection.
/// </summary>
private static string? TryGetLinq2DbTableName(Type type)
{
var tableAttr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "TableAttribute");
if (tableAttr != null)
{
// Linq2Db TableAttribute also has "Name" property
var nameProp = tableAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(tableAttr) as string;
}
return null;
}
#endregion
}

View File

@ -0,0 +1,371 @@
using System.ComponentModel;
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Get description for a type (can be extended with XML comments or attributes).
/// </summary>
private static string GetTypeDescription(Type type)
{
// For now, generate simple descriptions
// TODO: In the future, this could read XML documentation comments
if (type.IsEnum)
return $"Enum type with values: {string.Join(", ", Enum.GetNames(type))}";
var metadata = GetTypeMetadata(type);
// Only treat as collection if ElementType is not System.Object (fallback value from GetCollectionElementType)
if (metadata.IsCollection && metadata.ElementType != null && metadata.ElementType != typeof(object))
return $"Collection of {metadata.ElementType.Name}";
if (metadata.IsDictionary)
return "Dictionary mapping keys to values";
return $"Object of type {type.Name}";
}
/// <summary>
/// Get description for a property (can be extended with XML comments or attributes).
/// </summary>
private static string GetPropertyDescription(Type declaringType, string propertyName, Type propertyType)
{
// Enhanced description based on common patterns
var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType;
var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
// Common property name patterns
if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase))
return $"Unique identifier for {declaringType.Name}";
if (propertyName.Equals("Name", StringComparison.OrdinalIgnoreCase))
return $"Name of the {declaringType.Name}";
if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase))
return "Email address";
if (propertyName.Contains("Phone", StringComparison.OrdinalIgnoreCase))
return "Phone number";
if (propertyName.Contains("Address", StringComparison.OrdinalIgnoreCase))
return "Physical or mailing address";
if (propertyName.Contains("Date", StringComparison.OrdinalIgnoreCase) || baseType == typeof(DateTime))
return $"Date/time value for {propertyName}";
if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool))
return $"Boolean flag indicating {propertyName.Substring(2)}";
if (propertyName.StartsWith("Has", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool))
return $"Boolean flag indicating possession of {propertyName.Substring(3)}";
if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase) && IsIntegerType(baseType))
return $"Count of {propertyName.Replace("Count", "")}";
// Collection types (csak ha tényleg nem object az elem)
var elementType = GetCollectionElementType(propertyType);
if (elementType != null && elementType != typeof(object))
return $"Collection of {elementType.Name} for {declaringType.Name}";
// Dictionary types
if (IsDictionaryType(propertyType, out _, out _))
return $"Dictionary mapping for {propertyName} in {declaringType.Name}";
// Default
return $"Property {propertyName} of type {baseType.Name}{(isNullable ? " (nullable)" : "")}";
}
/// <summary>
/// Get property constraints (nullable, required, etc.).
/// </summary>
private static string GetPropertyConstraints(Type propertyType, string propertyName)
{
var constraints = new List<string>();
var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType;
if (isNullable)
constraints.Add("nullable");
else
constraints.Add("required");
var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
// Type-specific constraints
if (baseType == typeof(string))
{
if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase))
constraints.Add("email-format");
if (propertyName.Contains("Url", StringComparison.OrdinalIgnoreCase))
constraints.Add("url-format");
}
if (IsIntegerType(baseType))
{
if (propertyName.Contains("Age", StringComparison.OrdinalIgnoreCase))
constraints.Add("range: 0-150");
else if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase))
constraints.Add("non-negative");
}
return constraints.Count > 0 ? string.Join(", ", constraints) : "";
}
/// <summary>
/// Get property purpose (what it's used for).
/// </summary>
private static string GetPropertyPurpose(Type declaringType, string propertyName)
{
// Common purposes based on property patterns
if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase))
return "Primary key / unique identification";
if (propertyName.Contains("CreatedAt", StringComparison.OrdinalIgnoreCase))
return "Timestamp when entity was created";
if (propertyName.Contains("UpdatedAt", StringComparison.OrdinalIgnoreCase) ||
propertyName.Contains("ModifiedAt", StringComparison.OrdinalIgnoreCase))
return "Timestamp of last update";
if (propertyName.Contains("DeletedAt", StringComparison.OrdinalIgnoreCase))
return "Soft delete timestamp";
if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase))
return "Status flag";
if (propertyName.Contains("Version", StringComparison.OrdinalIgnoreCase))
return "Version tracking / concurrency control";
return "";
}
/// <summary>
/// Check if type is an integer type.
/// </summary>
private static bool IsIntegerType(Type type)
{
var typeCode = Type.GetTypeCode(type);
return typeCode is TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or
TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or
TypeCode.Int64 or TypeCode.UInt64;
}
/// <summary>
/// Get final property description with fallback chain and placeholder resolution.
/// Priority: ToonDescription (with placeholders) > Microsoft [Description] > null (no fallback)
/// Returns null if no explicit description is provided (to avoid redundant output).
/// </summary>
private static string? GetFinalPropertyDescription(ToonPropertyAccessor prop, Type declaringType)
{
// 1. ToonDescription.Description (if not empty)
var customDesc = prop.CustomDescription?.Description;
if (!string.IsNullOrWhiteSpace(customDesc))
{
// Has [#...] placeholder? → Resolve it
if (customDesc.Contains("[#"))
{
return ResolveDescriptionPlaceholders(customDesc, prop, declaringType);
}
// No placeholder → use as-is
return customDesc;
}
// 2. Microsoft [Description] attribute
var msDesc = prop.PropertyInfo.GetCustomAttribute<DescriptionAttribute>();
if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description))
return msDesc.Description;
// 3. No fallback - return null to avoid redundant output
return null;
}
/// <summary>
/// Get final property purpose with fallback chain and placeholder resolution.
/// Priority: ToonDescription.Purpose (with placeholders) > Smart inference
/// </summary>
private static string GetFinalPropertyPurpose(ToonPropertyAccessor prop, Type declaringType)
{
var customPurpose = prop.CustomDescription?.Purpose;
if (!string.IsNullOrWhiteSpace(customPurpose))
{
// Has [#...] placeholder? → Resolve it
if (customPurpose.Contains("[#"))
{
return ResolvePurposePlaceholders(customPurpose, prop, declaringType);
}
return customPurpose;
}
// Fallback: Smart inference
return GetPropertyPurpose(declaringType, prop.Name);
}
/// <summary>
/// Get final property constraints with fallback chain and placeholder resolution.
/// Priority: ToonDescription.Constraints (with placeholders merged) > Microsoft attributes > Type constraints > Smart inference
/// </summary>
private static string GetFinalPropertyConstraints(ToonPropertyAccessor prop, Type declaringType)
{
var customConstraints = prop.CustomDescription?.Constraints;
// --- NotMapped/NotColumn detection ---
var notMapped = false;
var attrs = prop.PropertyInfo.GetCustomAttributes(inherit: true);
foreach (var attr in attrs)
{
var attrName = attr.GetType().Name;
if (attrName == "NotMappedAttribute" || attrName == "NotColumnAttribute")
{
notMapped = true;
break;
}
}
string result;
if (!string.IsNullOrWhiteSpace(customConstraints))
{
// Has [#...] placeholder? → Resolve and merge
if (customConstraints.Contains("[#"))
{
// Resolve placeholders first
var resolved = ResolveConstraintPlaceholders(customConstraints, prop);
// Merge with type/smart constraints if resolved contains content
if (!string.IsNullOrWhiteSpace(resolved))
{
var typeConstraints = ExtractTypeConstraints(prop.PropertyType);
var inferredConstraints = GetInferredConstraints(prop.PropertyType, prop.Name);
result = MergeConstraints(typeConstraints, null, inferredConstraints, resolved);
}
else
{
result = string.Empty;
}
}
else
{
// No placeholder → custom wins (replace mode)
result = customConstraints;
}
}
else
{
// No custom constraint → Fallback chain
var msConstraints = ExtractDataAnnotationConstraints(prop.PropertyInfo);
var typeConstraints2 = ExtractTypeConstraints(prop.PropertyType);
var inferredConstraints2 = GetInferredConstraints(prop.PropertyType, prop.Name);
result = MergeConstraints(typeConstraints2, msConstraints, inferredConstraints2, null);
}
// Add readonly constraint if needed
if (IsReadOnlyProperty(prop.PropertyInfo) && !result.Contains("readonly"))
{
result = string.IsNullOrEmpty(result) ? "readonly" : result + ", readonly";
}
// Add not-mapped constraint if needed (only once, at the end)
if (notMapped && !result.Contains("not-mapped"))
{
result = string.IsNullOrEmpty(result) ? "not-mapped" : result + ", not-mapped";
}
// Add enum-type constraint if this is an enum backing field
var enumType = FindEnumTypeForBackingField(prop.PropertyInfo, declaringType);
if (enumType != null && !result.Contains("enum-type:"))
{
var enumConstraint = $"enum-type: {enumType.Name}";
result = string.IsNullOrEmpty(result) ? enumConstraint : result + ", " + enumConstraint;
}
return result;
}
/// <summary>
/// Get final property examples with placeholder resolution.
/// </summary>
private static string GetFinalPropertyExamples(ToonPropertyAccessor prop)
{
var customExamples = prop.CustomDescription?.Examples;
if (!string.IsNullOrWhiteSpace(customExamples))
{
// Has [#...] placeholder? → Resolve it
if (customExamples.Contains("[#"))
{
return ResolveExamplesPlaceholders(customExamples, prop);
}
return customExamples;
}
// No examples
return string.Empty;
}
/// <summary>
/// Get final type description with fallback chain and placeholder resolution.
/// Priority: ToonDescription (with placeholders) > Microsoft [Description] > Smart inference
/// </summary>
private static string GetFinalTypeDescription(Type type, ToonTypeMetadata metadata)
{
// 1. ToonDescription.Description (if not empty)
var customDesc = metadata.CustomDescription?.Description;
if (!string.IsNullOrWhiteSpace(customDesc))
{
// Has [#...] placeholder? → Resolve it
if (customDesc.Contains("[#"))
{
return ResolveTypeDescriptionPlaceholders(customDesc, type);
}
// No placeholder → use as-is
return customDesc;
}
// 2. Microsoft [Description] attribute
var msDesc = type.GetCustomAttribute<System.ComponentModel.DescriptionAttribute>();
if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description))
return msDesc.Description;
// 3. Smart inference (fallback)
return GetTypeDescription(type);
}
/// <summary>
/// Get final type purpose with fallback chain and placeholder resolution.
/// Priority: ToonDescription.Purpose (with placeholders) > Smart inference (empty for classes)
/// </summary>
private static string GetFinalTypePurpose(Type type, ToonTypeMetadata metadata)
{
var customPurpose = metadata.CustomDescription?.Purpose;
if (!string.IsNullOrWhiteSpace(customPurpose))
{
// Has [#...] placeholder? → Resolve it
if (customPurpose.Contains("[#"))
{
return ResolveTypePurposePlaceholders(customPurpose, type);
}
return customPurpose;
}
// No smart inference for class-level purpose - return empty
return string.Empty;
}
/// <summary>
/// Get final enum description with fallback chain.
/// Priority: ToonDescription.Description -> Microsoft [Description] -> null (no fallback)
/// Returns null if no explicit description to avoid redundancy (values already in values: section).
/// </summary>
private static string? GetFinalEnumDescription(Type enumType, ToonDescriptionAttribute? customDescription)
{
// 1. ToonDescription.Description
if (!string.IsNullOrWhiteSpace(customDescription?.Description))
{
return customDescription.Description;
}
// 2. Microsoft [Description] attribute
var msDesc = enumType.GetCustomAttribute<System.ComponentModel.DescriptionAttribute>();
if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description))
{
return msDesc.Description;
}
// 3. No fallback - avoid redundancy (values already listed in values: section)
return null;
}
}

View File

@ -0,0 +1,189 @@
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Convention: Property named "{TypeName}Id" with a corresponding "{TypeName}" navigation property is a foreign key.
/// Type-based validation: checks that navigation property is a complex type (not primitive/string).
/// Example: "CompanyId" + "Company" property exists and is complex type -> foreign key to "Company".
/// </summary>
private static string? DetectConventionForeignKey(PropertyInfo property)
{
// Skip non-value types (we don't check specific FK type, could be int/long/Guid/etc)
if (!property.PropertyType.IsValueType)
{
return null;
}
// Check if property name ends with "Id"
if (!property.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Extract potential navigation property name
var navigationPropertyName = property.Name.Substring(0, property.Name.Length - 2);
// Check if corresponding navigation property exists
var declaringType = property.DeclaringType;
if (declaringType == null)
{
return null;
}
var navigationProperty = declaringType.GetProperty(navigationPropertyName, BindingFlags.Public | BindingFlags.Instance);
// Type-based validation: navigation property must exist and be a complex type (not primitive/string)
if (navigationProperty == null || IsPrimitiveOrStringFast(navigationProperty.PropertyType))
{
return null;
}
// Additional validation: navigation property type should have a primary key (IsPrimaryKey or Id property)
if (!HasPrimaryKeyProperty(navigationProperty.PropertyType))
{
return null;
}
return navigationPropertyName;
}
/// <summary>
/// Detect FK property name for a navigation property.
/// Example: Customer navigation → CustomerId FK.
/// Also tries target type name: ShippingDocumentFile (type: Files) → FilesId FK.
/// </summary>
private static string? DetectForeignKeyForNavigationProperty(PropertyInfo property)
{
var declaringType = property.DeclaringType;
if (declaringType == null)
return null;
var allProperties = declaringType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// Strategy 1: FK based on navigation property name (e.g., Customer → CustomerId)
var fk = TryFindForeignKeyPropertyDirect(allProperties, property.Name);
if (fk != null)
return fk;
// Strategy 2: FK based on target type name (e.g., ShippingDocumentFile: Files → FilesId)
var targetTypeName = property.PropertyType.Name;
fk = TryFindForeignKeyPropertyDirect(allProperties, targetTypeName);
if (fk != null)
return fk;
return null;
}
/// <summary>
/// Detect the foreign key property name on the other side of the relationship (OtherKey).
/// For OneToMany: Order.OrderItems → OrderItem has "OrderId" FK.
/// </summary>
private static string? DetectOtherKey(PropertyInfo property, ToonRelationType? navigationType, string? inverseProperty)
{
if (navigationType != ToonRelationType.OneToMany)
return null;
var elementType = GetCollectionElementType(property.PropertyType);
if (elementType == null)
return null;
// Use ReflectedType if available (the type we're reflecting on), otherwise DeclaringType
var declaringType = property.ReflectedType ?? property.DeclaringType;
if (declaringType == null)
return null;
var elementProperties = elementType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// Strategy 1: FK based on inverse property name
if (!string.IsNullOrEmpty(inverseProperty))
{
var fk = TryFindForeignKeyPropertyDirect(elementProperties, inverseProperty);
if (fk != null) return fk;
}
// Strategy 2: FK based on declaring type name (with Dto suffix handling)
var fkFromType = TryFindForeignKeyPropertyDirect(elementProperties, declaringType.Name);
if (fkFromType != null) return fkFromType;
// Strategy 3: FK based on element type name prefix
// OrderNote.OrderId → "Order" is prefix of "OrderNote", so look for "{prefix}Id"
var elementTypeName = elementType.Name;
for (int i = 0; i < elementProperties.Length; i++)
{
var prop = elementProperties[i];
if (!prop.Name.EndsWith("Id", StringComparison.Ordinal) || !prop.PropertyType.IsValueType)
continue;
// Extract the prefix: "OrderId" → "Order"
var fkPrefix = prop.Name.Substring(0, prop.Name.Length - 2);
// Check if element type name starts with this prefix: "OrderNote".StartsWith("Order")
if (elementTypeName.StartsWith(fkPrefix, StringComparison.Ordinal) && fkPrefix.Length > 0)
{
return prop.Name;
}
}
// Strategy 4: FK that has navigation property pointing back
for (int i = 0; i < elementProperties.Length; i++)
{
var prop = elementProperties[i];
if (!prop.Name.EndsWith("Id", StringComparison.Ordinal) || !prop.PropertyType.IsValueType)
continue;
var navName = prop.Name.Substring(0, prop.Name.Length - 2);
for (int j = 0; j < elementProperties.Length; j++)
{
if (elementProperties[j].Name == navName && IsTypeMatch(elementProperties[j].PropertyType, declaringType))
return prop.Name;
}
}
return null;
}
/// <summary>
/// Find FK property by name using direct PropertyInfo array (not filtered by GetSerializableProperties).
/// Handles "Dto" and "Model" suffix removal for naming conventions.
/// Example: baseName="OrderDto" → tries "OrderDtoId", then "OrderId"
/// Example: baseName="OrderModel" → tries "OrderModelId", then "OrderId"
/// </summary>
private static string? TryFindForeignKeyPropertyDirect(PropertyInfo[] properties, string baseName)
{
// Try "{baseName}Id"
var fkName = baseName + "Id";
for (int i = 0; i < properties.Length; i++)
{
if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType)
return fkName;
}
// Try without "Dto" suffix: "OrderDto" → "OrderId"
if (baseName.Length > 3 && baseName.EndsWith("Dto", StringComparison.Ordinal))
{
fkName = baseName.Substring(0, baseName.Length - 3) + "Id";
for (int i = 0; i < properties.Length; i++)
{
if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType)
return fkName;
}
}
// Try without "Model" suffix: "OrderModel" → "OrderId"
if (baseName.Length > 5 && baseName.EndsWith("Model", StringComparison.Ordinal))
{
fkName = baseName.Substring(0, baseName.Length - 5) + "Id";
for (int i = 0; i < properties.Length; i++)
{
if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType)
return fkName;
}
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,245 @@
using System.Collections;
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Write meta section only (for MetaOnly mode).
/// </summary>
private static void WriteMetaSectionOnly(Type type, ToonSerializationContext context)
{
WriteMetaSection(type, context);
}
/// <summary>
/// Write @meta and @types sections for a single root type.
/// </summary>
private static void WriteMetaSection(Type type, ToonSerializationContext context)
{
if (!context.Options.UseMeta) return;
// Collect all types that need metadata
var typesToDocument = new HashSet<Type>();
CollectTypes(type, typesToDocument);
WriteMetaSectionCore(typesToDocument, context);
}
/// <summary>
/// Write @meta and @types sections for a collection of types.
/// </summary>
private static void WriteMetaSection(IEnumerable<Type> types, ToonSerializationContext context)
{
if (!context.Options.UseMeta) return;
// Collect all types that need metadata (including nested types)
var typesToDocument = new HashSet<Type>();
foreach (var type in types)
{
CollectTypes(type, typesToDocument);
}
WriteMetaSectionCore(typesToDocument, context);
}
/// <summary>
/// Core logic for writing @meta and @types sections.
/// </summary>
private static void WriteMetaSectionCore(HashSet<Type> typesToDocument, ToonSerializationContext context)
{
// @meta header
context.WriteLine("@meta {");
context.CurrentIndentLevel++;
context.WriteProperty("version", $"\"{FormatVersion}\"");
context.WriteProperty("format", "\"toon\"");
context.WriteProperty("source-code-language", "\"C#\"");
// Write type list
context.WriteIndent();
context.Write("types");
// Token optimization: no spaces around '=' when indentation is disabled
if (context.Options.UseIndentation)
{
context.Write(" = ");
}
else
{
context.Write('=');
}
// Szűrés: csak komplex típusok (class/struct) és enum-ok, primitívek nélkül
var filteredTypes = typesToDocument
.Where(t => t.IsEnum || !IsPrimitiveOrStringFast(t)) // Enum-ok megtartása, primitívek és string kizárása
.Where(t => !typeof(IEnumerable).IsAssignableFrom(t) || t == typeof(string))
.Where(t => !t.IsGenericType || t.IsGenericTypeDefinition)
.Where(t => !t.Name.StartsWith("List`", StringComparison.Ordinal) && !t.Name.StartsWith("ICollection`", StringComparison.Ordinal) && !t.Name.StartsWith("IEnumerable`", StringComparison.Ordinal) && !t.Name.StartsWith("Dictionary`", StringComparison.Ordinal))
.ToList();
// DAG topological sort: dependencies first
var sortedTypes = TopologicalSortTypes(filteredTypes);
context.Write("[");
var first = true;
foreach (var t in sortedTypes)
{
if (!first) context.Write(", ");
context.Write($"\"{t.Name}\"");
first = false;
}
context.WriteLine("]");
context.CurrentIndentLevel--;
context.WriteLine("}");
// Token optimization: skip empty line when indentation is disabled
if (context.Options.UseIndentation)
{
context.WriteLine();
}
// @types section with descriptions (same sorted order)
context.WriteLine("@types {");
context.CurrentIndentLevel++;
foreach (var t in sortedTypes)
{
WriteTypeDefinition(t, context);
}
context.CurrentIndentLevel--;
context.WriteLine("}");
}
/// <summary>
/// Recursively collects all types that need metadata documentation.
/// Uses the same logic as AcBinarySerializer.RegisterMetadataForType.
/// </summary>
private static void CollectTypes(Type type, HashSet<Type> types)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
// Enum-ok: hozzáadjuk és return (nem traversáljuk tovább)
if (underlyingType.IsEnum)
{
types.Add(underlyingType);
return;
}
// Primitívek és string: kihagyjuk
if (IsPrimitiveOrStringFast(underlyingType)) return;
if (!types.Add(underlyingType)) return; // Already processed
// Handle dictionaries FIRST (before generic IEnumerable check)
if (IsDictionaryType(underlyingType, out var keyType, out var valueType))
{
if (keyType != null) CollectTypes(keyType, types);
if (valueType != null) CollectTypes(valueType, types);
return;
}
// Handle collections/arrays (matches AcBinarySerializer logic)
if (typeof(IEnumerable).IsAssignableFrom(underlyingType) && !ReferenceEquals(underlyingType, StringType))
{
var elementType = GetCollectionElementType(underlyingType);
if (elementType != null)
{
CollectTypes(elementType, types);
}
return;
}
// Handle object properties (traverse type graph)
var metadata = GetTypeMetadata(underlyingType);
// Detektáljuk az enum backing field-eket (NotMapped enum property-k)
DetectAndCollectEnumBackingFields(underlyingType, types);
foreach (var prop in metadata.Properties)
{
CollectTypes(prop.PropertyType, types);
}
}
/// <summary>
/// Detektálja az enum backing field-eket és hozzáadja az enum típusokat a types hash set-hez.
/// Logika: Ha van egy enum property (bármilyen) és van egy {PropertyName}Id int property,
/// akkor az enum típust hozzáadjuk.
/// </summary>
private static void DetectAndCollectEnumBackingFields(Type type, HashSet<Type> types)
{
var allProperties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in allProperties)
{
// Csak enum property-ket vizsgálunk
if (!prop.PropertyType.IsEnum) continue;
// Keressük meg a {PropertyName}Id backing field-et
var backingFieldName = $"{prop.Name}Id";
var backingField = allProperties.FirstOrDefault(p =>
p.Name == backingFieldName &&
(p.PropertyType == typeof(int) || p.PropertyType == typeof(int?)));
if (backingField != null)
{
// Megtaláltuk az enum backing field párt → hozzáadjuk az enum típust
types.Add(prop.PropertyType);
}
}
}
/// <summary>
/// Megkeresi a property-hez tartozó enum típust, ha az egy enum backing field.
/// Visszaadja az enum típust CSAK akkor, ha:
/// 1. Van megfelelő nevű enum property (naming convention)
/// 2. Az enum property NotMapped/NotColumn/JsonIgnore attribútummal van ellátva
/// 3. A backing field típusa megegyezik az enum underlying type-jával (int/int?, byte/byte?, stb.)
/// </summary>
private static Type? FindEnumTypeForBackingField(PropertyInfo backingFieldProperty, Type declaringType)
{
// Ha a property neve "...Id"-re végződik, keressük meg az enum property-t
if (!backingFieldProperty.Name.EndsWith("Id", StringComparison.Ordinal))
return null;
var enumPropertyName = backingFieldProperty.Name.Substring(0, backingFieldProperty.Name.Length - 2);
var allProperties = declaringType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var enumProperty = allProperties.FirstOrDefault(p =>
p.Name == enumPropertyName &&
(p.PropertyType.IsEnum || (Nullable.GetUnderlyingType(p.PropertyType)?.IsEnum ?? false)));
if (enumProperty == null) return null;
// Ellenőrizzük, hogy NotMapped/NotColumn/JsonIgnore van-e rajta
var isNotMapped = enumProperty.GetCustomAttributes(inherit: true).Any(attr =>
{
var attrName = attr.GetType().Name;
return attrName == "NotMappedAttribute" ||
attrName == "NotColumnAttribute" ||
attrName == "JsonIgnoreAttribute";
});
// Csak akkor backing field, ha az enum property NotMapped!
if (!isNotMapped) return null;
// Ellenőrizzük, hogy a backing field típusa megegyezik-e az enum underlying type-jával
var enumType = Nullable.GetUnderlyingType(enumProperty.PropertyType) ?? enumProperty.PropertyType;
var enumUnderlyingType = Enum.GetUnderlyingType(enumType);
// A backing field lehet nullable vagy non-nullable is
var backingUnderlyingType = Nullable.GetUnderlyingType(backingFieldProperty.PropertyType)
?? backingFieldProperty.PropertyType;
// Az underlying type-oknak meg kell egyezniük
if (backingUnderlyingType != enumUnderlyingType)
return null;
// Visszaadjuk az enum típust (nullable nélkül)
return enumType;
}
}

View File

@ -0,0 +1,251 @@
using System.Collections;
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Detects navigation property metadata for a property with fallback chain.
/// Priority: ToonDescription -> EF Core/Linq2Db attributes -> Convention-based detection.
/// Returns cached AcNavigationPropertyInfo with all relationship details.
/// </summary>
internal static AcNavigationPropertyInfo DetectNavigationMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription)
{
// 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention
bool isPrimaryKey;
if (customDescription?.IsPrimaryKey.HasValue == true)
{
isPrimaryKey = customDescription.IsPrimaryKey.Value;
}
else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property))
{
isPrimaryKey = true;
}
else
{
isPrimaryKey = IsConventionPrimaryKey(property);
}
// 2. Navigation type detection (needed early for FK detection logic)
ToonRelationType? navigationType = null;
if (customDescription?.Navigation.HasValue == true)
{
navigationType = customDescription.Navigation.Value;
}
else
{
var linq2dbNav = TryGetLinq2DbNavigation(property);
if (linq2dbNav.HasValue)
{
navigationType = linq2dbNav.Value;
}
else
{
navigationType = DetectConventionNavigation(property);
}
}
// 3. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention
string? foreignKey = null;
if (!string.IsNullOrEmpty(customDescription?.ForeignKey))
{
foreignKey = customDescription.ForeignKey;
}
else
{
var efCoreFk = TryGetEFCoreForeignKey(property);
if (!string.IsNullOrEmpty(efCoreFk))
{
foreignKey = efCoreFk;
}
else
{
// For FK properties (int/Guid/etc ending with Id)
foreignKey = DetectConventionForeignKey(property);
// For navigation properties (ManyToOne/OneToOne), find the FK property name
if (string.IsNullOrEmpty(foreignKey) &&
(navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne))
{
foreignKey = DetectForeignKeyForNavigationProperty(property);
}
}
}
// 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty] -> Convention
string? inverseProperty = null;
if (!string.IsNullOrEmpty(customDescription?.InverseProperty))
{
inverseProperty = customDescription.InverseProperty;
}
else
{
var efCoreInverse = TryGetEFCoreInverseProperty(property);
if (!string.IsNullOrEmpty(efCoreInverse))
{
inverseProperty = efCoreInverse;
}
else
{
// Fallback: convention-based detection
inverseProperty = DetectConventionInverseProperty(property, navigationType);
}
}
// 5. OtherKey - Detect FK on the other side (for OneToMany)
string? otherKey = DetectOtherKey(property, navigationType, inverseProperty);
// 6. TargetType - Extract target entity type
Type? targetType = null;
if (navigationType == ToonRelationType.OneToMany || navigationType == ToonRelationType.ManyToMany)
{
targetType = GetCollectionElementType(property.PropertyType);
}
else if (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne)
{
targetType = property.PropertyType;
}
return new AcNavigationPropertyInfo(
isPrimaryKey: isPrimaryKey,
navigationType: navigationType,
foreignKey: foreignKey,
otherKey: otherKey,
inverseProperty: inverseProperty,
targetType: targetType);
}
/// <summary>
/// Convention: Detect navigation type based on property type.
/// Type-based validation with FK lookup:
/// - ICollection&lt;T&gt; or List&lt;T&gt; -> OneToMany (if T has primary key)
/// - Complex object type -> ManyToOne (if has corresponding FK property and has primary key)
/// </summary>
private static ToonRelationType? DetectConventionNavigation(PropertyInfo property)
{
var propertyType = property.PropertyType;
// Skip primitive types and strings
if (IsPrimitiveOrStringFast(propertyType))
{
return null;
}
// Check for collection types (OneToMany)
if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string))
{
// Extract element type from collection
var elementType = GetCollectionElementType(propertyType);
if (elementType != null && HasPrimaryKeyProperty(elementType))
{
// It's a collection of entities -> OneToMany
return ToonRelationType.OneToMany;
}
return null;
}
// Complex object type -> check if it's a navigation property
// Type-based validation: must have a primary key
if (!HasPrimaryKeyProperty(propertyType))
{
return null;
}
// Look for a corresponding foreign key property to confirm it's a navigation
var declaringType = property.DeclaringType;
if (declaringType != null)
{
var foreignKeyPropertyName = property.Name + "Id";
var foreignKeyProperty = declaringType.GetProperty(foreignKeyPropertyName, BindingFlags.Public | BindingFlags.Instance);
// Type-based validation: FK must be a value type
if (foreignKeyProperty != null && foreignKeyProperty.PropertyType.IsValueType)
{
// Found corresponding foreign key -> ManyToOne
return ToonRelationType.ManyToOne;
}
}
// No corresponding foreign key found
// Return null to avoid false positives
return null;
}
/// <summary>
/// Convention: Detect inverse property by looking at the related type's properties.
/// For OneToMany: Look for ManyToOne property in element type that points back.
/// For ManyToOne: Look for OneToMany collection in related type that points back.
/// </summary>
private static string? DetectConventionInverseProperty(PropertyInfo property, ToonRelationType? navigationType)
{
if (!navigationType.HasValue)
return null;
var declaringType = property.DeclaringType;
if (declaringType == null)
return null;
if (navigationType == ToonRelationType.OneToMany)
{
// OneToMany: look for ManyToOne in element type that points back to declaring type
var elementType = GetCollectionElementType(property.PropertyType);
if (elementType == null)
return null;
var elementMetadata = GetTypeMetadata(elementType);
foreach (var elementProp in elementMetadata.Properties)
{
if (IsTypeMatch(elementProp.PropertyType, declaringType))
return elementProp.Name;
}
}
else if (navigationType == ToonRelationType.ManyToOne)
{
// ManyToOne: look for OneToMany collection in related type that points back
var relatedMetadata = GetTypeMetadata(property.PropertyType);
foreach (var relatedProp in relatedMetadata.Properties)
{
if (typeof(IEnumerable).IsAssignableFrom(relatedProp.PropertyType) &&
relatedProp.PropertyType != typeof(string) &&
IsTypeMatch(GetCollectionElementType(relatedProp.PropertyType), declaringType))
{
return relatedProp.Name;
}
}
}
return null;
}
/// <summary>
/// Detect table name for a type with fallback chain.
/// Priority: ToonDescription.TableName -> EF Core [Table] -> Linq2Db [Table] -> Convention (class name).
/// </summary>
internal static string? DetectTableName(Type type, ToonDescriptionAttribute? customDescription)
{
// 1. ToonDescription.TableName (explicit override)
if (!string.IsNullOrEmpty(customDescription?.TableName))
{
return customDescription.TableName;
}
// 2. EF Core [Table("name")] attribute (primary)
var efCoreTable = TryGetEFCoreTableName(type);
if (!string.IsNullOrEmpty(efCoreTable))
{
return efCoreTable;
}
// 3. Linq2Db [Table(Name = "name")] attribute (if EF Core not found)
var linq2dbTable = TryGetLinq2DbTableName(type);
if (!string.IsNullOrEmpty(linq2dbTable))
{
return linq2dbTable;
}
// 4. Convention: class name (fallback)
return type.Name;
}
}

View File

@ -0,0 +1,56 @@
using System.ComponentModel;
using System.Reflection;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Resolve [#...] placeholders in type description string.
/// </summary>
private static string ResolveTypeDescriptionPlaceholders(string template, Type type)
{
var result = template;
// [#Description] → Microsoft [Description]
if (result.Contains("[#Description]"))
{
var msDesc = type.GetCustomAttribute<System.ComponentModel.DescriptionAttribute>();
var value = msDesc?.Description ?? "";
result = result.Replace("[#Description]", value);
}
// [#DisplayName] → Microsoft [DisplayName]
if (result.Contains("[#DisplayName]"))
{
var displayName = type.GetCustomAttribute<System.ComponentModel.DisplayNameAttribute>();
var value = displayName?.DisplayName ?? type.Name;
result = result.Replace("[#DisplayName]", value);
}
// [#SmartDescription] → Smart inference
if (result.Contains("[#SmartDescription]"))
{
var value = GetTypeDescription(type);
result = result.Replace("[#SmartDescription]", value);
}
return CleanupPlaceholders(result);
}
/// <summary>
/// Resolve [#...] placeholders in type purpose string.
/// </summary>
private static string ResolveTypePurposePlaceholders(string template, Type type)
{
var result = template;
// [#SmartPurpose] → Would be empty for classes, so just remove it
if (result.Contains("[#SmartPurpose]"))
{
result = result.Replace("[#SmartPurpose]", "");
}
return CleanupPlaceholders(result);
}
}

View File

@ -1,642 +0,0 @@
using System.Collections;
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Detects navigation property metadata for a property with fallback chain.
/// Priority: ToonDescription -> EF Core/Linq2Db attributes -> Convention-based detection.
/// Returns cached AcNavigationPropertyInfo with all relationship details.
/// </summary>
internal static AcNavigationPropertyInfo DetectNavigationMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription)
{
// 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention
bool isPrimaryKey;
if (customDescription?.IsPrimaryKey.HasValue == true)
{
isPrimaryKey = customDescription.IsPrimaryKey.Value;
}
else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property))
{
isPrimaryKey = true;
}
else
{
isPrimaryKey = IsConventionPrimaryKey(property);
}
// 2. Navigation type detection (needed early for FK detection logic)
ToonRelationType? navigationType = null;
if (customDescription?.Navigation.HasValue == true)
{
navigationType = customDescription.Navigation.Value;
}
else
{
var linq2dbNav = TryGetLinq2DbNavigation(property);
if (linq2dbNav.HasValue)
{
navigationType = linq2dbNav.Value;
}
else
{
navigationType = DetectConventionNavigation(property);
}
}
// 3. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention
string? foreignKey = null;
if (!string.IsNullOrEmpty(customDescription?.ForeignKey))
{
foreignKey = customDescription.ForeignKey;
}
else
{
var efCoreFk = TryGetEFCoreForeignKey(property);
if (!string.IsNullOrEmpty(efCoreFk))
{
foreignKey = efCoreFk;
}
else
{
// For FK properties (int/Guid/etc ending with Id)
foreignKey = DetectConventionForeignKey(property);
// For navigation properties (ManyToOne/OneToOne), find the FK property name
if (string.IsNullOrEmpty(foreignKey) &&
(navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne))
{
foreignKey = DetectForeignKeyForNavigationProperty(property);
}
}
}
// 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty] -> Convention
string? inverseProperty = null;
if (!string.IsNullOrEmpty(customDescription?.InverseProperty))
{
inverseProperty = customDescription.InverseProperty;
}
else
{
var efCoreInverse = TryGetEFCoreInverseProperty(property);
if (!string.IsNullOrEmpty(efCoreInverse))
{
inverseProperty = efCoreInverse;
}
else
{
// Fallback: convention-based detection
inverseProperty = DetectConventionInverseProperty(property, navigationType);
}
}
// 5. OtherKey - Detect FK on the other side (for OneToMany)
string? otherKey = DetectOtherKey(property, navigationType, inverseProperty);
// 6. TargetType - Extract target entity type
Type? targetType = null;
if (navigationType == ToonRelationType.OneToMany || navigationType == ToonRelationType.ManyToMany)
{
targetType = GetCollectionElementType(property.PropertyType);
}
else if (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne)
{
targetType = property.PropertyType;
}
return new AcNavigationPropertyInfo(
isPrimaryKey: isPrimaryKey,
navigationType: navigationType,
foreignKey: foreignKey,
otherKey: otherKey,
inverseProperty: inverseProperty,
targetType: targetType);
}
/// <summary>
/// Convention: Property named "Id" or "{TypeName}Id" is a primary key.
/// </summary>
private static bool IsConventionPrimaryKey(PropertyInfo property)
{
if (property.PropertyType != typeof(int) &&
property.PropertyType != typeof(long) &&
property.PropertyType != typeof(Guid))
{
return false;
}
return property.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
property.Name.Equals(property.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Convention: Property named "{TypeName}Id" with a corresponding "{TypeName}" navigation property is a foreign key.
/// Type-based validation: checks that navigation property is a complex type (not primitive/string).
/// Example: "CompanyId" + "Company" property exists and is complex type -> foreign key to "Company".
/// </summary>
private static string? DetectConventionForeignKey(PropertyInfo property)
{
// Skip non-value types (we don't check specific FK type, could be int/long/Guid/etc)
if (!property.PropertyType.IsValueType)
{
return null;
}
// Check if property name ends with "Id"
if (!property.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Extract potential navigation property name
var navigationPropertyName = property.Name.Substring(0, property.Name.Length - 2);
// Check if corresponding navigation property exists
var declaringType = property.DeclaringType;
if (declaringType == null)
{
return null;
}
var navigationProperty = declaringType.GetProperty(navigationPropertyName, BindingFlags.Public | BindingFlags.Instance);
// Type-based validation: navigation property must exist and be a complex type (not primitive/string)
if (navigationProperty == null || IsPrimitiveOrStringFast(navigationProperty.PropertyType))
{
return null;
}
// Additional validation: navigation property type should have a primary key (IsPrimaryKey or Id property)
if (!HasPrimaryKeyProperty(navigationProperty.PropertyType))
{
return null;
}
return navigationPropertyName;
}
/// <summary>
/// Convention: Detect navigation type based on property type.
/// Type-based validation with FK lookup:
/// - ICollection&lt;T&gt; or List&lt;T&gt; -> OneToMany (if T has primary key)
/// - Complex object type -> ManyToOne (if has corresponding FK property and has primary key)
/// </summary>
private static ToonRelationType? DetectConventionNavigation(PropertyInfo property)
{
var propertyType = property.PropertyType;
// Skip primitive types and strings
if (IsPrimitiveOrStringFast(propertyType))
{
return null;
}
// Check for collection types (OneToMany)
if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string))
{
// Extract element type from collection
var elementType = GetCollectionElementType(propertyType);
if (elementType != null && HasPrimaryKeyProperty(elementType))
{
// It's a collection of entities -> OneToMany
return ToonRelationType.OneToMany;
}
return null;
}
// Complex object type -> check if it's a navigation property
// Type-based validation: must have a primary key
if (!HasPrimaryKeyProperty(propertyType))
{
return null;
}
// Look for a corresponding foreign key property to confirm it's a navigation
var declaringType = property.DeclaringType;
if (declaringType != null)
{
var foreignKeyPropertyName = property.Name + "Id";
var foreignKeyProperty = declaringType.GetProperty(foreignKeyPropertyName, BindingFlags.Public | BindingFlags.Instance);
// Type-based validation: FK must be a value type
if (foreignKeyProperty != null && foreignKeyProperty.PropertyType.IsValueType)
{
// Found corresponding foreign key -> ManyToOne
return ToonRelationType.ManyToOne;
}
}
// No corresponding foreign key found
// Return null to avoid false positives
return null;
}
/// <summary>
/// Check if a type has a primary key property.
/// First checks for IsPrimaryKey=true in ToonDescription, then falls back to "Id" property.
/// </summary>
private static bool HasPrimaryKeyProperty(Type type)
{
if (IsPrimitiveOrStringFast(type))
{
return false;
}
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// First: check for ToonDescription with IsPrimaryKey = true
foreach (var prop in properties)
{
var customDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>();
if (customDescription?.IsPrimaryKey == true)
{
return true;
}
}
// Fallback: check for "Id" or "{TypeName}Id" property
foreach (var prop in properties)
{
if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase))
{
// Must be a value type (int, long, Guid, etc.)
if (prop.PropertyType.IsValueType)
{
return true;
}
}
}
return false;
}
#region EF Core Attribute Detection (reflection-based, no dependency)
/// <summary>
/// Detect EF Core [Key] attribute via reflection (no EF Core dependency).
/// </summary>
private static bool TryGetEFCoreKey(PropertyInfo property)
{
var keyAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "KeyAttribute");
return keyAttr != null;
}
/// <summary>
/// Detect EF Core [ForeignKey("NavigationPropertyName")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreForeignKey(PropertyInfo property)
{
var fkAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "ForeignKeyAttribute");
if (fkAttr != null)
{
// Get the Name property value (navigation property name)
var nameProp = fkAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(fkAttr) as string;
}
return null;
}
/// <summary>
/// Detect EF Core [InverseProperty("PropertyName")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreInverseProperty(PropertyInfo property)
{
var inversePropAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "InversePropertyAttribute");
if (inversePropAttr != null)
{
var propertyProp = inversePropAttr.GetType().GetProperty("Property");
return propertyProp?.GetValue(inversePropAttr) as string;
}
return null;
}
#endregion
#region Linq2Db Attribute Detection (reflection-based, no dependency)
/// <summary>
/// Detect Linq2Db [PrimaryKey] attribute via reflection (no Linq2Db dependency).
/// </summary>
private static bool TryGetLinq2DbPrimaryKey(PropertyInfo property)
{
var pkAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "PrimaryKeyAttribute");
return pkAttr != null;
}
/// <summary>
/// Detect Linq2Db [Association] attribute and determine navigation type.
/// Supports simple ThisKey/OtherKey associations. Expression-based associations are skipped.
/// </summary>
private static ToonRelationType? TryGetLinq2DbNavigation(PropertyInfo property)
{
var assocAttr = property.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "AssociationAttribute");
if (assocAttr == null)
return null;
// Check if it's expression-based (has QueryExpressionMethod property set)
var queryExprMethod = assocAttr.GetType().GetProperty("QueryExpressionMethod")?.GetValue(assocAttr) as string;
if (!string.IsNullOrEmpty(queryExprMethod))
{
// Expression-based many-to-many - too complex, skip and fallback to convention
return null;
}
// Simple association with ThisKey/OtherKey
var thisKey = assocAttr.GetType().GetProperty("ThisKey")?.GetValue(assocAttr) as string;
var otherKey = assocAttr.GetType().GetProperty("OtherKey")?.GetValue(assocAttr) as string;
var propertyType = property.PropertyType;
// Check if it's a collection (OneToMany or ManyToMany)
if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string))
{
// Collection with ThisKey + OtherKey -> could be ManyToMany or OneToMany
// If both ThisKey and OtherKey are set -> likely ManyToMany
// If only ThisKey -> OneToMany
if (!string.IsNullOrEmpty(thisKey) && !string.IsNullOrEmpty(otherKey))
{
// Could be ManyToMany, but without junction table info, treat as OneToMany
return ToonRelationType.OneToMany;
}
else
{
return ToonRelationType.OneToMany;
}
}
else
{
// Single object -> ManyToOne or OneToOne
// Typically ManyToOne
return ToonRelationType.ManyToOne;
}
}
#endregion
#region TableName Detection (class-level)
/// <summary>
/// Detect table name for a type with fallback chain.
/// Priority: ToonDescription.TableName -> EF Core [Table] -> Linq2Db [Table] -> Convention (class name).
/// </summary>
internal static string? DetectTableName(Type type, ToonDescriptionAttribute? customDescription)
{
// 1. ToonDescription.TableName (explicit override)
if (!string.IsNullOrEmpty(customDescription?.TableName))
{
return customDescription.TableName;
}
// 2. EF Core [Table("name")] attribute (primary)
var efCoreTable = TryGetEFCoreTableName(type);
if (!string.IsNullOrEmpty(efCoreTable))
{
return efCoreTable;
}
// 3. Linq2Db [Table(Name = "name")] attribute (if EF Core not found)
var linq2dbTable = TryGetLinq2DbTableName(type);
if (!string.IsNullOrEmpty(linq2dbTable))
{
return linq2dbTable;
}
// 4. Convention: class name (fallback)
return type.Name;
}
/// <summary>
/// Detect EF Core [Table("name")] or [Table(Name = "name", Schema = "schema")] attribute via reflection.
/// </summary>
private static string? TryGetEFCoreTableName(Type type)
{
var tableAttr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "TableAttribute");
if (tableAttr != null)
{
// EF Core TableAttribute has "Name" property
var nameProp = tableAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(tableAttr) as string;
}
return null;
}
/// <summary>
/// Detect Linq2Db [Table(Name = "name")] attribute via reflection.
/// </summary>
private static string? TryGetLinq2DbTableName(Type type)
{
var tableAttr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "TableAttribute");
if (tableAttr != null)
{
// Linq2Db TableAttribute also has "Name" property
var nameProp = tableAttr.GetType().GetProperty("Name");
return nameProp?.GetValue(tableAttr) as string;
}
return null;
}
#endregion
#region Convention-based Inverse Property Detection
/// <summary>
/// Convention: Detect inverse property by looking at the related type's properties.
/// For OneToMany: Look for ManyToOne property in element type that points back.
/// For ManyToOne: Look for OneToMany collection in related type that points back.
/// </summary>
private static string? DetectConventionInverseProperty(PropertyInfo property, ToonRelationType? navigationType)
{
if (!navigationType.HasValue)
return null;
var declaringType = property.DeclaringType;
if (declaringType == null)
return null;
if (navigationType == ToonRelationType.OneToMany)
{
// OneToMany: look for ManyToOne in element type that points back to declaring type
var elementType = GetCollectionElementType(property.PropertyType);
if (elementType == null)
return null;
var elementProperties = TypeMetadataBase.GetSerializableProperties(elementType);
for (int i = 0; i < elementProperties.Length; i++)
{
if (IsTypeMatch(elementProperties[i].PropertyType, declaringType))
return elementProperties[i].Name;
}
}
else if (navigationType == ToonRelationType.ManyToOne)
{
// ManyToOne: look for OneToMany collection in related type that points back
var relatedProperties = TypeMetadataBase.GetSerializableProperties(property.PropertyType);
for (int i = 0; i < relatedProperties.Length; i++)
{
var relatedProp = relatedProperties[i];
if (typeof(IEnumerable).IsAssignableFrom(relatedProp.PropertyType) &&
relatedProp.PropertyType != typeof(string) &&
IsTypeMatch(GetCollectionElementType(relatedProp.PropertyType), declaringType))
{
return relatedProp.Name;
}
}
}
return null;
}
/// <summary>
/// Check if two types match (handles different Type instances from different assemblies).
/// </summary>
private static bool IsTypeMatch(Type? type1, Type? type2)
{
if (type1 == null || type2 == null) return false;
return type1 == type2 || type2.IsAssignableFrom(type1) || type1.FullName == type2.FullName;
}
/// <summary>
/// Detect the foreign key property name on the other side of the relationship (OtherKey).
/// For OneToMany: Order.OrderItems → OrderItem has "OrderId" FK.
/// </summary>
private static string? DetectOtherKey(PropertyInfo property, ToonRelationType? navigationType, string? inverseProperty)
{
if (navigationType != ToonRelationType.OneToMany)
return null;
var elementType = GetCollectionElementType(property.PropertyType);
if (elementType == null)
return null;
// Use ReflectedType if available (the type we're reflecting on), otherwise DeclaringType
var declaringType = property.ReflectedType ?? property.DeclaringType;
if (declaringType == null)
return null;
var elementProperties = elementType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// Strategy 1: FK based on inverse property name
if (!string.IsNullOrEmpty(inverseProperty))
{
var fk = TryFindForeignKeyPropertyDirect(elementProperties, inverseProperty);
if (fk != null) return fk;
}
// Strategy 2: FK based on declaring type name (with Dto suffix handling)
var fkFromType = TryFindForeignKeyPropertyDirect(elementProperties, declaringType.Name);
if (fkFromType != null) return fkFromType;
// Strategy 3: FK based on element type name prefix
// OrderNote.OrderId → "Order" is prefix of "OrderNote", so look for "{prefix}Id"
var elementTypeName = elementType.Name;
for (int i = 0; i < elementProperties.Length; i++)
{
var prop = elementProperties[i];
if (!prop.Name.EndsWith("Id", StringComparison.Ordinal) || !prop.PropertyType.IsValueType)
continue;
// Extract the prefix: "OrderId" → "Order"
var fkPrefix = prop.Name.Substring(0, prop.Name.Length - 2);
// Check if element type name starts with this prefix: "OrderNote".StartsWith("Order")
if (elementTypeName.StartsWith(fkPrefix, StringComparison.Ordinal) && fkPrefix.Length > 0)
{
return prop.Name;
}
}
// Strategy 4: FK that has navigation property pointing back
for (int i = 0; i < elementProperties.Length; i++)
{
var prop = elementProperties[i];
if (!prop.Name.EndsWith("Id", StringComparison.Ordinal) || !prop.PropertyType.IsValueType)
continue;
var navName = prop.Name.Substring(0, prop.Name.Length - 2);
for (int j = 0; j < elementProperties.Length; j++)
{
if (elementProperties[j].Name == navName && IsTypeMatch(elementProperties[j].PropertyType, declaringType))
return prop.Name;
}
}
return null;
}
/// <summary>
/// Detect FK property name for a navigation property.
/// Example: Customer navigation → CustomerId FK.
/// Also tries target type name: ShippingDocumentFile (type: Files) → FilesId FK.
/// </summary>
private static string? DetectForeignKeyForNavigationProperty(PropertyInfo property)
{
var declaringType = property.DeclaringType;
if (declaringType == null)
return null;
var allProperties = declaringType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// Strategy 1: FK based on navigation property name (e.g., Customer → CustomerId)
var fk = TryFindForeignKeyPropertyDirect(allProperties, property.Name);
if (fk != null)
return fk;
// Strategy 2: FK based on target type name (e.g., ShippingDocumentFile: Files → FilesId)
var targetTypeName = property.PropertyType.Name;
fk = TryFindForeignKeyPropertyDirect(allProperties, targetTypeName);
if (fk != null)
return fk;
return null;
}
/// <summary>
/// Find FK property by name using direct PropertyInfo array (not filtered by GetSerializableProperties).
/// </summary>
private static string? TryFindForeignKeyPropertyDirect(PropertyInfo[] properties, string baseName)
{
// Try "{baseName}Id"
var fkName = baseName + "Id";
for (int i = 0; i < properties.Length; i++)
{
if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType)
return fkName;
}
// Try without "Dto" suffix: "OrderDto" → "OrderId"
if (baseName.Length > 3 && baseName.EndsWith("Dto", StringComparison.Ordinal))
{
fkName = baseName.Substring(0, baseName.Length - 3) + "Id";
for (int i = 0; i < properties.Length; i++)
{
if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType)
return fkName;
}
}
return null;
}
#endregion
}

View File

@ -22,7 +22,7 @@ public static partial class AcToonSerializer
public Type? ElementType { get; } public Type? ElementType { get; }
public ToonDescriptionAttribute? CustomDescription { get; } public ToonDescriptionAttribute? CustomDescription { get; }
public ToonTypeMetadata(Type type) : base(type) public ToonTypeMetadata(Type type) : base(type, HasToonIgnoreAttribute)
{ {
TypeName = type.FullName ?? type.Name; TypeName = type.FullName ?? type.Name;
ShortTypeName = type.Name; ShortTypeName = type.Name;
@ -45,7 +45,7 @@ public static partial class AcToonSerializer
// Build property accessors using shared GetSerializableProperties from base // Build property accessors using shared GetSerializableProperties from base
if (!IsCollection && !IsDictionary && !IsPrimitiveOrStringFast(type)) if (!IsCollection && !IsDictionary && !IsPrimitiveOrStringFast(type))
{ {
var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: false) var props = GetSerializableProperties(type, requiresWrite: false)
.Select(p => new ToonPropertyAccessor(p, type)) .Select(p => new ToonPropertyAccessor(p, type))
.ToArray(); .ToArray();
@ -67,7 +67,14 @@ public static partial class AcToonSerializer
public PropertyInfo PropertyInfo { get; } public PropertyInfo PropertyInfo { get; }
public string TypeDisplayName { get; } public string TypeDisplayName { get; }
public ToonDescriptionAttribute? CustomDescription { get; } public ToonDescriptionAttribute? CustomDescription { get; }
public AcNavigationPropertyInfo Navigation { get; }
private AcNavigationPropertyInfo? _navigation;
/// <summary>
/// Navigation metadata is lazily initialized to avoid circular reference issues
/// when types reference each other (e.g., OrderDto ? OrderItemDto).
/// </summary>
public AcNavigationPropertyInfo Navigation => _navigation ??= DetectNavigationMetadata(PropertyInfo, CustomDescription);
public ToonPropertyAccessor(PropertyInfo prop, Type declaringType) public ToonPropertyAccessor(PropertyInfo prop, Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
@ -79,9 +86,6 @@ public static partial class AcToonSerializer
// Get custom attribute if present // Get custom attribute if present
CustomDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>(); CustomDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>();
// Detect and cache navigation metadata once
Navigation = DetectNavigationMetadata(prop, CustomDescription);
} }
/// <summary> /// <summary>

View File

@ -0,0 +1,134 @@
using System.Collections;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Topological sort for types: dependencies appear before dependents.
/// Handles circular references by ignoring back-edges.
/// </summary>
private static List<Type> TopologicalSortTypes(List<Type> types)
{
// Build dependency graph
var graph = new Dictionary<Type, HashSet<Type>>();
var inDegree = new Dictionary<Type, int>();
foreach (var type in types)
{
if (!graph.ContainsKey(type))
{
graph[type] = new HashSet<Type>();
inDegree[type] = 0;
}
}
// For each type, find its dependencies (types referenced in properties)
foreach (var type in types)
{
var dependencies = GetTypeDependencies(type, types);
foreach (var dependency in dependencies)
{
if (graph.ContainsKey(dependency) && dependency != type) // Ignore self-references
{
// dependency → type edge (dependency must come before type)
if (graph[dependency].Add(type))
{
inDegree[type]++;
}
}
}
}
// Kahn's algorithm for topological sort
var queue = new Queue<Type>();
var result = new List<Type>();
// Start with types that have no dependencies (in-degree = 0)
foreach (var type in types)
{
if (inDegree[type] == 0)
{
queue.Enqueue(type);
}
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
result.Add(current);
// For each type that depends on current
foreach (var dependent in graph[current])
{
inDegree[dependent]--;
if (inDegree[dependent] == 0)
{
queue.Enqueue(dependent);
}
}
}
// If there are cycles, add remaining types in alphabetical order
if (result.Count < types.Count)
{
var remaining = types.Where(t => !result.Contains(t)).OrderBy(t => t.Name);
result.AddRange(remaining);
}
return result;
}
/// <summary>
/// Get all type dependencies for a given type (types referenced in properties).
/// </summary>
private static HashSet<Type> GetTypeDependencies(Type type, List<Type> availableTypes)
{
var dependencies = new HashSet<Type>();
if (type.IsEnum)
{
// Enums have no dependencies
return dependencies;
}
var metadata = GetTypeMetadata(type);
foreach (var prop in metadata.Properties)
{
var propType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
// Check if this property type is in our available types
if (availableTypes.Contains(propType) && propType != type)
{
dependencies.Add(propType);
}
// Check for collection element types
if (typeof(IEnumerable).IsAssignableFrom(propType) && propType != typeof(string))
{
var elementType = GetCollectionElementType(propType);
if (elementType != null && availableTypes.Contains(elementType) && elementType != type)
{
dependencies.Add(elementType);
}
}
// Check for dictionary key/value types
if (IsDictionaryType(propType, out var keyType, out var valueType))
{
if (keyType != null && availableTypes.Contains(keyType) && keyType != type)
{
dependencies.Add(keyType);
}
if (valueType != null && availableTypes.Contains(valueType) && valueType != type)
{
dependencies.Add(valueType);
}
}
}
return dependencies;
}
}

View File

@ -0,0 +1,299 @@
using System.Reflection;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Write type definition with property descriptions.
/// </summary>
private static void WriteTypeDefinition(Type type, ToonSerializationContext context)
{
// Handle enum types specially
if (type.IsEnum)
{
WriteEnumTypeDefinition(type, context);
return;
}
var metadata = GetTypeMetadata(type);
// Type description with fallback chain and placeholder resolution
var typeDescription = GetFinalTypeDescription(type, metadata);
context.WriteIndentedLine($"{type.Name}: \"{typeDescription}\"");
// Növeljük az indent-et a type body-hoz (type-level metadata ÉS property-k)
context.CurrentIndentLevel++;
// Type-level metadata (enhanced mode only)
if (context.Options.UseEnhancedMetadata)
{
// Table name
var tableName = DetectTableName(type, metadata.CustomDescription);
if (!string.IsNullOrEmpty(tableName))
{
context.WriteIndentedLine($"table-name: \"{tableName}\"");
}
// Related type
var typeRelation = metadata.CustomDescription?.TypeRelation;
var relatedTypes = metadata.CustomDescription?.RelatedTypes;
if (!string.IsNullOrEmpty(typeRelation) && relatedTypes != null && relatedTypes.Length > 0)
{
var relatedTypeStr = FormatRelatedType(typeRelation, relatedTypes);
if (!string.IsNullOrEmpty(relatedTypeStr))
{
context.WriteIndentedLine($"related-type: \"{relatedTypeStr}\"");
}
}
// Purpose
var typePurpose = GetFinalTypePurpose(type, metadata);
if (!string.IsNullOrEmpty(typePurpose))
{
context.WriteIndentedLine($"purpose: \"{typePurpose}\"");
}
}
if (metadata.Properties.Length == 0)
{
context.CurrentIndentLevel--;
return;
}
// Property descriptions (már indent szint 1-en vagyunk, property-k is ide kerülnek)
foreach (var prop in metadata.Properties)
{
// Get final values with fallback chain + placeholder resolution
var propDescription = GetFinalPropertyDescription(prop, type);
var purpose = GetFinalPropertyPurpose(prop, type);
var constraints = GetFinalPropertyConstraints(prop, type);
var examples = GetFinalPropertyExamples(prop);
// Use cached navigation metadata from property accessor
var nav = prop.Navigation;
var typeHint = prop.TypeDisplayName;
if (context.Options.UseEnhancedMetadata)
{
// Enhanced format with constraints and purpose
context.WriteIndentedLine($"{prop.Name}: {typeHint}");
context.CurrentIndentLevel++;
// Only write description if explicitly provided (not null/empty)
if (!string.IsNullOrEmpty(propDescription))
context.WriteIndentedLine($"description: \"{propDescription}\"");
if (!string.IsNullOrEmpty(purpose))
context.WriteIndentedLine($"purpose: \"{purpose}\"");
if (!string.IsNullOrEmpty(constraints))
context.WriteIndentedLine($"constraints: \"{constraints}\"");
if (!string.IsNullOrEmpty(examples))
context.WriteIndentedLine($"examples: \"{examples}\"");
// Add relationship metadata from cached navigation info
if (nav.IsPrimaryKey)
context.WriteIndentedLine($"primary-key: true");
// Only write foreign-key for navigation properties, not for FK properties themselves
if (!string.IsNullOrEmpty(nav.ForeignKey) && nav.NavigationType.HasValue)
context.WriteIndentedLine($"foreign-key: \"{nav.ForeignKey}\"");
if (!string.IsNullOrEmpty(nav.OtherKey))
context.WriteIndentedLine($"other-key: \"{nav.OtherKey}\"");
if (nav.NavigationType.HasValue)
{
var navType = nav.NavigationType.Value.ToString();
var navTypeFormatted = string.Concat(navType.Select((c, i) => i > 0 && char.IsUpper(c) ? "-" + char.ToLower(c) : char.ToLower(c).ToString()));
context.WriteIndentedLine($"navigation: \"{navTypeFormatted}\"");
}
if (!string.IsNullOrEmpty(nav.InverseProperty))
context.WriteIndentedLine($"inverse-property: \"{nav.InverseProperty}\"");
context.CurrentIndentLevel--;
}
else
{
// Simple format
var relationshipHint = GetRelationshipHint(nav);
// Only write description if there's explicit description OR relationship metadata
if (!string.IsNullOrEmpty(propDescription) || !string.IsNullOrEmpty(relationshipHint))
{
var fullDescription = string.IsNullOrEmpty(relationshipHint)
? propDescription
: string.IsNullOrEmpty(propDescription)
? relationshipHint
: $"{propDescription} ({relationshipHint})";
context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{fullDescription}\"");
}
else
{
// No description and no relationship metadata - just write type hint
context.WriteIndentedLine($"{prop.Name}: {typeHint}");
}
}
}
context.CurrentIndentLevel--;
// Token optimization: skip empty line when indentation is disabled
if (context.Options.UseIndentation)
{
context.WriteLine();
}
}
/// <summary>
/// Write enum type definition with values and metadata.
/// </summary>
private static void WriteEnumTypeDefinition(Type enumType, ToonSerializationContext context)
{
var customDescription = enumType.GetCustomAttribute<ToonDescriptionAttribute>();
// Type description with fallback
var typeDescription = GetFinalEnumDescription(enumType, customDescription);
context.WriteIndentedLine($"{enumType.Name}: enum");
if (context.Options.UseEnhancedMetadata)
{
context.CurrentIndentLevel++;
// Description
if (!string.IsNullOrEmpty(typeDescription))
{
context.WriteIndentedLine($"description: \"{typeDescription}\"");
}
// Purpose
var typePurpose = customDescription?.Purpose;
if (!string.IsNullOrEmpty(typePurpose))
{
context.WriteIndentedLine($"purpose: \"{typePurpose}\"");
}
// Underlying type
var underlyingType = Enum.GetUnderlyingType(enumType);
var underlyingTypeName = AcSerializerCommon.GetCSharpTypeName(underlyingType, useShortNames: true);
context.WriteIndentedLine($"underlying-type: \"{underlyingTypeName}\"");
// Default value (first enum member)
var enumValues = Enum.GetValues(enumType);
if (enumValues.Length > 0)
{
var firstValue = enumValues.GetValue(0);
var firstValueNumeric = Convert.ChangeType(firstValue, underlyingType);
context.WriteIndentedLine($"default-value: {firstValueNumeric}");
}
// Enum members with descriptions
context.WriteIndentedLine("values:");
context.CurrentIndentLevel++;
var names = Enum.GetNames(enumType);
foreach (var name in names)
{
var field = enumType.GetField(name);
var value = field?.GetValue(null);
var numericValue = Convert.ChangeType(value, underlyingType);
// Get member description from ToonDescription attribute
var memberDescription = field?.GetCustomAttribute<ToonDescriptionAttribute>();
var description = memberDescription?.Description;
// Token optimization: format depends on indentation setting
var separator = context.Options.UseIndentation ? " = " : "=";
if (!string.IsNullOrEmpty(description))
{
context.WriteIndentedLine($"{name}{separator}{numericValue}");
context.CurrentIndentLevel++;
context.WriteIndentedLine($"description: \"{description}\"");
context.CurrentIndentLevel--;
}
else
{
context.WriteIndentedLine($"{name}{separator}{numericValue}");
}
}
context.CurrentIndentLevel--;
context.CurrentIndentLevel--;
}
else
{
// Simple format - just show values
var names = Enum.GetNames(enumType);
var underlyingType = Enum.GetUnderlyingType(enumType);
context.CurrentIndentLevel++;
// Token optimization: format depends on indentation setting
var separator = context.Options.UseIndentation ? " = " : "=";
foreach (var name in names)
{
var field = enumType.GetField(name);
var value = field?.GetValue(null);
var numericValue = Convert.ChangeType(value, underlyingType);
context.WriteIndentedLine($"{name}{separator}{numericValue}");
}
context.CurrentIndentLevel--;
}
// Token optimization: skip empty line when indentation is disabled
if (context.Options.UseIndentation)
{
context.WriteLine();
}
}
/// <summary>
/// Get relationship hint string for simple format output.
/// </summary>
private static string GetRelationshipHint(AcNavigationPropertyInfo nav)
{
var hints = new List<string>();
if (nav.IsPrimaryKey)
hints.Add("primary-key");
if (!string.IsNullOrEmpty(nav.ForeignKey))
hints.Add($"fk->{nav.ForeignKey}");
if (!string.IsNullOrEmpty(nav.OtherKey))
hints.Add($"other-key:{nav.OtherKey}");
if (nav.NavigationType.HasValue)
{
var navType = nav.NavigationType.Value switch
{
ToonRelationType.ManyToOne => "many-to-one",
ToonRelationType.OneToMany => "one-to-many",
ToonRelationType.OneToOne => "one-to-one",
ToonRelationType.ManyToMany => "many-to-many",
_ => null
};
if (navType != null)
hints.Add(navType);
}
if (!string.IsNullOrEmpty(nav.InverseProperty))
hints.Add($"inverse:{nav.InverseProperty}");
return hints.Count > 0 ? string.Join(", ", hints) : string.Empty;
}
/// <summary>
/// Format related type metadata for output.
/// Example: "dto-of Order, OrderModel"
/// </summary>
private static string FormatRelatedType(string typeRelation, Type[] relatedTypes)
{
if (string.IsNullOrEmpty(typeRelation) || relatedTypes == null || relatedTypes.Length == 0)
return string.Empty;
var typeNames = string.Join(", ", relatedTypes.Select(t => t.Name));
return $"{typeRelation} {typeNames}";
}
}

View File

@ -0,0 +1,72 @@
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Convention: Property named "Id" or "{TypeName}Id" is a primary key.
/// </summary>
private static bool IsConventionPrimaryKey(PropertyInfo property)
{
if (property.PropertyType != typeof(int) &&
property.PropertyType != typeof(long) &&
property.PropertyType != typeof(Guid))
{
return false;
}
return property.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
property.Name.Equals(property.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Check if a type has a primary key property.
/// First checks for IsPrimaryKey=true in ToonDescription, then falls back to "Id" property.
/// </summary>
private static bool HasPrimaryKeyProperty(Type type)
{
if (IsPrimitiveOrStringFast(type))
{
return false;
}
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// First: check for ToonDescription with IsPrimaryKey = true
foreach (var prop in properties)
{
var customDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>();
if (customDescription?.IsPrimaryKey == true)
{
return true;
}
}
// Fallback: check for "Id" or "{TypeName}Id" property
foreach (var prop in properties)
{
if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase))
{
// Must be a value type (int, long, Guid, etc.)
if (prop.PropertyType.IsValueType)
{
return true;
}
}
}
return false;
}
/// <summary>
/// Check if two types match (handles different Type instances from different assemblies).
/// </summary>
private static bool IsTypeMatch(Type? type1, Type? type2)
{
if (type1 == null || type2 == null) return false;
return type1 == type2 || type2.IsAssignableFrom(type1) || type1.FullName == type2.FullName;
}
}

View File

@ -219,10 +219,11 @@ public enum ToonRelationType
public sealed class ToonDescriptionAttribute : Attribute public sealed class ToonDescriptionAttribute : Attribute
{ {
/// <summary> /// <summary>
/// Gets the human-readable description of the property or type. /// Gets or sets the human-readable description of the property or type.
/// This appears in the @types section to help LLMs understand the data structure. /// This appears in the @types section to help LLMs understand the data structure.
/// If not specified, fallback chain will be used (Microsoft [Description] -> Smart inference).
/// </summary> /// </summary>
public string Description { get; } public string? Description { get; set; }
/// <summary> /// <summary>
/// Gets or sets the purpose of this property (what it's used for). /// Gets or sets the purpose of this property (what it's used for).
@ -267,6 +268,19 @@ public sealed class ToonDescriptionAttribute : Attribute
/// </summary> /// </summary>
public string? InverseProperty { get; set; } public string? InverseProperty { get; set; }
/// <summary>
/// Gets or sets the business rule or validation logic for this property.
/// Uses a pseudo-code expression format that is both LLM-readable and developer-friendly.
/// The expression uses "this" to refer to the current property value.
/// Examples:
/// - "this >= NetWeight" (comparison with other property)
/// - "this > 0" (simple constraint)
/// - "this.Length <= 100" (property constraint)
/// - "this != null && this.Count > 0" (compound rule)
/// Note: This is descriptive only, not executed. Actual validation should use FluentValidation or similar.
/// </summary>
public string? BusinessRule { get; set; }
/// <summary> /// <summary>
/// Gets or sets the database table name for this entity (class-level only). /// Gets or sets the database table name for this entity (class-level only).
/// If not explicitly set, will fallback to EF Core [Table] or Linq2Db [Table] attributes, then to class name. /// If not explicitly set, will fallback to EF Core [Table] or Linq2Db [Table] attributes, then to class name.
@ -274,13 +288,35 @@ public sealed class ToonDescriptionAttribute : Attribute
/// </summary> /// </summary>
public string? TableName { get; set; } public string? TableName { get; set; }
/// <summary>
/// Gets or sets the type relationship string (class-level only).
/// Use constants from ToonTypeRelation class (e.g., ToonTypeRelation.DtoOf).
/// Example: TypeRelation = ToonTypeRelation.DtoOf
/// </summary>
public string? TypeRelation { get; set; }
/// <summary>
/// Gets or sets the related types for the type relationship (class-level only).
/// Used together with TypeRelation to declare type relationships.
/// Example: RelatedTypes = new[] { typeof(Order), typeof(OrderModel) }
/// </summary>
public Type[]? RelatedTypes { get; set; }
/// <summary>
/// Initializes a new instance of the ToonDescriptionAttribute.
/// All properties are optional and can be set using property initializers.
/// </summary>
public ToonDescriptionAttribute()
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the ToonDescriptionAttribute with the specified description. /// Initializes a new instance of the ToonDescriptionAttribute with the specified description.
/// </summary> /// </summary>
/// <param name="description">Human-readable description of the property or type.</param> /// <param name="description">Human-readable description of the property or type.</param>
public ToonDescriptionAttribute(string description) public ToonDescriptionAttribute(string description)
{ {
Description = description ?? throw new ArgumentNullException(nameof(description)); Description = description;
} }
/// <summary> /// <summary>

View File

@ -0,0 +1,49 @@
namespace AyCode.Core.Serializers.Toons;
/// <summary>
/// Defines type relationship constants for Toon serialization.
/// These string values are directly used in the serialized output.
/// </summary>
public static class ToonTypeRelation
{
/// <summary>
/// No special type relationship.
/// </summary>
public const string None = "";
/// <summary>
/// This type is a base class/interface for other types.
/// Example: Order is base-of OrderDto
/// </summary>
public const string BaseOf = "base-of";
/// <summary>
/// This type is derived from another type.
/// Example: OrderDto is derived-from Order
/// </summary>
public const string DerivedFrom = "derived-from";
/// <summary>
/// This type is a Data Transfer Object representation of another type.
/// Example: OrderDto is dto-of Order
/// </summary>
public const string DtoOf = "dto-of";
/// <summary>
/// This type is a Model representation of another type.
/// Example: OrderModel is model-of Order
/// </summary>
public const string ModelOf = "model-of";
/// <summary>
/// This type is a ViewModel representation of another type.
/// Example: OrderViewModel is view-model-of Order
/// </summary>
public const string ViewModelOf = "view-model-of";
/// <summary>
/// This type is a projection/subset of another type.
/// Example: OrderSummary is projection-of Order
/// </summary>
public const string ProjectionOf = "projection-of";
}

View File

@ -1,6 +1,10 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Helpers;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers; namespace AyCode.Core.Serializers;
@ -32,16 +36,20 @@ public abstract class TypeMetadataBase
/// Key: (Type, requiresWrite) - since requiresRead is always true in practice. /// Key: (Type, requiresWrite) - since requiresRead is always true in practice.
/// Value: PropertyInfo[] ordered alphabetically by name for deterministic serialization. /// Value: PropertyInfo[] ordered alphabetically by name for deterministic serialization.
/// </summary> /// </summary>
private static readonly ConcurrentDictionary<(Type, bool), PropertyInfo[]> OrderedPropertiesCache = new(); private static readonly ConcurrentDictionary<(Type, bool), List<PropertyInfo>> UnfilteredPropertiesGlobalCache = new();
private readonly ConcurrentDictionary<(Type, bool), PropertyInfo[]> _orderedPropertiesCache = new();
private readonly Func<PropertyInfo, bool> _ignorePropertyFilter;
/// <summary> /// <summary>
/// Compiled parameterless constructor for the type. /// Compiled parameterless constructor for the type.
/// Null if the type is abstract or has no parameterless constructor. /// Null if the type is abstract or has no parameterless constructor.
/// </summary> /// </summary>
public Func<object>? CompiledConstructor { get; } public Func<object>? CompiledConstructor { get; }
protected TypeMetadataBase(Type type) protected TypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter)
{ {
_ignorePropertyFilter = ignorePropertyFilter;
CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type); CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type);
} }
@ -56,15 +64,19 @@ public abstract class TypeMetadataBase
/// Results are cached per type and requiresWrite combination. /// Results are cached per type and requiresWrite combination.
/// </summary> /// </summary>
/// <param name="type">The type to analyze.</param> /// <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> /// <param name="requiresWrite">Whether the property must be writable (true for deserialization).</param>
/// <returns>Array of properties with stable indices (cached).</returns> /// <returns>Array of properties with stable indices (cached).</returns>
public static PropertyInfo[] GetSerializableProperties( public PropertyInfo[] GetSerializableProperties(Type type, bool requiresWrite = false)
Type type,
bool requiresRead = true,
bool requiresWrite = false)
{ {
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key => return _orderedPropertiesCache.GetOrAdd((type, requiresWrite), _ =>
{
return GetUnfilteredProperties(type, requiresWrite).Where(propertyInfo => !_ignorePropertyFilter(propertyInfo)).ToArray();
});
}
private static List<PropertyInfo> GetUnfilteredProperties(Type type, bool requiresWrite)
{
return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key =>
{ {
var (t, needsWrite) = key; var (t, needsWrite) = key;
@ -78,15 +90,15 @@ public abstract class TypeMetadataBase
var levelProperties = currentType var levelProperties = currentType
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.CanRead && .Where(p => p.CanRead &&
(!needsWrite || p.CanWrite) && (!needsWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 && p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p)) !IsUnsupportedPropertyType(p.PropertyType))
.OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level .OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level
allProperties.AddRange(levelProperties); allProperties.AddRange(levelProperties);
} }
return allProperties.ToArray(); return allProperties;
}); });
} }
} }
@ -97,8 +109,7 @@ public abstract class TypeMetadataBase
/// Each TMetadata type gets its own ThreadStatic cache instance automatically. /// Each TMetadata type gets its own ThreadStatic cache instance automatically.
/// </summary> /// </summary>
/// <typeparam name="TMetadata">The concrete metadata type (must inherit from this class).</typeparam> /// <typeparam name="TMetadata">The concrete metadata type (must inherit from this class).</typeparam>
public abstract class TypeMetadataBase<TMetadata> : TypeMetadataBase public abstract class TypeMetadataBase<TMetadata> : TypeMetadataBase where TMetadata : TypeMetadataBase<TMetadata>
where TMetadata : TypeMetadataBase<TMetadata>
{ {
/// <summary> /// <summary>
/// ThreadLocal cache for this specific metadata type. /// ThreadLocal cache for this specific metadata type.
@ -107,7 +118,7 @@ public abstract class TypeMetadataBase<TMetadata> : TypeMetadataBase
[ThreadStatic] [ThreadStatic]
private static Dictionary<Type, TMetadata>? t_localCache; private static Dictionary<Type, TMetadata>? t_localCache;
protected TypeMetadataBase(Type type) : base(type) protected TypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) : base(type, ignorePropertyFilter)
{ {
} }