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 _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(); - // Compile getter for fast access - _getter = CreateCompiledGetter(prop.DeclaringType!, prop); + // Detect and cache navigation metadata once + Navigation = DetectNavigationMetadata(prop, CustomDescription); } - private static Func 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>(boxed, objParam).Compile(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public object? GetValue(object obj) => _getter(obj); - /// /// Checks if value is default/null without boxing value types. /// @@ -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? t_visitedTypes; - /// - /// 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. /// 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; - } - - /// - /// Get complex type name WITH circular reference protection using thread-local visited set. - /// - private static string GetComplexTypeName(Type type) - { - // Initialize thread-local visited set if needed - t_visitedTypes ??= new HashSet(); - - // 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); } } } diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.cs index 5913d41..2400e21 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.cs @@ -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; /// 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 }