Refactor Toon serializer: robust navigation/type metadata

- Introduce AcNavigationPropertyInfo for unified, cached relationship metadata per property (primary key, navigation type, FK, other key, inverse, target type)
- Refactor relationship detection to use AcNavigationPropertyInfo, replacing ad-hoc logic and old RelationshipMetadata
- Add AcSerializerCommon.GetCSharpTypeName for consistent, C#-style type name formatting (handles primitives, generics, nullables, enums, collections)
- Use topological sort for @types output to ensure dependency order
- Improve enum handling: avoid redundant constraints, use new type name formatter for underlying types
- Output navigation, foreign-key, other-key, and inverse-property metadata consistently in meta section
- Enhance convention-based detection for inverse properties and "other key", including unidirectional/polymorphic support
- Add comprehensive test for navigation metadata completeness and demo test entities
- Add "source-code-language: C#" to meta section
- Misc: code cleanup, remove unused cache, improve property filtering
This commit is contained in:
Loretta 2026-01-14 08:00:32 +01:00
parent 9312298032
commit 93d38d427f
9 changed files with 657 additions and 247 deletions

View File

@ -26,7 +26,8 @@
"Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -3\")",
"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(dotnet exec vstest:*)"
"Bash(dotnet exec vstest:*)",
"Bash(dotnet new:*)"
]
}
}

View File

@ -1006,4 +1006,102 @@ public static class AcSerializerCommon
}
#endregion
#region Type Name Formatting
/// <summary>
/// Gets the C# display name for a type (e.g., "int", "List&lt;string&gt;", "Dictionary&lt;int, string&gt;").
/// Handles primitives, nullables, generics, enums, collections, and dictionaries.
/// Thread-safe with circular reference protection.
/// </summary>
/// <param name="type">The type to get the name for.</param>
/// <param name="useShortNames">If true, uses C# keywords (int, string). If false, uses full names (Int32, String).</param>
/// <returns>C# display name of the type.</returns>
public static string GetCSharpTypeName(Type type, bool useShortNames = true)
{
// Handle nullable types
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
{
return GetCSharpTypeName(underlying, useShortNames) + "?";
}
// Handle arrays
if (type.IsArray)
{
var elementType = type.GetElementType()!;
return GetCSharpTypeName(elementType, useShortNames) + "[]";
}
// IMPORTANT: Check for enum BEFORE primitive type check!
// Enum TypeCode returns the underlying type's code (e.g., Int32),
// which would incorrectly return "int" instead of the enum name.
if (type.IsEnum)
{
return type.Name;
}
// Handle primitive types with C# keywords
if (useShortNames)
{
var typeCode = Type.GetTypeCode(type);
var primitiveName = typeCode switch
{
TypeCode.Boolean => "bool",
TypeCode.Byte => "byte",
TypeCode.SByte => "sbyte",
TypeCode.Char => "char",
TypeCode.Int16 => "short",
TypeCode.UInt16 => "ushort",
TypeCode.Int32 => "int",
TypeCode.UInt32 => "uint",
TypeCode.Int64 => "long",
TypeCode.UInt64 => "ulong",
TypeCode.Single => "float",
TypeCode.Double => "double",
TypeCode.Decimal => "decimal",
TypeCode.String => "string",
_ => null
};
if (primitiveName != null) return primitiveName;
}
// Handle common BCL types
if (type == typeof(object)) return "object";
if (type == typeof(void)) return "void";
// Handle generic types
if (type.IsGenericType)
{
var genericDef = type.GetGenericTypeDefinition();
var args = type.GetGenericArguments();
// Special handling for common generic types
if (genericDef == typeof(List<>))
return $"List<{GetCSharpTypeName(args[0], useShortNames)}>";
if (genericDef == typeof(Dictionary<,>))
return $"Dictionary<{GetCSharpTypeName(args[0], useShortNames)}, {GetCSharpTypeName(args[1], useShortNames)}>";
if (genericDef == typeof(IEnumerable<>))
return $"IEnumerable<{GetCSharpTypeName(args[0], useShortNames)}>";
if (genericDef == typeof(ICollection<>))
return $"ICollection<{GetCSharpTypeName(args[0], useShortNames)}>";
if (genericDef == typeof(IList<>))
return $"IList<{GetCSharpTypeName(args[0], useShortNames)}>";
// Generic type with name mangling (e.g., SomeType`2)
var baseName = type.Name;
var backtickIndex = baseName.IndexOf('`');
if (backtickIndex > 0)
{
baseName = baseName.Substring(0, backtickIndex);
}
var argNames = string.Join(", ", args.Select(a => GetCSharpTypeName(a, useShortNames)));
return $"{baseName}<{argNames}>";
}
// Simple type name (class, struct, enum, etc.)
return type.Name;
}
#endregion
}

