diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 88c7860..ec449c7 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -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:*)"
]
}
}
diff --git a/AyCode.Core/Serializers/AcSerializerCommon.cs b/AyCode.Core/Serializers/AcSerializerCommon.cs
index 9d64407..70b37fc 100644
--- a/AyCode.Core/Serializers/AcSerializerCommon.cs
+++ b/AyCode.Core/Serializers/AcSerializerCommon.cs
@@ -1006,4 +1006,102 @@ public static class AcSerializerCommon
}
#endregion
+
+ #region Type Name Formatting
+
+ ///
+ /// Gets the C# display name for a type (e.g., "int", "List<string>", "Dictionary<int, string>").
+ /// Handles primitives, nullables, generics, enums, collections, and dictionaries.
+ /// Thread-safe with circular reference protection.
+ ///
+ /// The type to get the name for.
+ /// If true, uses C# keywords (int, string). If false, uses full names (Int32, String).
+ /// C# display name of the type.
+ 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
}
\ No newline at end of file
diff --git a/AyCode.Core/Serializers/Toons/AcNavigationPropertyInfo.cs b/AyCode.Core/Serializers/Toons/AcNavigationPropertyInfo.cs
new file mode 100644
index 0000000..93775d8
--- /dev/null
+++ b/AyCode.Core/Serializers/Toons/AcNavigationPropertyInfo.cs
@@ -0,0 +1,71 @@
+using System;
+
+namespace AyCode.Core.Serializers.Toons;
+
+///
+/// Cached navigation property metadata for efficient relationship information access.
+/// Contains all relationship details detected once during type metadata construction.
+///
+public sealed class AcNavigationPropertyInfo
+{
+ ///
+ /// Whether this property is a primary key.
+ ///
+ public bool IsPrimaryKey { get; }
+
+ ///
+ /// Type of navigation relationship (ManyToOne, OneToMany, etc.).
+ /// Null if this is not a navigation property.
+ ///
+ public ToonRelationType? NavigationType { get; }
+
+ ///
+ /// 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).
+ ///
+ public string? ForeignKey { get; }
+
+ ///
+ /// 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.
+ ///
+ public string? OtherKey { get; }
+
+ ///
+ /// 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).
+ ///
+ public string? InverseProperty { get; }
+
+ ///
+ /// Target entity type for navigation properties.
+ /// For collections (OneToMany), this is the element type.
+ /// For single references (ManyToOne), this is the property type.
+ ///
+ 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;
+ }
+
+ ///
+ /// Empty navigation info (not a navigation property, not a primary key).
+ ///
+ public static readonly AcNavigationPropertyInfo Empty = new();
+}
diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.AttributeExtraction.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.AttributeExtraction.cs
index 088ec0a..d349cea 100644
--- a/AyCode.Core/Serializers/Toons/AcToonSerializer.AttributeExtraction.cs
+++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.AttributeExtraction.cs
@@ -14,8 +14,8 @@ public static partial class AcToonSerializer
{
var constraints = new List();
- 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;
}
diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs
index 69bbf51..913adc0 100644
--- a/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs
+++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs
@@ -326,30 +326,12 @@ public static partial class AcToonSerializer
}
///
- /// 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.
///
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);
}
///
diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs
index 75d8348..7cc94b8 100644
--- a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs
+++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs
@@ -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
///
/// 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.)
///
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;
}
///
@@ -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
///
/// Get relationship hint string for simple format output.
///
- private static string GetRelationshipHint(RelationshipMetadata metadata)
+ private static string GetRelationshipHint(AcNavigationPropertyInfo nav)
{
var hints = new List();
- 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;
}
+
+ ///
+ /// Topological sort for types: dependencies appear before dependents.
+ /// Handles circular references by ignoring back-edges.
+ ///
+ private static List TopologicalSortTypes(List types)
+ {
+ // Build dependency graph
+ var graph = new Dictionary>();
+ var inDegree = new Dictionary();
+
+ foreach (var type in types)
+ {
+ if (!graph.ContainsKey(type))
+ {
+ graph[type] = new HashSet();
+ 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();
+ var result = new List();
+
+ // 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;
+ }
+
+ ///
+ /// Get all type dependencies for a given type (types referenced in properties).
+ ///
+ private static HashSet GetTypeDependencies(Type type, List availableTypes)
+ {
+ var dependencies = new HashSet();
+
+ 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;
+ }
}
diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs
index c8f1982..ce7af4b 100644
--- a/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs
+++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs
@@ -7,89 +7,114 @@ namespace AyCode.Core.Serializers.Toons;
public static partial class AcToonSerializer
{
///
- /// Relationship metadata for a property.
- ///
- private sealed class RelationshipMetadata
- {
- public bool IsPrimaryKey { get; set; }
- public string? ForeignKeyNavigationProperty { get; set; }
- public ToonRelationType? NavigationType { get; set; }
- public string? InverseProperty { get; set; }
- }
-
- ///
- /// 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.
///
- 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);
}
///
@@ -432,4 +457,186 @@ public static partial class AcToonSerializer
}
#endregion
-}
+
+ #region Convention-based Inverse Property Detection
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Check if two types match (handles different Type instances from different assemblies).
+ ///
+ 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;
+ }
+
+ ///
+ /// Detect the foreign key property name on the other side of the relationship (OtherKey).
+ /// For OneToMany: Order.OrderItems → OrderItem has "OrderId" FK.
+ ///
+ 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;
+ }
+
+ ///
+ /// Detect FK property name for a navigation property.
+ /// Example: Customer navigation → CustomerId FK.
+ /// Also tries target type name: ShippingDocumentFile (type: Files) → FilesId FK.
+ ///
+ 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;
+ }
+
+ ///
+ /// Find FK property by name using direct PropertyInfo array (not filtered by GetSerializableProperties).
+ ///
+ 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
+}
\ No newline at end of file
diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs
index c6f365a..d66ad95 100644
--- a/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs
+++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs
@@ -10,8 +10,9 @@ public static partial class AcToonSerializer
{
///
/// Cached metadata for a type including properties, type name, and descriptions.
+ /// Uses TypeMetadataBase infrastructure for shared caching across all serializers.
///
- private sealed class ToonTypeMetadata
+ private sealed class ToonTypeMetadata : TypeMetadataBase
{
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();
- // 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
}
///
- /// 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.
///
- 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
public static partial class AcToonSerializer
{
- private static readonly ConcurrentDictionary TypeMetadataCache = new();
-
///
/// 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
+ ///
+ /// Gets or creates ToonTypeMetadata using TypeMetadataBase infrastructure.
+ /// This uses the shared GlobalMetadataCache and ThreadLocal cache for optimal performance.
+ ///
[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
}