View File

@ -0,0 +1,71 @@
using System;
namespace AyCode.Core.Serializers.Toons;
/// <summary>
/// Cached navigation property metadata for efficient relationship information access.
/// Contains all relationship details detected once during type metadata construction.
/// </summary>
public sealed class AcNavigationPropertyInfo
{
/// <summary>
/// Whether this property is a primary key.
/// </summary>
public bool IsPrimaryKey { get; }
/// <summary>
/// Type of navigation relationship (ManyToOne, OneToMany, etc.).
/// Null if this is not a navigation property.
/// </summary>
public ToonRelationType? NavigationType { get; }
/// <summary>
/// Foreign key property name on the current side.
/// For ManyToOne/OneToOne: the FK property in this entity (e.g., "CustomerId").
/// For OneToMany: null (FK is on the other side, see OtherKey).
/// </summary>
public string? ForeignKey { get; }
/// <summary>
/// Foreign key property name on the other side of the relationship.
/// For OneToMany: the FK property in the related entity (e.g., "OrderId" in OrderItem).
/// For ManyToOne: null (FK is on this side, see ForeignKey).
/// This corresponds to Linq2Db's "OtherKey" concept.
/// </summary>
public string? OtherKey { get; }
/// <summary>
/// Navigation property name on the other side of the relationship.
/// For OneToMany: the navigation property in the related entity pointing back (e.g., "Order" in OrderItem).
/// For ManyToOne: the collection property in the related entity (e.g., "OrderItems" in Order).
/// </summary>
public string? InverseProperty { get; }
/// <summary>
/// Target entity type for navigation properties.
/// For collections (OneToMany), this is the element type.
/// For single references (ManyToOne), this is the property type.
/// </summary>
public Type? TargetType { get; }
public AcNavigationPropertyInfo(
bool isPrimaryKey = false,
ToonRelationType? navigationType = null,
string? foreignKey = null,
string? otherKey = null,
string? inverseProperty = null,
Type? targetType = null)
{
IsPrimaryKey = isPrimaryKey;
NavigationType = navigationType;
ForeignKey = foreignKey;
OtherKey = otherKey;
InverseProperty = inverseProperty;
TargetType = targetType;
}
/// <summary>
/// Empty navigation info (not a navigation property, not a primary key).
/// </summary>
public static readonly AcNavigationPropertyInfo Empty = new();
}

View File

@ -14,8 +14,8 @@ public static partial class AcToonSerializer
{
var constraints = new List<string>();
var underlyingType = Nullable.GetUnderlyingType(type);
var isNullable = underlyingType != null || !type.IsValueType;
var nullableUnderlying = Nullable.GetUnderlyingType(type);
var isNullable = nullableUnderlying != null || !type.IsValueType;
// Nullable/Required: NE írjuk ki automatikusan, mert redundáns (a típusból látszik)
// Csak explicit [Required] attribute esetén írjuk ki (azt az ExtractDataAnnotationConstraints kezeli)
@ -23,7 +23,17 @@ public static partial class AcToonSerializer
// Numeric/Boolean: NE írjuk ki, mert redundáns (a típusból látszik)
// Range-eket megtartjuk, mert azok hasznos extra információk
var baseType = underlyingType ?? type;
var baseType = nullableUnderlying ?? type;
// Enum property: NE írjunk automatic constraint-et!
// A típusból (pl. "TaxDisplayType: TaxDisplayType") már minden információ látszik.
// Az underlying type már az enum definíciójában megtalálható a @types szekcióban.
// Csak akkor kell constraint, ha explicit attribute-al van megjelölve.
if (baseType.IsEnum)
{
return string.Empty;
}
var typeCode = Type.GetTypeCode(baseType);
switch (typeCode)
{
@ -45,8 +55,6 @@ public static partial class AcToonSerializer
// Int32, Int64, UInt64, Float, Double, Decimal, Boolean: nincs constraint (redundáns lenne)
}
// Enum values: NE írjuk ki a constraint-ben, mert már a type metadata-ban benne van
return constraints.Count > 0 ? string.Join(", ", constraints) : string.Empty;
}

View File

@ -326,30 +326,12 @@ public static partial class AcToonSerializer
}
/// <summary>
/// Get type display name for a type (used in array hints).
/// Get C# type display name for a type (used in array hints).
/// Uses shared AcSerializerCommon.GetCSharpTypeName for consistency.
/// </summary>
private static string GetTypeDisplayName(Type type)
{
var typeCode = Type.GetTypeCode(type);
return typeCode switch
{
TypeCode.Int32 => "int32",
TypeCode.Int64 => "int64",
TypeCode.Double => "float64",
TypeCode.Single => "float32",
TypeCode.Decimal => "decimal",
TypeCode.Boolean => "bool",
TypeCode.String => "string",
TypeCode.DateTime => "datetime",
TypeCode.Byte => "byte",
TypeCode.Int16 => "int16",
TypeCode.UInt16 => "uint16",
TypeCode.UInt32 => "uint32",
TypeCode.UInt64 => "uint64",
TypeCode.SByte => "sbyte",
TypeCode.Char => "char",
_ => type.Name.ToLowerInvariant()
};
return AcSerializerCommon.GetCSharpTypeName(type, useShortNames: true);
}
/// <summary>

View File

@ -59,6 +59,7 @@ public static partial class AcToonSerializer
context.CurrentIndentLevel++;
context.WriteProperty("version", $"\"{FormatVersion}\"");
context.WriteProperty("format", "\"toon\"");
context.WriteProperty("source-code-language", "\"C#\"");
// Write type list
context.WriteIndent();
@ -82,9 +83,12 @@ public static partial class AcToonSerializer
.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 filteredTypes)
foreach (var t in sortedTypes)
{
if (!first) context.Write(", ");
context.Write($"\"{t.Name}\"");
@ -101,11 +105,11 @@ public static partial class AcToonSerializer
context.WriteLine();
}
// @types section with descriptions
// @types section with descriptions (same sorted order)
context.WriteLine("@types {");
context.CurrentIndentLevel++;
foreach (var t in filteredTypes)
foreach (var t in sortedTypes)
{
WriteTypeDefinition(t, context);
}
@ -195,14 +199,13 @@ public static partial class AcToonSerializer
/// <summary>
/// Megkeresi a property-hez tartozó enum típust, ha az egy enum backing field.
/// Visszaadja az enum típust, ha van, különben null.
/// 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)
{
// Csak int/int? típusú property-k lehetnek backing field-ek
if (backingFieldProperty.PropertyType != typeof(int) && backingFieldProperty.PropertyType != typeof(int?))
return null;
// 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;
@ -212,10 +215,36 @@ public static partial class AcToonSerializer
var enumProperty = allProperties.FirstOrDefault(p =>
p.Name == enumPropertyName &&
p.PropertyType.IsEnum);
(p.PropertyType.IsEnum || (Nullable.GetUnderlyingType(p.PropertyType)?.IsEnum ?? false)));
// Ha van enum property, visszaadjuk a típusát (függetlenül a NotMapped attribútumtól)
return enumProperty?.PropertyType;
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;
}
/// <summary>
@ -272,8 +301,8 @@ public static partial class AcToonSerializer
var constraints = GetFinalPropertyConstraints(prop, type);
var examples = GetFinalPropertyExamples(prop);
// Detect relationship metadata
var relationshipMetadata = DetectRelationshipMetadata(prop.PropertyInfo, prop.CustomDescription);
// Use cached navigation metadata from property accessor
var nav = prop.Navigation;
var typeHint = prop.TypeDisplayName;
@ -294,26 +323,31 @@ public static partial class AcToonSerializer
if (!string.IsNullOrEmpty(examples))
context.WriteIndentedLine($"examples: \"{examples}\"");
// Add relationship metadata
if (relationshipMetadata.IsPrimaryKey)
// Add relationship metadata from cached navigation info
if (nav.IsPrimaryKey)
context.WriteIndentedLine($"primary-key: true");
if (!string.IsNullOrEmpty(relationshipMetadata.ForeignKeyNavigationProperty))
context.WriteIndentedLine($"foreign-key: \"{relationshipMetadata.ForeignKeyNavigationProperty}\"");
if (relationshipMetadata.NavigationType.HasValue)
// 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 = relationshipMetadata.NavigationType.Value.ToString();
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(relationshipMetadata.InverseProperty))
context.WriteIndentedLine($"inverse-property: \"{relationshipMetadata.InverseProperty}\"");
if (!string.IsNullOrEmpty(nav.InverseProperty))
context.WriteIndentedLine($"inverse-property: \"{nav.InverseProperty}\"");
context.CurrentIndentLevel--;
}
else
{
// Simple format
var relationshipHint = GetRelationshipHint(relationshipMetadata);
var relationshipHint = GetRelationshipHint(nav);
// Only write description if there's explicit description OR relationship metadata
if (!string.IsNullOrEmpty(propDescription) || !string.IsNullOrEmpty(relationshipHint))
@ -371,18 +405,7 @@ public static partial class AcToonSerializer
// Underlying type
var underlyingType = Enum.GetUnderlyingType(enumType);
var underlyingTypeName = Type.GetTypeCode(underlyingType) switch
{
TypeCode.Byte => "byte",
TypeCode.SByte => "sbyte",
TypeCode.Int16 => "int16",
TypeCode.UInt16 => "uint16",
TypeCode.Int32 => "int32",
TypeCode.UInt32 => "uint32",
TypeCode.Int64 => "int64",
TypeCode.UInt64 => "uint64",
_ => underlyingType.Name.ToLower()
};
var underlyingTypeName = AcSerializerCommon.GetCSharpTypeName(underlyingType, useShortNames: true);
context.WriteIndentedLine($"underlying-type: \"{underlyingTypeName}\"");
// Default value (first enum member)
@ -871,19 +894,22 @@ public static partial class AcToonSerializer
/// <summary>
/// Get relationship hint string for simple format output.
/// </summary>
private static string GetRelationshipHint(RelationshipMetadata metadata)
private static string GetRelationshipHint(AcNavigationPropertyInfo nav)
{
var hints = new List<string>();
if (metadata.IsPrimaryKey)
if (nav.IsPrimaryKey)
hints.Add("primary-key");
if (!string.IsNullOrEmpty(metadata.ForeignKeyNavigationProperty))
hints.Add($"fk->{metadata.ForeignKeyNavigationProperty}");
if (!string.IsNullOrEmpty(nav.ForeignKey))
hints.Add($"fk->{nav.ForeignKey}");
if (metadata.NavigationType.HasValue)
if (!string.IsNullOrEmpty(nav.OtherKey))
hints.Add($"other-key:{nav.OtherKey}");
if (nav.NavigationType.HasValue)
{
var navType = metadata.NavigationType.Value switch
var navType = nav.NavigationType.Value switch
{
ToonRelationType.ManyToOne => "many-to-one",
ToonRelationType.OneToMany => "one-to-many",
@ -895,9 +921,136 @@ public static partial class AcToonSerializer
hints.Add(navType);
}
if (!string.IsNullOrEmpty(metadata.InverseProperty))
hints.Add($"inverse:{metadata.InverseProperty}");
if (!string.IsNullOrEmpty(nav.InverseProperty))
hints.Add($"inverse:{nav.InverseProperty}");
return hints.Count > 0 ? string.Join(", ", hints) : string.Empty;
}
/// <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

@ -7,89 +7,114 @@ namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
/// <summary>
/// Relationship metadata for a property.
/// </summary>
private sealed class RelationshipMetadata
{
public bool IsPrimaryKey { get; set; }
public string? ForeignKeyNavigationProperty { get; set; }
public ToonRelationType? NavigationType { get; set; }
public string? InverseProperty { get; set; }
}
/// <summary>
/// Detects relationship metadata for a property with fallback chain.
/// 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>
private static RelationshipMetadata DetectRelationshipMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription)
internal static AcNavigationPropertyInfo DetectNavigationMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription)
{
var metadata = new RelationshipMetadata();
// 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention
bool isPrimaryKey;
if (customDescription?.IsPrimaryKey.HasValue == true)
{
metadata.IsPrimaryKey = customDescription.IsPrimaryKey.Value;
isPrimaryKey = customDescription.IsPrimaryKey.Value;
}
else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property))
{
metadata.IsPrimaryKey = true;
isPrimaryKey = true;
}
else
{
metadata.IsPrimaryKey = IsConventionPrimaryKey(property);
isPrimaryKey = IsConventionPrimaryKey(property);
}
// 2. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention
if (!string.IsNullOrEmpty(customDescription?.ForeignKey))
{
metadata.ForeignKeyNavigationProperty = customDescription.ForeignKey;
}
else
{
var efCoreFk = TryGetEFCoreForeignKey(property);
if (!string.IsNullOrEmpty(efCoreFk))
{
metadata.ForeignKeyNavigationProperty = efCoreFk;
}
else
{
metadata.ForeignKeyNavigationProperty = DetectConventionForeignKey(property);
}
}
// 3. Navigation - Priority: ToonDescription -> EF Core [InverseProperty] -> Linq2Db [Association] -> Convention
// 2. Navigation type detection (needed early for FK detection logic)
ToonRelationType? navigationType = null;
if (customDescription?.Navigation.HasValue == true)
{
metadata.NavigationType = customDescription.Navigation.Value;
navigationType = customDescription.Navigation.Value;
}
else
{
var linq2dbNav = TryGetLinq2DbNavigation(property);
if (linq2dbNav.HasValue)
{
metadata.NavigationType = linq2dbNav.Value;
navigationType = linq2dbNav.Value;
}
else
{
metadata.NavigationType = DetectConventionNavigation(property);
navigationType = DetectConventionNavigation(property);
}
}
// 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty]
// 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))
{
metadata.InverseProperty = customDescription.InverseProperty;
inverseProperty = customDescription.InverseProperty;
}
else
{
var efCoreInverse = TryGetEFCoreInverseProperty(property);
if (!string.IsNullOrEmpty(efCoreInverse))
{
metadata.InverseProperty = efCoreInverse;
inverseProperty = efCoreInverse;
}
else
{
// Fallback: convention-based detection
inverseProperty = DetectConventionInverseProperty(property, navigationType);
}
}
return metadata;
// 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>
@ -432,4 +457,186 @@ public static partial class AcToonSerializer
}
#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

@ -10,8 +10,9 @@ public static partial class AcToonSerializer
{
/// <summary>
/// Cached metadata for a type including properties, type name, and descriptions.
/// Uses TypeMetadataBase infrastructure for shared caching across all serializers.
/// </summary>
private sealed class ToonTypeMetadata
private sealed class ToonTypeMetadata : TypeMetadataBase<ToonTypeMetadata>
{
public string TypeName { get; }
public string ShortTypeName { get; }
@ -21,7 +22,7 @@ public static partial class AcToonSerializer
public Type? ElementType { get; }
public ToonDescriptionAttribute? CustomDescription { get; }
public ToonTypeMetadata(Type type)
public ToonTypeMetadata(Type type) : base(type)
{
TypeName = type.FullName ?? type.Name;
ShortTypeName = type.Name;
@ -29,7 +30,7 @@ public static partial class AcToonSerializer
// Get custom attribute if present
CustomDescription = type.GetCustomAttribute<ToonDescriptionAttribute>();
// Check if collection or dictionary (matches AcBinarySerializer logic)
// Check if collection or dictionary (use shared JsonUtilities methods)
IsDictionary = IsDictionaryType(type, out _, out _);
if (!IsDictionary)
{
@ -41,14 +42,11 @@ public static partial class AcToonSerializer
}
}
// Build property accessors
// Build property accessors using shared GetSerializableProperties from base
if (!IsCollection && !IsDictionary && !IsPrimitiveOrStringFast(type))
{
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p))
.Select(p => new ToonPropertyAccessor(p))
var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: false)
.Select(p => new ToonPropertyAccessor(p, type))
.ToArray();
Properties = props;
@ -61,56 +59,31 @@ public static partial class AcToonSerializer
}
/// <summary>
/// Property accessor with compiled getter for performance.
/// Property accessor with compiled getter and cached navigation metadata.
/// Extends PropertyAccessorBase for shared functionality and adds Toon-specific metadata.
/// </summary>
private sealed class ToonPropertyAccessor
private sealed class ToonPropertyAccessor : PropertyAccessorBase
{
public string Name { get; }
public Type PropertyType { get; }
public PropertyInfo PropertyInfo { get; }
public TypeCode PropertyTypeCode { get; }
public string TypeDisplayName { get; }
public bool IsNullable { get; }
// Custom attribute metadata
public ToonDescriptionAttribute? CustomDescription { get; }
public AcNavigationPropertyInfo Navigation { get; }
private readonly Func<object, object?> _getter;
public ToonPropertyAccessor(PropertyInfo prop)
public ToonPropertyAccessor(PropertyInfo prop, Type declaringType)
: base(prop, declaringType)
{
Name = prop.Name;
PropertyType = prop.PropertyType;
PropertyInfo = prop;
var underlyingType = Nullable.GetUnderlyingType(PropertyType);
IsNullable = underlyingType != null;
var actualType = underlyingType ?? PropertyType;
PropertyTypeCode = Type.GetTypeCode(actualType);
// Build type display name for meta section
TypeDisplayName = BuildTypeDisplayName(PropertyType);
// Get custom attribute if present
CustomDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>();
// Compile getter for fast access
_getter = CreateCompiledGetter(prop.DeclaringType!, prop);
// Detect and cache navigation metadata once
Navigation = DetectNavigationMetadata(prop, CustomDescription);
}
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
{
var objParam = Expression.Parameter(typeof(object), "obj");
var castExpr = Expression.Convert(objParam, declaringType);
var propAccess = Expression.Property(castExpr, prop);
var boxed = Expression.Convert(propAccess, typeof(object));
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object? GetValue(object obj) => _getter(obj);
/// <summary>
/// Checks if value is default/null without boxing value types.
/// </summary>
@ -142,97 +115,13 @@ public static partial class AcToonSerializer
return false;
}
// Thread-local visited set to prevent circular reference stack overflow
[ThreadStatic]
private static HashSet<Type>? t_visitedTypes;
/// <summary>
/// Build human-readable type name for meta section.
/// Uses thread-local visited set to prevent stack overflow on circular references.
/// Build human-readable C# type name for meta section.
/// Uses shared AcSerializerCommon.GetCSharpTypeName for consistency.
/// </summary>
private static string BuildTypeDisplayName(Type type)
{
var underlying = Nullable.GetUnderlyingType(type);
var isNullable = underlying != null;
var actualType = underlying ?? type;
var baseName = Type.GetTypeCode(actualType) switch
{
TypeCode.Int32 => "int32",
TypeCode.Int64 => "int64",
TypeCode.Double => "float64",
TypeCode.Decimal => "decimal",
TypeCode.Single => "float32",
TypeCode.Boolean => "bool",
TypeCode.String => "string",
TypeCode.DateTime => "datetime",
TypeCode.Byte => "byte",
TypeCode.Int16 => "int16",
TypeCode.UInt16 => "uint16",
TypeCode.UInt32 => "uint32",
TypeCode.UInt64 => "uint64",
TypeCode.SByte => "sbyte",
TypeCode.Char => "char",
_ => GetComplexTypeName(actualType)
};
return isNullable ? baseName + "?" : baseName;
}
/// <summary>
/// Get complex type name WITH circular reference protection using thread-local visited set.
/// </summary>
private static string GetComplexTypeName(Type type)
{
// Initialize thread-local visited set if needed
t_visitedTypes ??= new HashSet<Type>();
// Check for circular reference - if already visiting, return simple name
if (!t_visitedTypes.Add(type))
{
return type.Name; // Break circular reference
}
try
{
if (ReferenceEquals(type, GuidType)) return "guid";
if (ReferenceEquals(type, DateTimeOffsetType)) return "datetimeoffset";
if (ReferenceEquals(type, TimeSpanType)) return "timespan";
if (type.IsEnum) return "enum";
// For collections: recursively build element type name (safe with visited tracking)
if (typeof(IEnumerable).IsAssignableFrom(type) && !ReferenceEquals(type, StringType))
{
var elementType = GetCollectionElementType(type);
if (elementType != null && elementType != typeof(object))
{
var elementTypeName = BuildTypeDisplayName(elementType);
return $"{elementTypeName}[]";
}
}
// For dictionaries: recursively build key/value type names (safe with visited tracking)
if (IsDictionaryType(type, out var keyType, out var valueType))
{
var keyTypeName = keyType != null ? BuildTypeDisplayName(keyType) : "object";
var valueTypeName = valueType != null ? BuildTypeDisplayName(valueType) : "object";
return $"dict<{keyTypeName}, {valueTypeName}>";
}
// Simple type name for complex objects
return type.Name;
}
finally
{
// Remove from visited set when done (allows reuse in different branches)
t_visitedTypes.Remove(type);
// Clear the set if empty to avoid memory leaks
if (t_visitedTypes.Count == 0)
{
t_visitedTypes = null;
}
}
return AcSerializerCommon.GetCSharpTypeName(type, useShortNames: true);
}
}
}

View File

@ -1,5 +1,4 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
@ -18,8 +17,6 @@ namespace AyCode.Core.Serializers.Toons;
/// </summary>
public static partial class AcToonSerializer
{
private static readonly ConcurrentDictionary<Type, ToonTypeMetadata> TypeMetadataCache = new();
/// <summary>
/// Format version for Toon serialization.
/// Incremented when breaking changes are made to format.
@ -323,9 +320,13 @@ public static partial class AcToonSerializer
#region Type Metadata
/// <summary>
/// Gets or creates ToonTypeMetadata using TypeMetadataBase infrastructure.
/// This uses the shared GlobalMetadataCache and ThreadLocal cache for optimal performance.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ToonTypeMetadata GetTypeMetadata(Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new ToonTypeMetadata(t));
=> ToonTypeMetadata.GetOrCreateMetadata(type, static t => new ToonTypeMetadata(t));
#endregion
}