diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ec449c7..2511057 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -27,7 +27,8 @@ "Bash(cmd /c \"dir /B /O-D H:\\Applications\\Aycode\\Source\\AyCode.Core\\Test_Benchmark_Results\\Benchmark\\*.log 2>nul | head -1\")", "Bash(timeout 30 dotnet run:*)", "Bash(dotnet exec vstest:*)", - "Bash(dotnet new:*)" + "Bash(dotnet new:*)", + "Bash(Remove-Item \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Core\\\\AyCode.Core\\\\Serializers\\\\Toons\\\\AcToonSerializer.RelationshipDetection.cs\")" ] } } diff --git a/AyCode.Core/Helpers/JsonUtilities.cs b/AyCode.Core/Helpers/JsonUtilities.cs index 33c164e..fe8fba5 100644 --- a/AyCode.Core/Helpers/JsonUtilities.cs +++ b/AyCode.Core/Helpers/JsonUtilities.cs @@ -525,6 +525,37 @@ public static class JsonUtilities Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute))); } + /// + /// Checks if property has ToonIgnore attribute. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasToonIgnoreAttribute(PropertyInfo prop) + { + //return JsonIgnoreCache.GetOrAdd(prop, static p => Attribute.IsDefined(p, typeof(ToonIgnoreAttribute))); + + return JsonIgnoreCache.GetOrAdd(prop, static p => + Attribute.IsDefined(p, typeof(JsonIgnoreAttribute)) || + Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute))); + } + + /// + /// Check if a property type is unsupported for serialization. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsUnsupportedPropertyType(Type type) + { + if (type.IsByRef || type.IsByRefLike) + return true; + + if (type.IsPointer) + return true; + + if (typeof(Type).IsAssignableFrom(type) || typeof(MemberInfo).IsAssignableFrom(type)) + return true; + + return false; + } + /// /// Checks if collection contains primitive elements. /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs index cd923c4..33d7cc2 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs @@ -1,8 +1,9 @@ using System.Reflection; using System.Runtime.CompilerServices; +using AyCode.Core.Helpers; using AyCode.Core.Serializers; using AyCode.Core.Serializers.Attributes; - +using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers.Binaries; public static partial class AcBinaryDeserializer @@ -28,9 +29,9 @@ public static partial class AcBinaryDeserializer /// public Type? GeneratedSerializerType { get; } - public BinaryDeserializeTypeMetadata(Type type) : base(type) + public BinaryDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute) { - var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); + var orderedProperties = GetSerializableProperties(type, requiresWrite: true); PropertiesArray = new BinaryPropertySetterInfo[orderedProperties.Length]; for (var i = 0; i < orderedProperties.Length; i++) diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs index 06d72c3..3069eec 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs @@ -11,10 +11,12 @@ public static partial class AcBinaryDeserializer /// /// Helper to get index mapping for cross-type operations. + /// Uses cached PropertyInfo arrays from TypeMetadataBase.GetSerializableProperties. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper) - => IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, DeserializeCrossTypeBase.GetOrderedProperties); + => IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, + type => GetTypeMetadata(type).GetSerializableProperties(type, requiresWrite: true)); #region Cross-Type Deserialization diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs index 31999b6..b59c323 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs @@ -23,9 +23,9 @@ public static partial class AcBinarySerializer /// public Type? GeneratedSerializerType { get; } - public BinaryTypeMetadata(Type type) : base(type) + public BinaryTypeMetadata(Type type, Func ignorePropertyFilter) : base(type,ignorePropertyFilter) { - var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); + var orderedProperties = GetSerializableProperties(type, requiresWrite: true); Properties = new BinaryPropertyAccessor[orderedProperties.Length]; for (var i = 0; i < orderedProperties.Length; i++) diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index ab1a0a9..fca55f2 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -1,4 +1,6 @@ -using System.Buffers; +using AyCode.Core.Helpers; +using AyCode.Core.Serializers.Expressions; +using System.Buffers; using System.Collections; using System.Collections.Concurrent; using System.Linq.Expressions; @@ -6,7 +8,6 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; -using AyCode.Core.Serializers.Expressions; using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers.Binaries; @@ -1239,7 +1240,7 @@ public static partial class AcBinarySerializer /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static BinaryTypeMetadata GetTypeMetadata(Type type) - => BinaryTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryTypeMetadata(t)); + => BinaryTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryTypeMetadata(t, HasJsonIgnoreAttribute)); // Type metadata helpers moved to AcBinarySerializer.BinaryTypeMetadata.cs diff --git a/AyCode.Core/Serializers/DeserializeCrossTypeBase.cs b/AyCode.Core/Serializers/DeserializeCrossTypeBase.cs index b1abb2b..88e9303 100644 --- a/AyCode.Core/Serializers/DeserializeCrossTypeBase.cs +++ b/AyCode.Core/Serializers/DeserializeCrossTypeBase.cs @@ -1,6 +1,5 @@ -using System.Reflection; +using AyCode.Core.Helpers; using AyCode.Core.Serializers.Jsons; -using static AyCode.Core.Serializers.TypeMetadataBase; namespace AyCode.Core.Serializers; @@ -8,14 +7,10 @@ namespace AyCode.Core.Serializers; /// Utility class providing common cross-type deserialization functionality. /// Shared by both JSON and Binary deserializers. /// -public static class DeserializeCrossTypeBase +public class DeserializeCrossTypeBase : TypeMetadataBase { - /// - /// Gets ordered properties for a type using stable PropertyIndex ordering. - /// Wrapper around TypeMetadataBase.GetSerializableProperties for consistency. - /// - public static PropertyInfo[] GetOrderedProperties(Type type) - => GetSerializableProperties(type, requiresRead: true, requiresWrite: true); + protected DeserializeCrossTypeBase(Type type) : base(type, JsonUtilities.HasJsonIgnoreAttribute) + {} /// /// Checks if two types are the same (fast path detection). diff --git a/AyCode.Core/Serializers/DeserializeTypeMetadataBase.cs b/AyCode.Core/Serializers/DeserializeTypeMetadataBase.cs index 6cb9e8f..401a8f1 100644 --- a/AyCode.Core/Serializers/DeserializeTypeMetadataBase.cs +++ b/AyCode.Core/Serializers/DeserializeTypeMetadataBase.cs @@ -29,7 +29,7 @@ public abstract class DeserializeTypeMetadataBase : TypeMetadataBase< /// public Func? IdGetter { get; } - protected DeserializeTypeMetadataBase(Type type) : base(type) + protected DeserializeTypeMetadataBase(Type type, Func ignorePropertyFilter) : base(type, ignorePropertyFilter) { // Cache IId info at construction time - no runtime reflection needed later! var idInfo = GetIdInfo(type); diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonDeserializeTypeMetadata.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonDeserializeTypeMetadata.cs index 076c76d..5833518 100644 --- a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonDeserializeTypeMetadata.cs +++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonDeserializeTypeMetadata.cs @@ -1,9 +1,11 @@ +using AyCode.Core.Helpers; +using AyCode.Core.Serializers; using System.Collections.Concurrent; using System.Collections.Frozen; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; -using AyCode.Core.Serializers; +using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers.Jsons; @@ -22,9 +24,9 @@ public static partial class AcJsonDeserializer public FrozenDictionary PropertySettersFrozen { get; } public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects) - public JsonDeserializeTypeMetadata(Type type) : base(type) + public JsonDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute) { - var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: true); + var props = GetSerializableProperties(type, requiresWrite: true); var propertySetters = new Dictionary(props.Length, StringComparer.OrdinalIgnoreCase); var propsArray = new PropertySetterInfo[props.Length]; diff --git a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.JsonTypeMetadata.cs b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.JsonTypeMetadata.cs index 0a1beae..9cc5c09 100644 --- a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.JsonTypeMetadata.cs +++ b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.JsonTypeMetadata.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; +using AyCode.Core.Helpers; namespace AyCode.Core.Serializers.Jsons; @@ -15,9 +16,9 @@ public static partial class AcJsonSerializer { public PropertyAccessor[] Properties { get; } - public JsonTypeMetadata(Type type) : base(type) + public JsonTypeMetadata(Type type) : base(type, JsonUtilities.HasJsonIgnoreAttribute) { - Properties = GetSerializableProperties(type, requiresRead: true, requiresWrite: false) + Properties = GetSerializableProperties(type, requiresWrite: false) .Select(p => new PropertyAccessor(p, type)) .ToArray(); } diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.Attributes.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.Attributes.cs new file mode 100644 index 0000000..2bf3549 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.Attributes.cs @@ -0,0 +1,155 @@ +using System.Reflection; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + #region EF Core Attribute Detection (reflection-based, no dependency) + + /// + /// Detect EF Core [Key] attribute via reflection (no EF Core dependency). + /// + private static bool TryGetEFCoreKey(PropertyInfo property) + { + var keyAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "KeyAttribute"); + return keyAttr != null; + } + + /// + /// Detect EF Core [ForeignKey("NavigationPropertyName")] attribute via reflection. + /// + private static string? TryGetEFCoreForeignKey(PropertyInfo property) + { + var fkAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "ForeignKeyAttribute"); + + if (fkAttr != null) + { + // Get the Name property value (navigation property name) + var nameProp = fkAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(fkAttr) as string; + } + + return null; + } + + /// + /// Detect EF Core [InverseProperty("PropertyName")] attribute via reflection. + /// + private static string? TryGetEFCoreInverseProperty(PropertyInfo property) + { + var inversePropAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "InversePropertyAttribute"); + + if (inversePropAttr != null) + { + var propertyProp = inversePropAttr.GetType().GetProperty("Property"); + return propertyProp?.GetValue(inversePropAttr) as string; + } + + return null; + } + + /// + /// Detect EF Core [Table("name")] or [Table(Name = "name", Schema = "schema")] attribute via reflection. + /// + private static string? TryGetEFCoreTableName(Type type) + { + var tableAttr = type.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); + + if (tableAttr != null) + { + // EF Core TableAttribute has "Name" property + var nameProp = tableAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(tableAttr) as string; + } + + return null; + } + + #endregion + + #region Linq2Db Attribute Detection (reflection-based, no dependency) + + /// + /// Detect Linq2Db [PrimaryKey] attribute via reflection (no Linq2Db dependency). + /// + private static bool TryGetLinq2DbPrimaryKey(PropertyInfo property) + { + var pkAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "PrimaryKeyAttribute"); + return pkAttr != null; + } + + /// + /// Detect Linq2Db [Association] attribute and determine navigation type. + /// Supports simple ThisKey/OtherKey associations. Expression-based associations are skipped. + /// + private static ToonRelationType? TryGetLinq2DbNavigation(PropertyInfo property) + { + var assocAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "AssociationAttribute"); + + if (assocAttr == null) + return null; + + // Check if it's expression-based (has QueryExpressionMethod property set) + var queryExprMethod = assocAttr.GetType().GetProperty("QueryExpressionMethod")?.GetValue(assocAttr) as string; + if (!string.IsNullOrEmpty(queryExprMethod)) + { + // Expression-based many-to-many - too complex, skip and fallback to convention + return null; + } + + // Simple association with ThisKey/OtherKey + var thisKey = assocAttr.GetType().GetProperty("ThisKey")?.GetValue(assocAttr) as string; + var otherKey = assocAttr.GetType().GetProperty("OtherKey")?.GetValue(assocAttr) as string; + + var propertyType = property.PropertyType; + + // Check if it's a collection (OneToMany or ManyToMany) + if (typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) + { + // Collection with ThisKey + OtherKey -> could be ManyToMany or OneToMany + // If both ThisKey and OtherKey are set -> likely ManyToMany + // If only ThisKey -> OneToMany + if (!string.IsNullOrEmpty(thisKey) && !string.IsNullOrEmpty(otherKey)) + { + // Could be ManyToMany, but without junction table info, treat as OneToMany + return ToonRelationType.OneToMany; + } + else + { + return ToonRelationType.OneToMany; + } + } + else + { + // Single object -> ManyToOne or OneToOne + // Typically ManyToOne + return ToonRelationType.ManyToOne; + } + } + + /// + /// Detect Linq2Db [Table(Name = "name")] attribute via reflection. + /// + private static string? TryGetLinq2DbTableName(Type type) + { + var tableAttr = type.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); + + if (tableAttr != null) + { + // Linq2Db TableAttribute also has "Name" property + var nameProp = tableAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(tableAttr) as string; + } + + return null; + } + + #endregion +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.Descriptions.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.Descriptions.cs new file mode 100644 index 0000000..dde0c5c --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.Descriptions.cs @@ -0,0 +1,371 @@ +using System.ComponentModel; +using System.Reflection; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Get description for a type (can be extended with XML comments or attributes). + /// + private static string GetTypeDescription(Type type) + { + // For now, generate simple descriptions + // TODO: In the future, this could read XML documentation comments + + if (type.IsEnum) + return $"Enum type with values: {string.Join(", ", Enum.GetNames(type))}"; + + var metadata = GetTypeMetadata(type); + + // Only treat as collection if ElementType is not System.Object (fallback value from GetCollectionElementType) + if (metadata.IsCollection && metadata.ElementType != null && metadata.ElementType != typeof(object)) + return $"Collection of {metadata.ElementType.Name}"; + + if (metadata.IsDictionary) + return "Dictionary mapping keys to values"; + + return $"Object of type {type.Name}"; + } + + /// + /// Get description for a property (can be extended with XML comments or attributes). + /// + private static string GetPropertyDescription(Type declaringType, string propertyName, Type propertyType) + { + // Enhanced description based on common patterns + var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType; + var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + // Common property name patterns + if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase)) + return $"Unique identifier for {declaringType.Name}"; + if (propertyName.Equals("Name", StringComparison.OrdinalIgnoreCase)) + return $"Name of the {declaringType.Name}"; + if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase)) + return "Email address"; + if (propertyName.Contains("Phone", StringComparison.OrdinalIgnoreCase)) + return "Phone number"; + if (propertyName.Contains("Address", StringComparison.OrdinalIgnoreCase)) + return "Physical or mailing address"; + if (propertyName.Contains("Date", StringComparison.OrdinalIgnoreCase) || baseType == typeof(DateTime)) + return $"Date/time value for {propertyName}"; + if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool)) + return $"Boolean flag indicating {propertyName.Substring(2)}"; + if (propertyName.StartsWith("Has", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool)) + return $"Boolean flag indicating possession of {propertyName.Substring(3)}"; + if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase) && IsIntegerType(baseType)) + return $"Count of {propertyName.Replace("Count", "")}"; + + // Collection types (csak ha tényleg nem object az elem) + var elementType = GetCollectionElementType(propertyType); + if (elementType != null && elementType != typeof(object)) + return $"Collection of {elementType.Name} for {declaringType.Name}"; + + // Dictionary types + if (IsDictionaryType(propertyType, out _, out _)) + return $"Dictionary mapping for {propertyName} in {declaringType.Name}"; + + // Default + return $"Property {propertyName} of type {baseType.Name}{(isNullable ? " (nullable)" : "")}"; + } + + /// + /// Get property constraints (nullable, required, etc.). + /// + private static string GetPropertyConstraints(Type propertyType, string propertyName) + { + var constraints = new List(); + + var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType; + if (isNullable) + constraints.Add("nullable"); + else + constraints.Add("required"); + + var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + // Type-specific constraints + if (baseType == typeof(string)) + { + if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase)) + constraints.Add("email-format"); + if (propertyName.Contains("Url", StringComparison.OrdinalIgnoreCase)) + constraints.Add("url-format"); + } + + if (IsIntegerType(baseType)) + { + if (propertyName.Contains("Age", StringComparison.OrdinalIgnoreCase)) + constraints.Add("range: 0-150"); + else if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase)) + constraints.Add("non-negative"); + } + + return constraints.Count > 0 ? string.Join(", ", constraints) : ""; + } + + /// + /// Get property purpose (what it's used for). + /// + private static string GetPropertyPurpose(Type declaringType, string propertyName) + { + // Common purposes based on property patterns + if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase)) + return "Primary key / unique identification"; + if (propertyName.Contains("CreatedAt", StringComparison.OrdinalIgnoreCase)) + return "Timestamp when entity was created"; + if (propertyName.Contains("UpdatedAt", StringComparison.OrdinalIgnoreCase) || + propertyName.Contains("ModifiedAt", StringComparison.OrdinalIgnoreCase)) + return "Timestamp of last update"; + if (propertyName.Contains("DeletedAt", StringComparison.OrdinalIgnoreCase)) + return "Soft delete timestamp"; + if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase)) + return "Status flag"; + if (propertyName.Contains("Version", StringComparison.OrdinalIgnoreCase)) + return "Version tracking / concurrency control"; + + return ""; + } + + /// + /// Check if type is an integer type. + /// + private static bool IsIntegerType(Type type) + { + var typeCode = Type.GetTypeCode(type); + return typeCode is TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or + TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or + TypeCode.Int64 or TypeCode.UInt64; + } + + /// + /// Get final property description with fallback chain and placeholder resolution. + /// Priority: ToonDescription (with placeholders) > Microsoft [Description] > null (no fallback) + /// Returns null if no explicit description is provided (to avoid redundant output). + /// + private static string? GetFinalPropertyDescription(ToonPropertyAccessor prop, Type declaringType) + { + // 1. ToonDescription.Description (if not empty) + var customDesc = prop.CustomDescription?.Description; + + if (!string.IsNullOrWhiteSpace(customDesc)) + { + // Has [#...] placeholder? → Resolve it + if (customDesc.Contains("[#")) + { + return ResolveDescriptionPlaceholders(customDesc, prop, declaringType); + } + // No placeholder → use as-is + return customDesc; + } + + // 2. Microsoft [Description] attribute + var msDesc = prop.PropertyInfo.GetCustomAttribute(); + if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) + return msDesc.Description; + + // 3. No fallback - return null to avoid redundant output + return null; + } + + /// + /// Get final property purpose with fallback chain and placeholder resolution. + /// Priority: ToonDescription.Purpose (with placeholders) > Smart inference + /// + private static string GetFinalPropertyPurpose(ToonPropertyAccessor prop, Type declaringType) + { + var customPurpose = prop.CustomDescription?.Purpose; + + if (!string.IsNullOrWhiteSpace(customPurpose)) + { + // Has [#...] placeholder? → Resolve it + if (customPurpose.Contains("[#")) + { + return ResolvePurposePlaceholders(customPurpose, prop, declaringType); + } + return customPurpose; + } + + // Fallback: Smart inference + return GetPropertyPurpose(declaringType, prop.Name); + } + + /// + /// Get final property constraints with fallback chain and placeholder resolution. + /// Priority: ToonDescription.Constraints (with placeholders merged) > Microsoft attributes > Type constraints > Smart inference + /// + private static string GetFinalPropertyConstraints(ToonPropertyAccessor prop, Type declaringType) + { + var customConstraints = prop.CustomDescription?.Constraints; + + // --- NotMapped/NotColumn detection --- + var notMapped = false; + var attrs = prop.PropertyInfo.GetCustomAttributes(inherit: true); + foreach (var attr in attrs) + { + var attrName = attr.GetType().Name; + if (attrName == "NotMappedAttribute" || attrName == "NotColumnAttribute") + { + notMapped = true; + break; + } + } + + string result; + + if (!string.IsNullOrWhiteSpace(customConstraints)) + { + // Has [#...] placeholder? → Resolve and merge + if (customConstraints.Contains("[#")) + { + // Resolve placeholders first + var resolved = ResolveConstraintPlaceholders(customConstraints, prop); + + // Merge with type/smart constraints if resolved contains content + if (!string.IsNullOrWhiteSpace(resolved)) + { + var typeConstraints = ExtractTypeConstraints(prop.PropertyType); + var inferredConstraints = GetInferredConstraints(prop.PropertyType, prop.Name); + result = MergeConstraints(typeConstraints, null, inferredConstraints, resolved); + } + else + { + result = string.Empty; + } + } + else + { + // No placeholder → custom wins (replace mode) + result = customConstraints; + } + } + else + { + // No custom constraint → Fallback chain + var msConstraints = ExtractDataAnnotationConstraints(prop.PropertyInfo); + var typeConstraints2 = ExtractTypeConstraints(prop.PropertyType); + var inferredConstraints2 = GetInferredConstraints(prop.PropertyType, prop.Name); + result = MergeConstraints(typeConstraints2, msConstraints, inferredConstraints2, null); + } + + // Add readonly constraint if needed + if (IsReadOnlyProperty(prop.PropertyInfo) && !result.Contains("readonly")) + { + result = string.IsNullOrEmpty(result) ? "readonly" : result + ", readonly"; + } + + // Add not-mapped constraint if needed (only once, at the end) + if (notMapped && !result.Contains("not-mapped")) + { + result = string.IsNullOrEmpty(result) ? "not-mapped" : result + ", not-mapped"; + } + + // Add enum-type constraint if this is an enum backing field + var enumType = FindEnumTypeForBackingField(prop.PropertyInfo, declaringType); + if (enumType != null && !result.Contains("enum-type:")) + { + var enumConstraint = $"enum-type: {enumType.Name}"; + result = string.IsNullOrEmpty(result) ? enumConstraint : result + ", " + enumConstraint; + } + + return result; + } + + /// + /// Get final property examples with placeholder resolution. + /// + private static string GetFinalPropertyExamples(ToonPropertyAccessor prop) + { + var customExamples = prop.CustomDescription?.Examples; + + if (!string.IsNullOrWhiteSpace(customExamples)) + { + // Has [#...] placeholder? → Resolve it + if (customExamples.Contains("[#")) + { + return ResolveExamplesPlaceholders(customExamples, prop); + } + return customExamples; + } + + // No examples + return string.Empty; + } + + /// + /// Get final type description with fallback chain and placeholder resolution. + /// Priority: ToonDescription (with placeholders) > Microsoft [Description] > Smart inference + /// + private static string GetFinalTypeDescription(Type type, ToonTypeMetadata metadata) + { + // 1. ToonDescription.Description (if not empty) + var customDesc = metadata.CustomDescription?.Description; + + if (!string.IsNullOrWhiteSpace(customDesc)) + { + // Has [#...] placeholder? → Resolve it + if (customDesc.Contains("[#")) + { + return ResolveTypeDescriptionPlaceholders(customDesc, type); + } + // No placeholder → use as-is + return customDesc; + } + + // 2. Microsoft [Description] attribute + var msDesc = type.GetCustomAttribute(); + if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) + return msDesc.Description; + + // 3. Smart inference (fallback) + return GetTypeDescription(type); + } + + /// + /// Get final type purpose with fallback chain and placeholder resolution. + /// Priority: ToonDescription.Purpose (with placeholders) > Smart inference (empty for classes) + /// + private static string GetFinalTypePurpose(Type type, ToonTypeMetadata metadata) + { + var customPurpose = metadata.CustomDescription?.Purpose; + + if (!string.IsNullOrWhiteSpace(customPurpose)) + { + // Has [#...] placeholder? → Resolve it + if (customPurpose.Contains("[#")) + { + return ResolveTypePurposePlaceholders(customPurpose, type); + } + return customPurpose; + } + + // No smart inference for class-level purpose - return empty + return string.Empty; + } + + /// + /// Get final enum description with fallback chain. + /// Priority: ToonDescription.Description -> Microsoft [Description] -> null (no fallback) + /// Returns null if no explicit description to avoid redundancy (values already in values: section). + /// + private static string? GetFinalEnumDescription(Type enumType, ToonDescriptionAttribute? customDescription) + { + // 1. ToonDescription.Description + if (!string.IsNullOrWhiteSpace(customDescription?.Description)) + { + return customDescription.Description; + } + + // 2. Microsoft [Description] attribute + var msDesc = enumType.GetCustomAttribute(); + if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) + { + return msDesc.Description; + } + + // 3. No fallback - avoid redundancy (values already listed in values: section) + return null; + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.ForeignKeys.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.ForeignKeys.cs new file mode 100644 index 0000000..70d0a42 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.ForeignKeys.cs @@ -0,0 +1,189 @@ +using System.Reflection; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Convention: Property named "{TypeName}Id" with a corresponding "{TypeName}" navigation property is a foreign key. + /// Type-based validation: checks that navigation property is a complex type (not primitive/string). + /// Example: "CompanyId" + "Company" property exists and is complex type -> foreign key to "Company". + /// + private static string? DetectConventionForeignKey(PropertyInfo property) + { + // Skip non-value types (we don't check specific FK type, could be int/long/Guid/etc) + if (!property.PropertyType.IsValueType) + { + return null; + } + + // Check if property name ends with "Id" + if (!property.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Extract potential navigation property name + var navigationPropertyName = property.Name.Substring(0, property.Name.Length - 2); + + // Check if corresponding navigation property exists + var declaringType = property.DeclaringType; + if (declaringType == null) + { + return null; + } + + var navigationProperty = declaringType.GetProperty(navigationPropertyName, BindingFlags.Public | BindingFlags.Instance); + + // Type-based validation: navigation property must exist and be a complex type (not primitive/string) + if (navigationProperty == null || IsPrimitiveOrStringFast(navigationProperty.PropertyType)) + { + return null; + } + + // Additional validation: navigation property type should have a primary key (IsPrimaryKey or Id property) + if (!HasPrimaryKeyProperty(navigationProperty.PropertyType)) + { + return null; + } + + return navigationPropertyName; + } + + /// + /// 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; + } + + /// + /// 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; + } + + /// + /// Find FK property by name using direct PropertyInfo array (not filtered by GetSerializableProperties). + /// Handles "Dto" and "Model" suffix removal for naming conventions. + /// Example: baseName="OrderDto" → tries "OrderDtoId", then "OrderId" + /// Example: baseName="OrderModel" → tries "OrderModelId", then "OrderId" + /// + private static string? TryFindForeignKeyPropertyDirect(PropertyInfo[] properties, string baseName) + { + // Try "{baseName}Id" + var fkName = baseName + "Id"; + for (int i = 0; i < properties.Length; i++) + { + if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType) + return fkName; + } + + // Try without "Dto" suffix: "OrderDto" → "OrderId" + if (baseName.Length > 3 && baseName.EndsWith("Dto", StringComparison.Ordinal)) + { + fkName = baseName.Substring(0, baseName.Length - 3) + "Id"; + for (int i = 0; i < properties.Length; i++) + { + if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType) + return fkName; + } + } + + // Try without "Model" suffix: "OrderModel" → "OrderId" + if (baseName.Length > 5 && baseName.EndsWith("Model", StringComparison.Ordinal)) + { + fkName = baseName.Substring(0, baseName.Length - 5) + "Id"; + for (int i = 0; i < properties.Length; i++) + { + if (properties[i].Name == fkName && properties[i].PropertyType.IsValueType) + return fkName; + } + } + + return null; + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs deleted file mode 100644 index 7cc94b8..0000000 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs +++ /dev/null @@ -1,1056 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reflection; -using static AyCode.Core.Helpers.JsonUtilities; - -namespace AyCode.Core.Serializers.Toons; - -public static partial class AcToonSerializer -{ - /// - /// Write meta section only (for MetaOnly mode). - /// - private static void WriteMetaSectionOnly(Type type, ToonSerializationContext context) - { - WriteMetaSection(type, context); - } - - /// - /// Write @meta and @types sections for a single root type. - /// - private static void WriteMetaSection(Type type, ToonSerializationContext context) - { - if (!context.Options.UseMeta) return; - - // Collect all types that need metadata - var typesToDocument = new HashSet(); - CollectTypes(type, typesToDocument); - - WriteMetaSectionCore(typesToDocument, context); - } - - /// - /// Write @meta and @types sections for a collection of types. - /// - private static void WriteMetaSection(IEnumerable types, ToonSerializationContext context) - { - if (!context.Options.UseMeta) return; - - // Collect all types that need metadata (including nested types) - var typesToDocument = new HashSet(); - foreach (var type in types) - { - CollectTypes(type, typesToDocument); - } - - WriteMetaSectionCore(typesToDocument, context); - } - - /// - /// Core logic for writing @meta and @types sections. - /// - private static void WriteMetaSectionCore(HashSet typesToDocument, ToonSerializationContext context) - { - // @meta header - context.WriteLine("@meta {"); - context.CurrentIndentLevel++; - context.WriteProperty("version", $"\"{FormatVersion}\""); - context.WriteProperty("format", "\"toon\""); - context.WriteProperty("source-code-language", "\"C#\""); - - // Write type list - context.WriteIndent(); - context.Write("types"); - - // Token optimization: no spaces around '=' when indentation is disabled - if (context.Options.UseIndentation) - { - context.Write(" = "); - } - else - { - context.Write('='); - } - - // Szűrés: csak komplex típusok (class/struct) és enum-ok, primitívek nélkül - var filteredTypes = typesToDocument - .Where(t => t.IsEnum || !IsPrimitiveOrStringFast(t)) // Enum-ok megtartása, primitívek és string kizárása - .Where(t => !typeof(System.Collections.IEnumerable).IsAssignableFrom(t) || t == typeof(string)) - .Where(t => !t.IsGenericType || t.IsGenericTypeDefinition) - .Where(t => !t.Name.StartsWith("List`", StringComparison.Ordinal) && !t.Name.StartsWith("ICollection`", StringComparison.Ordinal) && !t.Name.StartsWith("IEnumerable`", StringComparison.Ordinal) && !t.Name.StartsWith("Dictionary`", StringComparison.Ordinal)) - .ToList(); - - // DAG topological sort: dependencies first - var sortedTypes = TopologicalSortTypes(filteredTypes); - - context.Write("["); - var first = true; - foreach (var t in sortedTypes) - { - if (!first) context.Write(", "); - context.Write($"\"{t.Name}\""); - first = false; - } - context.WriteLine("]"); - - context.CurrentIndentLevel--; - context.WriteLine("}"); - - // Token optimization: skip empty line when indentation is disabled - if (context.Options.UseIndentation) - { - context.WriteLine(); - } - - // @types section with descriptions (same sorted order) - context.WriteLine("@types {"); - context.CurrentIndentLevel++; - - foreach (var t in sortedTypes) - { - WriteTypeDefinition(t, context); - } - - context.CurrentIndentLevel--; - context.WriteLine("}"); - } - - /// - /// Recursively collects all types that need metadata documentation. - /// Uses the same logic as AcBinarySerializer.RegisterMetadataForType. - /// - private static void CollectTypes(Type type, HashSet types) - { - var underlyingType = Nullable.GetUnderlyingType(type) ?? type; - - // Enum-ok: hozzáadjuk és return (nem traversáljuk tovább) - if (underlyingType.IsEnum) - { - types.Add(underlyingType); - return; - } - - // Primitívek és string: kihagyjuk - if (IsPrimitiveOrStringFast(underlyingType)) return; - - if (!types.Add(underlyingType)) return; // Already processed - - // Handle dictionaries FIRST (before generic IEnumerable check) - if (IsDictionaryType(underlyingType, out var keyType, out var valueType)) - { - if (keyType != null) CollectTypes(keyType, types); - if (valueType != null) CollectTypes(valueType, types); - return; - } - - // Handle collections/arrays (matches AcBinarySerializer logic) - if (typeof(IEnumerable).IsAssignableFrom(underlyingType) && !ReferenceEquals(underlyingType, StringType)) - { - var elementType = GetCollectionElementType(underlyingType); - if (elementType != null) - { - CollectTypes(elementType, types); - } - return; - } - - // Handle object properties (traverse type graph) - var metadata = GetTypeMetadata(underlyingType); - - // Detektáljuk az enum backing field-eket (NotMapped enum property-k) - DetectAndCollectEnumBackingFields(underlyingType, types); - - foreach (var prop in metadata.Properties) - { - CollectTypes(prop.PropertyType, types); - } - } - - /// - /// Detektálja az enum backing field-eket és hozzáadja az enum típusokat a types hash set-hez. - /// Logika: Ha van egy enum property (bármilyen) és van egy {PropertyName}Id int property, - /// akkor az enum típust hozzáadjuk. - /// - private static void DetectAndCollectEnumBackingFields(Type type, HashSet types) - { - var allProperties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (var prop in allProperties) - { - // Csak enum property-ket vizsgálunk - if (!prop.PropertyType.IsEnum) continue; - - // Keressük meg a {PropertyName}Id backing field-et - var backingFieldName = $"{prop.Name}Id"; - var backingField = allProperties.FirstOrDefault(p => - p.Name == backingFieldName && - (p.PropertyType == typeof(int) || p.PropertyType == typeof(int?))); - - if (backingField != null) - { - // Megtaláltuk az enum backing field párt → hozzáadjuk az enum típust - types.Add(prop.PropertyType); - } - } - } - - /// - /// Megkeresi a property-hez tartozó enum típust, ha az egy enum backing field. - /// Visszaadja az enum típust CSAK akkor, ha: - /// 1. Van megfelelő nevű enum property (naming convention) - /// 2. Az enum property NotMapped/NotColumn/JsonIgnore attribútummal van ellátva - /// 3. A backing field típusa megegyezik az enum underlying type-jával (int/int?, byte/byte?, stb.) - /// - private static Type? FindEnumTypeForBackingField(PropertyInfo backingFieldProperty, Type declaringType) - { - // Ha a property neve "...Id"-re végződik, keressük meg az enum property-t - if (!backingFieldProperty.Name.EndsWith("Id", StringComparison.Ordinal)) - return null; - - var enumPropertyName = backingFieldProperty.Name.Substring(0, backingFieldProperty.Name.Length - 2); - var allProperties = declaringType.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - var enumProperty = allProperties.FirstOrDefault(p => - p.Name == enumPropertyName && - (p.PropertyType.IsEnum || (Nullable.GetUnderlyingType(p.PropertyType)?.IsEnum ?? false))); - - if (enumProperty == null) return null; - - // Ellenőrizzük, hogy NotMapped/NotColumn/JsonIgnore van-e rajta - var isNotMapped = enumProperty.GetCustomAttributes(inherit: true).Any(attr => - { - var attrName = attr.GetType().Name; - return attrName == "NotMappedAttribute" || - attrName == "NotColumnAttribute" || - attrName == "JsonIgnoreAttribute"; - }); - - // Csak akkor backing field, ha az enum property NotMapped! - if (!isNotMapped) return null; - - // Ellenőrizzük, hogy a backing field típusa megegyezik-e az enum underlying type-jával - var enumType = Nullable.GetUnderlyingType(enumProperty.PropertyType) ?? enumProperty.PropertyType; - var enumUnderlyingType = Enum.GetUnderlyingType(enumType); - - // A backing field lehet nullable vagy non-nullable is - var backingUnderlyingType = Nullable.GetUnderlyingType(backingFieldProperty.PropertyType) - ?? backingFieldProperty.PropertyType; - - // Az underlying type-oknak meg kell egyezniük - if (backingUnderlyingType != enumUnderlyingType) - return null; - - // Visszaadjuk az enum típust (nullable nélkül) - return enumType; - } - - /// - /// Write type definition with property descriptions. - /// - private static void WriteTypeDefinition(Type type, ToonSerializationContext context) - { - // Handle enum types specially - if (type.IsEnum) - { - WriteEnumTypeDefinition(type, context); - return; - } - - var metadata = GetTypeMetadata(type); - - // Type description with fallback chain and placeholder resolution - var typeDescription = GetFinalTypeDescription(type, metadata); - context.WriteIndentedLine($"{type.Name}: \"{typeDescription}\""); - - // Növeljük az indent-et a type body-hoz (type-level metadata ÉS property-k) - context.CurrentIndentLevel++; - - // Type-level metadata (enhanced mode only) - if (context.Options.UseEnhancedMetadata) - { - // Table name - var tableName = DetectTableName(type, metadata.CustomDescription); - if (!string.IsNullOrEmpty(tableName)) - { - context.WriteIndentedLine($"table-name: \"{tableName}\""); - } - - // Purpose - var typePurpose = GetFinalTypePurpose(type, metadata); - if (!string.IsNullOrEmpty(typePurpose)) - { - context.WriteIndentedLine($"purpose: \"{typePurpose}\""); - } - } - - if (metadata.Properties.Length == 0) - { - context.CurrentIndentLevel--; - return; - } - - // Property descriptions (már indent szint 1-en vagyunk, property-k is ide kerülnek) - foreach (var prop in metadata.Properties) - { - // Get final values with fallback chain + placeholder resolution - var propDescription = GetFinalPropertyDescription(prop, type); - var purpose = GetFinalPropertyPurpose(prop, type); - var constraints = GetFinalPropertyConstraints(prop, type); - var examples = GetFinalPropertyExamples(prop); - - // Use cached navigation metadata from property accessor - var nav = prop.Navigation; - - var typeHint = prop.TypeDisplayName; - - if (context.Options.UseEnhancedMetadata) - { - // Enhanced format with constraints and purpose - context.WriteIndentedLine($"{prop.Name}: {typeHint}"); - context.CurrentIndentLevel++; - - // Only write description if explicitly provided (not null/empty) - if (!string.IsNullOrEmpty(propDescription)) - context.WriteIndentedLine($"description: \"{propDescription}\""); - - if (!string.IsNullOrEmpty(purpose)) - context.WriteIndentedLine($"purpose: \"{purpose}\""); - if (!string.IsNullOrEmpty(constraints)) - context.WriteIndentedLine($"constraints: \"{constraints}\""); - if (!string.IsNullOrEmpty(examples)) - context.WriteIndentedLine($"examples: \"{examples}\""); - - // Add relationship metadata from cached navigation info - if (nav.IsPrimaryKey) - context.WriteIndentedLine($"primary-key: true"); - - // Only write foreign-key for navigation properties, not for FK properties themselves - if (!string.IsNullOrEmpty(nav.ForeignKey) && nav.NavigationType.HasValue) - context.WriteIndentedLine($"foreign-key: \"{nav.ForeignKey}\""); - - if (!string.IsNullOrEmpty(nav.OtherKey)) - context.WriteIndentedLine($"other-key: \"{nav.OtherKey}\""); - if (nav.NavigationType.HasValue) - { - var navType = nav.NavigationType.Value.ToString(); - var navTypeFormatted = string.Concat(navType.Select((c, i) => i > 0 && char.IsUpper(c) ? "-" + char.ToLower(c) : char.ToLower(c).ToString())); - context.WriteIndentedLine($"navigation: \"{navTypeFormatted}\""); - } - if (!string.IsNullOrEmpty(nav.InverseProperty)) - context.WriteIndentedLine($"inverse-property: \"{nav.InverseProperty}\""); - - context.CurrentIndentLevel--; - } - else - { - // Simple format - var relationshipHint = GetRelationshipHint(nav); - - // Only write description if there's explicit description OR relationship metadata - if (!string.IsNullOrEmpty(propDescription) || !string.IsNullOrEmpty(relationshipHint)) - { - var fullDescription = string.IsNullOrEmpty(relationshipHint) - ? propDescription - : string.IsNullOrEmpty(propDescription) - ? relationshipHint - : $"{propDescription} ({relationshipHint})"; - context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{fullDescription}\""); - } - else - { - // No description and no relationship metadata - just write type hint - context.WriteIndentedLine($"{prop.Name}: {typeHint}"); - } - } - } - context.CurrentIndentLevel--; - - // Token optimization: skip empty line when indentation is disabled - if (context.Options.UseIndentation) - { - context.WriteLine(); - } - } - - /// - /// Write enum type definition with values and metadata. - /// - private static void WriteEnumTypeDefinition(Type enumType, ToonSerializationContext context) - { - var customDescription = enumType.GetCustomAttribute(); - - // Type description with fallback - var typeDescription = GetFinalEnumDescription(enumType, customDescription); - context.WriteIndentedLine($"{enumType.Name}: enum"); - - if (context.Options.UseEnhancedMetadata) - { - context.CurrentIndentLevel++; - - // Description - if (!string.IsNullOrEmpty(typeDescription)) - { - context.WriteIndentedLine($"description: \"{typeDescription}\""); - } - - // Purpose - var typePurpose = customDescription?.Purpose; - if (!string.IsNullOrEmpty(typePurpose)) - { - context.WriteIndentedLine($"purpose: \"{typePurpose}\""); - } - - // Underlying type - var underlyingType = Enum.GetUnderlyingType(enumType); - var underlyingTypeName = AcSerializerCommon.GetCSharpTypeName(underlyingType, useShortNames: true); - context.WriteIndentedLine($"underlying-type: \"{underlyingTypeName}\""); - - // Default value (first enum member) - var enumValues = Enum.GetValues(enumType); - if (enumValues.Length > 0) - { - var firstValue = enumValues.GetValue(0); - var firstValueNumeric = Convert.ChangeType(firstValue, underlyingType); - context.WriteIndentedLine($"default-value: {firstValueNumeric}"); - } - - // Enum members with descriptions - context.WriteIndentedLine("values:"); - context.CurrentIndentLevel++; - - var names = Enum.GetNames(enumType); - foreach (var name in names) - { - var field = enumType.GetField(name); - var value = field?.GetValue(null); - var numericValue = Convert.ChangeType(value, underlyingType); - - // Get member description from ToonDescription attribute - var memberDescription = field?.GetCustomAttribute(); - var description = memberDescription?.Description; - - // Token optimization: format depends on indentation setting - var separator = context.Options.UseIndentation ? " = " : "="; - - if (!string.IsNullOrEmpty(description)) - { - context.WriteIndentedLine($"{name}{separator}{numericValue}"); - context.CurrentIndentLevel++; - context.WriteIndentedLine($"description: \"{description}\""); - context.CurrentIndentLevel--; - } - else - { - context.WriteIndentedLine($"{name}{separator}{numericValue}"); - } - } - - context.CurrentIndentLevel--; - context.CurrentIndentLevel--; - } - else - { - // Simple format - just show values - var names = Enum.GetNames(enumType); - var underlyingType = Enum.GetUnderlyingType(enumType); - context.CurrentIndentLevel++; - - // Token optimization: format depends on indentation setting - var separator = context.Options.UseIndentation ? " = " : "="; - - foreach (var name in names) - { - var field = enumType.GetField(name); - var value = field?.GetValue(null); - var numericValue = Convert.ChangeType(value, underlyingType); - context.WriteIndentedLine($"{name}{separator}{numericValue}"); - } - - context.CurrentIndentLevel--; - } - - // Token optimization: skip empty line when indentation is disabled - if (context.Options.UseIndentation) - { - context.WriteLine(); - } - } - - /// - /// Get final enum description with fallback chain. - /// Priority: ToonDescription.Description -> Microsoft [Description] -> null (no fallback) - /// Returns null if no explicit description to avoid redundancy (values already in values: section). - /// - private static string? GetFinalEnumDescription(Type enumType, ToonDescriptionAttribute? customDescription) - { - // 1. ToonDescription.Description - if (!string.IsNullOrWhiteSpace(customDescription?.Description)) - { - return customDescription.Description; - } - - // 2. Microsoft [Description] attribute - var msDesc = enumType.GetCustomAttribute(); - if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) - { - return msDesc.Description; - } - - // 3. No fallback - avoid redundancy (values already listed in values: section) - return null; - } - - /// - /// Get description for a type (can be extended with XML comments or attributes). - /// - private static string GetTypeDescription(Type type) - { - // For now, generate simple descriptions - // TODO: In the future, this could read XML documentation comments - - if (type.IsEnum) - return $"Enum type with values: {string.Join(", ", Enum.GetNames(type))}"; - - var metadata = GetTypeMetadata(type); - - // Only treat as collection if ElementType is not System.Object (fallback value from GetCollectionElementType) - if (metadata.IsCollection && metadata.ElementType != null && metadata.ElementType != typeof(object)) - return $"Collection of {metadata.ElementType.Name}"; - - if (metadata.IsDictionary) - return "Dictionary mapping keys to values"; - - return $"Object of type {type.Name}"; - } - - /// - /// Get description for a property (can be extended with XML comments or attributes). - /// - private static string GetPropertyDescription(Type declaringType, string propertyName, Type propertyType) - { - // Enhanced description based on common patterns - var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType; - var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; - - // Common property name patterns - if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase)) - return $"Unique identifier for {declaringType.Name}"; - if (propertyName.Equals("Name", StringComparison.OrdinalIgnoreCase)) - return $"Name of the {declaringType.Name}"; - if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase)) - return "Email address"; - if (propertyName.Contains("Phone", StringComparison.OrdinalIgnoreCase)) - return "Phone number"; - if (propertyName.Contains("Address", StringComparison.OrdinalIgnoreCase)) - return "Physical or mailing address"; - if (propertyName.Contains("Date", StringComparison.OrdinalIgnoreCase) || baseType == typeof(DateTime)) - return $"Date/time value for {propertyName}"; - if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool)) - return $"Boolean flag indicating {propertyName.Substring(2)}"; - if (propertyName.StartsWith("Has", StringComparison.OrdinalIgnoreCase) && baseType == typeof(bool)) - return $"Boolean flag indicating possession of {propertyName.Substring(3)}"; - if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase) && IsIntegerType(baseType)) - return $"Count of {propertyName.Replace("Count", "")}"; - - // Collection types (csak ha tényleg nem object az elem) - var elementType = GetCollectionElementType(propertyType); - if (elementType != null && elementType != typeof(object)) - return $"Collection of {elementType.Name} for {declaringType.Name}"; - - // Dictionary types - if (IsDictionaryType(propertyType, out _, out _)) - return $"Dictionary mapping for {propertyName} in {declaringType.Name}"; - - // Default - return $"Property {propertyName} of type {baseType.Name}{(isNullable ? " (nullable)" : "")}"; - } - - /// - /// Get property constraints (nullable, required, etc.). - /// - private static string GetPropertyConstraints(Type propertyType, string propertyName) - { - var constraints = new List(); - - var isNullable = Nullable.GetUnderlyingType(propertyType) != null || !propertyType.IsValueType; - if (isNullable) - constraints.Add("nullable"); - else - constraints.Add("required"); - - var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; - - // Type-specific constraints - if (baseType == typeof(string)) - { - if (propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase)) - constraints.Add("email-format"); - if (propertyName.Contains("Url", StringComparison.OrdinalIgnoreCase)) - constraints.Add("url-format"); - } - - if (IsIntegerType(baseType)) - { - if (propertyName.Contains("Age", StringComparison.OrdinalIgnoreCase)) - constraints.Add("range: 0-150"); - else if (propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase)) - constraints.Add("non-negative"); - } - - return constraints.Count > 0 ? string.Join(", ", constraints) : ""; - } - - /// - /// Get property purpose (what it's used for). - /// - private static string GetPropertyPurpose(Type declaringType, string propertyName) - { - // Common purposes based on property patterns - if (propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase)) - return "Primary key / unique identification"; - if (propertyName.Contains("CreatedAt", StringComparison.OrdinalIgnoreCase)) - return "Timestamp when entity was created"; - if (propertyName.Contains("UpdatedAt", StringComparison.OrdinalIgnoreCase) || - propertyName.Contains("ModifiedAt", StringComparison.OrdinalIgnoreCase)) - return "Timestamp of last update"; - if (propertyName.Contains("DeletedAt", StringComparison.OrdinalIgnoreCase)) - return "Soft delete timestamp"; - if (propertyName.StartsWith("Is", StringComparison.OrdinalIgnoreCase)) - return "Status flag"; - if (propertyName.Contains("Version", StringComparison.OrdinalIgnoreCase)) - return "Version tracking / concurrency control"; - - return ""; - } - - /// - /// Check if type is an integer type. - /// - private static bool IsIntegerType(Type type) - { - var typeCode = Type.GetTypeCode(type); - return typeCode is TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or - TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or - TypeCode.Int64 or TypeCode.UInt64; - } - - /// - /// Get final property description with fallback chain and placeholder resolution. - /// Priority: ToonDescription (with placeholders) > Microsoft [Description] > null (no fallback) - /// Returns null if no explicit description is provided (to avoid redundant output). - /// - private static string? GetFinalPropertyDescription(ToonPropertyAccessor prop, Type declaringType) - { - // 1. ToonDescription.Description (if not empty) - var customDesc = prop.CustomDescription?.Description; - - if (!string.IsNullOrWhiteSpace(customDesc)) - { - // Has [#...] placeholder? → Resolve it - if (customDesc.Contains("[#")) - { - return ResolveDescriptionPlaceholders(customDesc, prop, declaringType); - } - // No placeholder → use as-is - return customDesc; - } - - // 2. Microsoft [Description] attribute - var msDesc = prop.PropertyInfo.GetCustomAttribute(); - if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) - return msDesc.Description; - - // 3. No fallback - return null to avoid redundant output - return null; - } - - /// - /// Get final property purpose with fallback chain and placeholder resolution. - /// Priority: ToonDescription.Purpose (with placeholders) > Smart inference - /// - private static string GetFinalPropertyPurpose(ToonPropertyAccessor prop, Type declaringType) - { - var customPurpose = prop.CustomDescription?.Purpose; - - if (!string.IsNullOrWhiteSpace(customPurpose)) - { - // Has [#...] placeholder? → Resolve it - if (customPurpose.Contains("[#")) - { - return ResolvePurposePlaceholders(customPurpose, prop, declaringType); - } - return customPurpose; - } - - // Fallback: Smart inference - return GetPropertyPurpose(declaringType, prop.Name); - } - - /// - /// Get final property constraints with fallback chain and placeholder resolution. - /// Priority: ToonDescription.Constraints (with placeholders merged) > Microsoft attributes > Type constraints > Smart inference - /// - private static string GetFinalPropertyConstraints(ToonPropertyAccessor prop, Type declaringType) - { - var customConstraints = prop.CustomDescription?.Constraints; - - // --- NotMapped/NotColumn detection --- - var notMapped = false; - var attrs = prop.PropertyInfo.GetCustomAttributes(inherit: true); - foreach (var attr in attrs) - { - var attrName = attr.GetType().Name; - if (attrName == "NotMappedAttribute" || attrName == "NotColumnAttribute") - { - notMapped = true; - break; - } - } - - string result; - - if (!string.IsNullOrWhiteSpace(customConstraints)) - { - // Has [#...] placeholder? → Resolve and merge - if (customConstraints.Contains("[#")) - { - // Resolve placeholders first - var resolved = ResolveConstraintPlaceholders(customConstraints, prop); - - // Merge with type/smart constraints if resolved contains content - if (!string.IsNullOrWhiteSpace(resolved)) - { - var typeConstraints = ExtractTypeConstraints(prop.PropertyType); - var inferredConstraints = GetInferredConstraints(prop.PropertyType, prop.Name); - result = MergeConstraints(typeConstraints, null, inferredConstraints, resolved); - } - else - { - result = string.Empty; - } - } - else - { - // No placeholder → custom wins (replace mode) - result = customConstraints; - } - } - else - { - // No custom constraint → Fallback chain - var msConstraints = ExtractDataAnnotationConstraints(prop.PropertyInfo); - var typeConstraints2 = ExtractTypeConstraints(prop.PropertyType); - var inferredConstraints2 = GetInferredConstraints(prop.PropertyType, prop.Name); - result = MergeConstraints(typeConstraints2, msConstraints, inferredConstraints2, null); - } - - // Add readonly constraint if needed - if (IsReadOnlyProperty(prop.PropertyInfo) && !result.Contains("readonly")) - { - result = string.IsNullOrEmpty(result) ? "readonly" : result + ", readonly"; - } - - // Add not-mapped constraint if needed (only once, at the end) - if (notMapped && !result.Contains("not-mapped")) - { - result = string.IsNullOrEmpty(result) ? "not-mapped" : result + ", not-mapped"; - } - - // Add enum-type constraint if this is an enum backing field - var enumType = FindEnumTypeForBackingField(prop.PropertyInfo, declaringType); - if (enumType != null && !result.Contains("enum-type:")) - { - var enumConstraint = $"enum-type: {enumType.Name}"; - result = string.IsNullOrEmpty(result) ? enumConstraint : result + ", " + enumConstraint; - } - - return result; - } - - /// - /// Get final property examples with placeholder resolution. - /// - private static string GetFinalPropertyExamples(ToonPropertyAccessor prop) - { - var customExamples = prop.CustomDescription?.Examples; - - if (!string.IsNullOrWhiteSpace(customExamples)) - { - // Has [#...] placeholder? → Resolve it - if (customExamples.Contains("[#")) - { - return ResolveExamplesPlaceholders(customExamples, prop); - } - return customExamples; - } - - // No examples - return string.Empty; - } - - /// - /// Get final type description with fallback chain and placeholder resolution. - /// Priority: ToonDescription (with placeholders) > Microsoft [Description] > Smart inference - /// - private static string GetFinalTypeDescription(Type type, ToonTypeMetadata metadata) - { - // 1. ToonDescription.Description (if not empty) - var customDesc = metadata.CustomDescription?.Description; - - if (!string.IsNullOrWhiteSpace(customDesc)) - { - // Has [#...] placeholder? → Resolve it - if (customDesc.Contains("[#")) - { - return ResolveTypeDescriptionPlaceholders(customDesc, type); - } - // No placeholder → use as-is - return customDesc; - } - - // 2. Microsoft [Description] attribute - var msDesc = type.GetCustomAttribute(); - if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) - return msDesc.Description; - - // 3. Smart inference (fallback) - return GetTypeDescription(type); - } - - /// - /// Resolve [#...] placeholders in type description string. - /// - private static string ResolveTypeDescriptionPlaceholders(string template, Type type) - { - var result = template; - - // [#Description] → Microsoft [Description] - if (result.Contains("[#Description]")) - { - var msDesc = type.GetCustomAttribute(); - var value = msDesc?.Description ?? ""; - result = result.Replace("[#Description]", value); - } - - // [#DisplayName] → Microsoft [DisplayName] - if (result.Contains("[#DisplayName]")) - { - var displayName = type.GetCustomAttribute(); - var value = displayName?.DisplayName ?? type.Name; - result = result.Replace("[#DisplayName]", value); - } - - // [#SmartDescription] → Smart inference - if (result.Contains("[#SmartDescription]")) - { - var value = GetTypeDescription(type); - result = result.Replace("[#SmartDescription]", value); - } - - return CleanupPlaceholders(result); - } - - /// - /// Get final type purpose with fallback chain and placeholder resolution. - /// Priority: ToonDescription.Purpose (with placeholders) > Smart inference (empty for classes) - /// - private static string GetFinalTypePurpose(Type type, ToonTypeMetadata metadata) - { - var customPurpose = metadata.CustomDescription?.Purpose; - - if (!string.IsNullOrWhiteSpace(customPurpose)) - { - // Has [#...] placeholder? → Resolve it - if (customPurpose.Contains("[#")) - { - return ResolveTypePurposePlaceholders(customPurpose, type); - } - return customPurpose; - } - - // No smart inference for class-level purpose - return empty - return string.Empty; - } - - /// - /// Resolve [#...] placeholders in type purpose string. - /// - private static string ResolveTypePurposePlaceholders(string template, Type type) - { - var result = template; - - // [#SmartPurpose] → Would be empty for classes, so just remove it - if (result.Contains("[#SmartPurpose]")) - { - result = result.Replace("[#SmartPurpose]", ""); - } - - return CleanupPlaceholders(result); - } - - /// - /// Get relationship hint string for simple format output. - /// - private static string GetRelationshipHint(AcNavigationPropertyInfo nav) - { - var hints = new List(); - - if (nav.IsPrimaryKey) - hints.Add("primary-key"); - - if (!string.IsNullOrEmpty(nav.ForeignKey)) - hints.Add($"fk->{nav.ForeignKey}"); - - if (!string.IsNullOrEmpty(nav.OtherKey)) - hints.Add($"other-key:{nav.OtherKey}"); - - if (nav.NavigationType.HasValue) - { - var navType = nav.NavigationType.Value switch - { - ToonRelationType.ManyToOne => "many-to-one", - ToonRelationType.OneToMany => "one-to-many", - ToonRelationType.OneToOne => "one-to-one", - ToonRelationType.ManyToMany => "many-to-many", - _ => null - }; - if (navType != null) - hints.Add(navType); - } - - if (!string.IsNullOrEmpty(nav.InverseProperty)) - hints.Add($"inverse:{nav.InverseProperty}"); - - return hints.Count > 0 ? string.Join(", ", hints) : string.Empty; - } - - /// - /// 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.MetaWriter.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaWriter.cs new file mode 100644 index 0000000..36fb25e --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaWriter.cs @@ -0,0 +1,245 @@ +using System.Collections; +using System.Reflection; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Write meta section only (for MetaOnly mode). + /// + private static void WriteMetaSectionOnly(Type type, ToonSerializationContext context) + { + WriteMetaSection(type, context); + } + + /// + /// Write @meta and @types sections for a single root type. + /// + private static void WriteMetaSection(Type type, ToonSerializationContext context) + { + if (!context.Options.UseMeta) return; + + // Collect all types that need metadata + var typesToDocument = new HashSet(); + CollectTypes(type, typesToDocument); + + WriteMetaSectionCore(typesToDocument, context); + } + + /// + /// Write @meta and @types sections for a collection of types. + /// + private static void WriteMetaSection(IEnumerable types, ToonSerializationContext context) + { + if (!context.Options.UseMeta) return; + + // Collect all types that need metadata (including nested types) + var typesToDocument = new HashSet(); + foreach (var type in types) + { + CollectTypes(type, typesToDocument); + } + + WriteMetaSectionCore(typesToDocument, context); + } + + /// + /// Core logic for writing @meta and @types sections. + /// + private static void WriteMetaSectionCore(HashSet typesToDocument, ToonSerializationContext context) + { + // @meta header + context.WriteLine("@meta {"); + context.CurrentIndentLevel++; + context.WriteProperty("version", $"\"{FormatVersion}\""); + context.WriteProperty("format", "\"toon\""); + context.WriteProperty("source-code-language", "\"C#\""); + + // Write type list + context.WriteIndent(); + context.Write("types"); + + // Token optimization: no spaces around '=' when indentation is disabled + if (context.Options.UseIndentation) + { + context.Write(" = "); + } + else + { + context.Write('='); + } + + // Szűrés: csak komplex típusok (class/struct) és enum-ok, primitívek nélkül + var filteredTypes = typesToDocument + .Where(t => t.IsEnum || !IsPrimitiveOrStringFast(t)) // Enum-ok megtartása, primitívek és string kizárása + .Where(t => !typeof(IEnumerable).IsAssignableFrom(t) || t == typeof(string)) + .Where(t => !t.IsGenericType || t.IsGenericTypeDefinition) + .Where(t => !t.Name.StartsWith("List`", StringComparison.Ordinal) && !t.Name.StartsWith("ICollection`", StringComparison.Ordinal) && !t.Name.StartsWith("IEnumerable`", StringComparison.Ordinal) && !t.Name.StartsWith("Dictionary`", StringComparison.Ordinal)) + .ToList(); + + // DAG topological sort: dependencies first + var sortedTypes = TopologicalSortTypes(filteredTypes); + + context.Write("["); + var first = true; + foreach (var t in sortedTypes) + { + if (!first) context.Write(", "); + context.Write($"\"{t.Name}\""); + first = false; + } + context.WriteLine("]"); + + context.CurrentIndentLevel--; + context.WriteLine("}"); + + // Token optimization: skip empty line when indentation is disabled + if (context.Options.UseIndentation) + { + context.WriteLine(); + } + + // @types section with descriptions (same sorted order) + context.WriteLine("@types {"); + context.CurrentIndentLevel++; + + foreach (var t in sortedTypes) + { + WriteTypeDefinition(t, context); + } + + context.CurrentIndentLevel--; + context.WriteLine("}"); + } + + /// + /// Recursively collects all types that need metadata documentation. + /// Uses the same logic as AcBinarySerializer.RegisterMetadataForType. + /// + private static void CollectTypes(Type type, HashSet types) + { + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + // Enum-ok: hozzáadjuk és return (nem traversáljuk tovább) + if (underlyingType.IsEnum) + { + types.Add(underlyingType); + return; + } + + // Primitívek és string: kihagyjuk + if (IsPrimitiveOrStringFast(underlyingType)) return; + + if (!types.Add(underlyingType)) return; // Already processed + + // Handle dictionaries FIRST (before generic IEnumerable check) + if (IsDictionaryType(underlyingType, out var keyType, out var valueType)) + { + if (keyType != null) CollectTypes(keyType, types); + if (valueType != null) CollectTypes(valueType, types); + return; + } + + // Handle collections/arrays (matches AcBinarySerializer logic) + if (typeof(IEnumerable).IsAssignableFrom(underlyingType) && !ReferenceEquals(underlyingType, StringType)) + { + var elementType = GetCollectionElementType(underlyingType); + if (elementType != null) + { + CollectTypes(elementType, types); + } + return; + } + + // Handle object properties (traverse type graph) + var metadata = GetTypeMetadata(underlyingType); + + // Detektáljuk az enum backing field-eket (NotMapped enum property-k) + DetectAndCollectEnumBackingFields(underlyingType, types); + + foreach (var prop in metadata.Properties) + { + CollectTypes(prop.PropertyType, types); + } + } + + /// + /// Detektálja az enum backing field-eket és hozzáadja az enum típusokat a types hash set-hez. + /// Logika: Ha van egy enum property (bármilyen) és van egy {PropertyName}Id int property, + /// akkor az enum típust hozzáadjuk. + /// + private static void DetectAndCollectEnumBackingFields(Type type, HashSet types) + { + var allProperties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in allProperties) + { + // Csak enum property-ket vizsgálunk + if (!prop.PropertyType.IsEnum) continue; + + // Keressük meg a {PropertyName}Id backing field-et + var backingFieldName = $"{prop.Name}Id"; + var backingField = allProperties.FirstOrDefault(p => + p.Name == backingFieldName && + (p.PropertyType == typeof(int) || p.PropertyType == typeof(int?))); + + if (backingField != null) + { + // Megtaláltuk az enum backing field párt → hozzáadjuk az enum típust + types.Add(prop.PropertyType); + } + } + } + + /// + /// Megkeresi a property-hez tartozó enum típust, ha az egy enum backing field. + /// Visszaadja az enum típust CSAK akkor, ha: + /// 1. Van megfelelő nevű enum property (naming convention) + /// 2. Az enum property NotMapped/NotColumn/JsonIgnore attribútummal van ellátva + /// 3. A backing field típusa megegyezik az enum underlying type-jával (int/int?, byte/byte?, stb.) + /// + private static Type? FindEnumTypeForBackingField(PropertyInfo backingFieldProperty, Type declaringType) + { + // Ha a property neve "...Id"-re végződik, keressük meg az enum property-t + if (!backingFieldProperty.Name.EndsWith("Id", StringComparison.Ordinal)) + return null; + + var enumPropertyName = backingFieldProperty.Name.Substring(0, backingFieldProperty.Name.Length - 2); + var allProperties = declaringType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + var enumProperty = allProperties.FirstOrDefault(p => + p.Name == enumPropertyName && + (p.PropertyType.IsEnum || (Nullable.GetUnderlyingType(p.PropertyType)?.IsEnum ?? false))); + + if (enumProperty == null) return null; + + // Ellenőrizzük, hogy NotMapped/NotColumn/JsonIgnore van-e rajta + var isNotMapped = enumProperty.GetCustomAttributes(inherit: true).Any(attr => + { + var attrName = attr.GetType().Name; + return attrName == "NotMappedAttribute" || + attrName == "NotColumnAttribute" || + attrName == "JsonIgnoreAttribute"; + }); + + // Csak akkor backing field, ha az enum property NotMapped! + if (!isNotMapped) return null; + + // Ellenőrizzük, hogy a backing field típusa megegyezik-e az enum underlying type-jával + var enumType = Nullable.GetUnderlyingType(enumProperty.PropertyType) ?? enumProperty.PropertyType; + var enumUnderlyingType = Enum.GetUnderlyingType(enumType); + + // A backing field lehet nullable vagy non-nullable is + var backingUnderlyingType = Nullable.GetUnderlyingType(backingFieldProperty.PropertyType) + ?? backingFieldProperty.PropertyType; + + // Az underlying type-oknak meg kell egyezniük + if (backingUnderlyingType != enumUnderlyingType) + return null; + + // Visszaadjuk az enum típust (nullable nélkül) + return enumType; + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.Navigation.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.Navigation.cs new file mode 100644 index 0000000..7603b0f --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.Navigation.cs @@ -0,0 +1,251 @@ +using System.Collections; +using System.Reflection; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// 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. + /// + internal static AcNavigationPropertyInfo DetectNavigationMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription) + { + // 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention + bool isPrimaryKey; + if (customDescription?.IsPrimaryKey.HasValue == true) + { + isPrimaryKey = customDescription.IsPrimaryKey.Value; + } + else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property)) + { + isPrimaryKey = true; + } + else + { + isPrimaryKey = IsConventionPrimaryKey(property); + } + + // 2. Navigation type detection (needed early for FK detection logic) + ToonRelationType? navigationType = null; + if (customDescription?.Navigation.HasValue == true) + { + navigationType = customDescription.Navigation.Value; + } + else + { + var linq2dbNav = TryGetLinq2DbNavigation(property); + if (linq2dbNav.HasValue) + { + navigationType = linq2dbNav.Value; + } + else + { + navigationType = DetectConventionNavigation(property); + } + } + + // 3. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention + string? foreignKey = null; + if (!string.IsNullOrEmpty(customDescription?.ForeignKey)) + { + foreignKey = customDescription.ForeignKey; + } + else + { + var efCoreFk = TryGetEFCoreForeignKey(property); + if (!string.IsNullOrEmpty(efCoreFk)) + { + foreignKey = efCoreFk; + } + else + { + // For FK properties (int/Guid/etc ending with Id) + foreignKey = DetectConventionForeignKey(property); + + // For navigation properties (ManyToOne/OneToOne), find the FK property name + if (string.IsNullOrEmpty(foreignKey) && + (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne)) + { + foreignKey = DetectForeignKeyForNavigationProperty(property); + } + } + } + + // 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty] -> Convention + string? inverseProperty = null; + if (!string.IsNullOrEmpty(customDescription?.InverseProperty)) + { + inverseProperty = customDescription.InverseProperty; + } + else + { + var efCoreInverse = TryGetEFCoreInverseProperty(property); + if (!string.IsNullOrEmpty(efCoreInverse)) + { + inverseProperty = efCoreInverse; + } + else + { + // Fallback: convention-based detection + inverseProperty = DetectConventionInverseProperty(property, navigationType); + } + } + + // 5. OtherKey - Detect FK on the other side (for OneToMany) + string? otherKey = DetectOtherKey(property, navigationType, inverseProperty); + + // 6. TargetType - Extract target entity type + Type? targetType = null; + if (navigationType == ToonRelationType.OneToMany || navigationType == ToonRelationType.ManyToMany) + { + targetType = GetCollectionElementType(property.PropertyType); + } + else if (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne) + { + targetType = property.PropertyType; + } + + return new AcNavigationPropertyInfo( + isPrimaryKey: isPrimaryKey, + navigationType: navigationType, + foreignKey: foreignKey, + otherKey: otherKey, + inverseProperty: inverseProperty, + targetType: targetType); + } + + /// + /// Convention: Detect navigation type based on property type. + /// Type-based validation with FK lookup: + /// - ICollection<T> or List<T> -> OneToMany (if T has primary key) + /// - Complex object type -> ManyToOne (if has corresponding FK property and has primary key) + /// + private static ToonRelationType? DetectConventionNavigation(PropertyInfo property) + { + var propertyType = property.PropertyType; + + // Skip primitive types and strings + if (IsPrimitiveOrStringFast(propertyType)) + { + return null; + } + + // Check for collection types (OneToMany) + if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) + { + // Extract element type from collection + var elementType = GetCollectionElementType(propertyType); + if (elementType != null && HasPrimaryKeyProperty(elementType)) + { + // It's a collection of entities -> OneToMany + return ToonRelationType.OneToMany; + } + return null; + } + + // Complex object type -> check if it's a navigation property + // Type-based validation: must have a primary key + if (!HasPrimaryKeyProperty(propertyType)) + { + return null; + } + + // Look for a corresponding foreign key property to confirm it's a navigation + var declaringType = property.DeclaringType; + if (declaringType != null) + { + var foreignKeyPropertyName = property.Name + "Id"; + var foreignKeyProperty = declaringType.GetProperty(foreignKeyPropertyName, BindingFlags.Public | BindingFlags.Instance); + + // Type-based validation: FK must be a value type + if (foreignKeyProperty != null && foreignKeyProperty.PropertyType.IsValueType) + { + // Found corresponding foreign key -> ManyToOne + return ToonRelationType.ManyToOne; + } + } + + // No corresponding foreign key found + // Return null to avoid false positives + return null; + } + + /// + /// 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 elementMetadata = GetTypeMetadata(elementType); + foreach (var elementProp in elementMetadata.Properties) + { + if (IsTypeMatch(elementProp.PropertyType, declaringType)) + return elementProp.Name; + } + } + else if (navigationType == ToonRelationType.ManyToOne) + { + // ManyToOne: look for OneToMany collection in related type that points back + var relatedMetadata = GetTypeMetadata(property.PropertyType); + foreach (var relatedProp in relatedMetadata.Properties) + { + if (typeof(IEnumerable).IsAssignableFrom(relatedProp.PropertyType) && + relatedProp.PropertyType != typeof(string) && + IsTypeMatch(GetCollectionElementType(relatedProp.PropertyType), declaringType)) + { + return relatedProp.Name; + } + } + } + + return null; + } + + /// + /// Detect table name for a type with fallback chain. + /// Priority: ToonDescription.TableName -> EF Core [Table] -> Linq2Db [Table] -> Convention (class name). + /// + internal static string? DetectTableName(Type type, ToonDescriptionAttribute? customDescription) + { + // 1. ToonDescription.TableName (explicit override) + if (!string.IsNullOrEmpty(customDescription?.TableName)) + { + return customDescription.TableName; + } + + // 2. EF Core [Table("name")] attribute (primary) + var efCoreTable = TryGetEFCoreTableName(type); + if (!string.IsNullOrEmpty(efCoreTable)) + { + return efCoreTable; + } + + // 3. Linq2Db [Table(Name = "name")] attribute (if EF Core not found) + var linq2dbTable = TryGetLinq2DbTableName(type); + if (!string.IsNullOrEmpty(linq2dbTable)) + { + return linq2dbTable; + } + + // 4. Convention: class name (fallback) + return type.Name; + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.Placeholders.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.Placeholders.cs new file mode 100644 index 0000000..b6f5ea2 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.Placeholders.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using System.Reflection; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Resolve [#...] placeholders in type description string. + /// + private static string ResolveTypeDescriptionPlaceholders(string template, Type type) + { + var result = template; + + // [#Description] → Microsoft [Description] + if (result.Contains("[#Description]")) + { + var msDesc = type.GetCustomAttribute(); + var value = msDesc?.Description ?? ""; + result = result.Replace("[#Description]", value); + } + + // [#DisplayName] → Microsoft [DisplayName] + if (result.Contains("[#DisplayName]")) + { + var displayName = type.GetCustomAttribute(); + var value = displayName?.DisplayName ?? type.Name; + result = result.Replace("[#DisplayName]", value); + } + + // [#SmartDescription] → Smart inference + if (result.Contains("[#SmartDescription]")) + { + var value = GetTypeDescription(type); + result = result.Replace("[#SmartDescription]", value); + } + + return CleanupPlaceholders(result); + } + + /// + /// Resolve [#...] placeholders in type purpose string. + /// + private static string ResolveTypePurposePlaceholders(string template, Type type) + { + var result = template; + + // [#SmartPurpose] → Would be empty for classes, so just remove it + if (result.Contains("[#SmartPurpose]")) + { + result = result.Replace("[#SmartPurpose]", ""); + } + + return CleanupPlaceholders(result); + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs deleted file mode 100644 index ce7af4b..0000000 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs +++ /dev/null @@ -1,642 +0,0 @@ -using System.Collections; -using System.Reflection; -using static AyCode.Core.Helpers.JsonUtilities; - -namespace AyCode.Core.Serializers.Toons; - -public static partial class AcToonSerializer -{ - /// - /// 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. - /// - internal static AcNavigationPropertyInfo DetectNavigationMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription) - { - // 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention - bool isPrimaryKey; - if (customDescription?.IsPrimaryKey.HasValue == true) - { - isPrimaryKey = customDescription.IsPrimaryKey.Value; - } - else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property)) - { - isPrimaryKey = true; - } - else - { - isPrimaryKey = IsConventionPrimaryKey(property); - } - - // 2. Navigation type detection (needed early for FK detection logic) - ToonRelationType? navigationType = null; - if (customDescription?.Navigation.HasValue == true) - { - navigationType = customDescription.Navigation.Value; - } - else - { - var linq2dbNav = TryGetLinq2DbNavigation(property); - if (linq2dbNav.HasValue) - { - navigationType = linq2dbNav.Value; - } - else - { - navigationType = DetectConventionNavigation(property); - } - } - - // 3. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention - string? foreignKey = null; - if (!string.IsNullOrEmpty(customDescription?.ForeignKey)) - { - foreignKey = customDescription.ForeignKey; - } - else - { - var efCoreFk = TryGetEFCoreForeignKey(property); - if (!string.IsNullOrEmpty(efCoreFk)) - { - foreignKey = efCoreFk; - } - else - { - // For FK properties (int/Guid/etc ending with Id) - foreignKey = DetectConventionForeignKey(property); - - // For navigation properties (ManyToOne/OneToOne), find the FK property name - if (string.IsNullOrEmpty(foreignKey) && - (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne)) - { - foreignKey = DetectForeignKeyForNavigationProperty(property); - } - } - } - - // 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty] -> Convention - string? inverseProperty = null; - if (!string.IsNullOrEmpty(customDescription?.InverseProperty)) - { - inverseProperty = customDescription.InverseProperty; - } - else - { - var efCoreInverse = TryGetEFCoreInverseProperty(property); - if (!string.IsNullOrEmpty(efCoreInverse)) - { - inverseProperty = efCoreInverse; - } - else - { - // Fallback: convention-based detection - inverseProperty = DetectConventionInverseProperty(property, navigationType); - } - } - - // 5. OtherKey - Detect FK on the other side (for OneToMany) - string? otherKey = DetectOtherKey(property, navigationType, inverseProperty); - - // 6. TargetType - Extract target entity type - Type? targetType = null; - if (navigationType == ToonRelationType.OneToMany || navigationType == ToonRelationType.ManyToMany) - { - targetType = GetCollectionElementType(property.PropertyType); - } - else if (navigationType == ToonRelationType.ManyToOne || navigationType == ToonRelationType.OneToOne) - { - targetType = property.PropertyType; - } - - return new AcNavigationPropertyInfo( - isPrimaryKey: isPrimaryKey, - navigationType: navigationType, - foreignKey: foreignKey, - otherKey: otherKey, - inverseProperty: inverseProperty, - targetType: targetType); - } - - /// - /// Convention: Property named "Id" or "{TypeName}Id" is a primary key. - /// - private static bool IsConventionPrimaryKey(PropertyInfo property) - { - if (property.PropertyType != typeof(int) && - property.PropertyType != typeof(long) && - property.PropertyType != typeof(Guid)) - { - return false; - } - - return property.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || - property.Name.Equals(property.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Convention: Property named "{TypeName}Id" with a corresponding "{TypeName}" navigation property is a foreign key. - /// Type-based validation: checks that navigation property is a complex type (not primitive/string). - /// Example: "CompanyId" + "Company" property exists and is complex type -> foreign key to "Company". - /// - private static string? DetectConventionForeignKey(PropertyInfo property) - { - // Skip non-value types (we don't check specific FK type, could be int/long/Guid/etc) - if (!property.PropertyType.IsValueType) - { - return null; - } - - // Check if property name ends with "Id" - if (!property.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - // Extract potential navigation property name - var navigationPropertyName = property.Name.Substring(0, property.Name.Length - 2); - - // Check if corresponding navigation property exists - var declaringType = property.DeclaringType; - if (declaringType == null) - { - return null; - } - - var navigationProperty = declaringType.GetProperty(navigationPropertyName, BindingFlags.Public | BindingFlags.Instance); - - // Type-based validation: navigation property must exist and be a complex type (not primitive/string) - if (navigationProperty == null || IsPrimitiveOrStringFast(navigationProperty.PropertyType)) - { - return null; - } - - // Additional validation: navigation property type should have a primary key (IsPrimaryKey or Id property) - if (!HasPrimaryKeyProperty(navigationProperty.PropertyType)) - { - return null; - } - - return navigationPropertyName; - } - - /// - /// Convention: Detect navigation type based on property type. - /// Type-based validation with FK lookup: - /// - ICollection<T> or List<T> -> OneToMany (if T has primary key) - /// - Complex object type -> ManyToOne (if has corresponding FK property and has primary key) - /// - private static ToonRelationType? DetectConventionNavigation(PropertyInfo property) - { - var propertyType = property.PropertyType; - - // Skip primitive types and strings - if (IsPrimitiveOrStringFast(propertyType)) - { - return null; - } - - // Check for collection types (OneToMany) - if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) - { - // Extract element type from collection - var elementType = GetCollectionElementType(propertyType); - if (elementType != null && HasPrimaryKeyProperty(elementType)) - { - // It's a collection of entities -> OneToMany - return ToonRelationType.OneToMany; - } - return null; - } - - // Complex object type -> check if it's a navigation property - // Type-based validation: must have a primary key - if (!HasPrimaryKeyProperty(propertyType)) - { - return null; - } - - // Look for a corresponding foreign key property to confirm it's a navigation - var declaringType = property.DeclaringType; - if (declaringType != null) - { - var foreignKeyPropertyName = property.Name + "Id"; - var foreignKeyProperty = declaringType.GetProperty(foreignKeyPropertyName, BindingFlags.Public | BindingFlags.Instance); - - // Type-based validation: FK must be a value type - if (foreignKeyProperty != null && foreignKeyProperty.PropertyType.IsValueType) - { - // Found corresponding foreign key -> ManyToOne - return ToonRelationType.ManyToOne; - } - } - - // No corresponding foreign key found - // Return null to avoid false positives - return null; - } - - /// - /// Check if a type has a primary key property. - /// First checks for IsPrimaryKey=true in ToonDescription, then falls back to "Id" property. - /// - private static bool HasPrimaryKeyProperty(Type type) - { - if (IsPrimitiveOrStringFast(type)) - { - return false; - } - - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - // First: check for ToonDescription with IsPrimaryKey = true - foreach (var prop in properties) - { - var customDescription = prop.GetCustomAttribute(); - if (customDescription?.IsPrimaryKey == true) - { - return true; - } - } - - // Fallback: check for "Id" or "{TypeName}Id" property - foreach (var prop in properties) - { - if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase)) - { - // Must be a value type (int, long, Guid, etc.) - if (prop.PropertyType.IsValueType) - { - return true; - } - } - } - - return false; - } - - #region EF Core Attribute Detection (reflection-based, no dependency) - - /// - /// Detect EF Core [Key] attribute via reflection (no EF Core dependency). - /// - private static bool TryGetEFCoreKey(PropertyInfo property) - { - var keyAttr = property.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "KeyAttribute"); - return keyAttr != null; - } - - /// - /// Detect EF Core [ForeignKey("NavigationPropertyName")] attribute via reflection. - /// - private static string? TryGetEFCoreForeignKey(PropertyInfo property) - { - var fkAttr = property.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "ForeignKeyAttribute"); - - if (fkAttr != null) - { - // Get the Name property value (navigation property name) - var nameProp = fkAttr.GetType().GetProperty("Name"); - return nameProp?.GetValue(fkAttr) as string; - } - - return null; - } - - /// - /// Detect EF Core [InverseProperty("PropertyName")] attribute via reflection. - /// - private static string? TryGetEFCoreInverseProperty(PropertyInfo property) - { - var inversePropAttr = property.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "InversePropertyAttribute"); - - if (inversePropAttr != null) - { - var propertyProp = inversePropAttr.GetType().GetProperty("Property"); - return propertyProp?.GetValue(inversePropAttr) as string; - } - - return null; - } - - #endregion - - #region Linq2Db Attribute Detection (reflection-based, no dependency) - - /// - /// Detect Linq2Db [PrimaryKey] attribute via reflection (no Linq2Db dependency). - /// - private static bool TryGetLinq2DbPrimaryKey(PropertyInfo property) - { - var pkAttr = property.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "PrimaryKeyAttribute"); - return pkAttr != null; - } - - /// - /// Detect Linq2Db [Association] attribute and determine navigation type. - /// Supports simple ThisKey/OtherKey associations. Expression-based associations are skipped. - /// - private static ToonRelationType? TryGetLinq2DbNavigation(PropertyInfo property) - { - var assocAttr = property.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "AssociationAttribute"); - - if (assocAttr == null) - return null; - - // Check if it's expression-based (has QueryExpressionMethod property set) - var queryExprMethod = assocAttr.GetType().GetProperty("QueryExpressionMethod")?.GetValue(assocAttr) as string; - if (!string.IsNullOrEmpty(queryExprMethod)) - { - // Expression-based many-to-many - too complex, skip and fallback to convention - return null; - } - - // Simple association with ThisKey/OtherKey - var thisKey = assocAttr.GetType().GetProperty("ThisKey")?.GetValue(assocAttr) as string; - var otherKey = assocAttr.GetType().GetProperty("OtherKey")?.GetValue(assocAttr) as string; - - var propertyType = property.PropertyType; - - // Check if it's a collection (OneToMany or ManyToMany) - if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) - { - // Collection with ThisKey + OtherKey -> could be ManyToMany or OneToMany - // If both ThisKey and OtherKey are set -> likely ManyToMany - // If only ThisKey -> OneToMany - if (!string.IsNullOrEmpty(thisKey) && !string.IsNullOrEmpty(otherKey)) - { - // Could be ManyToMany, but without junction table info, treat as OneToMany - return ToonRelationType.OneToMany; - } - else - { - return ToonRelationType.OneToMany; - } - } - else - { - // Single object -> ManyToOne or OneToOne - // Typically ManyToOne - return ToonRelationType.ManyToOne; - } - } - - #endregion - - #region TableName Detection (class-level) - - /// - /// Detect table name for a type with fallback chain. - /// Priority: ToonDescription.TableName -> EF Core [Table] -> Linq2Db [Table] -> Convention (class name). - /// - internal static string? DetectTableName(Type type, ToonDescriptionAttribute? customDescription) - { - // 1. ToonDescription.TableName (explicit override) - if (!string.IsNullOrEmpty(customDescription?.TableName)) - { - return customDescription.TableName; - } - - // 2. EF Core [Table("name")] attribute (primary) - var efCoreTable = TryGetEFCoreTableName(type); - if (!string.IsNullOrEmpty(efCoreTable)) - { - return efCoreTable; - } - - // 3. Linq2Db [Table(Name = "name")] attribute (if EF Core not found) - var linq2dbTable = TryGetLinq2DbTableName(type); - if (!string.IsNullOrEmpty(linq2dbTable)) - { - return linq2dbTable; - } - - // 4. Convention: class name (fallback) - return type.Name; - } - - /// - /// Detect EF Core [Table("name")] or [Table(Name = "name", Schema = "schema")] attribute via reflection. - /// - private static string? TryGetEFCoreTableName(Type type) - { - var tableAttr = type.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); - - if (tableAttr != null) - { - // EF Core TableAttribute has "Name" property - var nameProp = tableAttr.GetType().GetProperty("Name"); - return nameProp?.GetValue(tableAttr) as string; - } - - return null; - } - - /// - /// Detect Linq2Db [Table(Name = "name")] attribute via reflection. - /// - private static string? TryGetLinq2DbTableName(Type type) - { - var tableAttr = type.GetCustomAttributes() - .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); - - if (tableAttr != null) - { - // Linq2Db TableAttribute also has "Name" property - var nameProp = tableAttr.GetType().GetProperty("Name"); - return nameProp?.GetValue(tableAttr) as string; - } - - return null; - } - - #endregion - - #region Convention-based Inverse Property Detection - - /// - /// 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 d66ad95..d9d69f0 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonTypeMetadata.cs @@ -22,7 +22,7 @@ public static partial class AcToonSerializer public Type? ElementType { get; } public ToonDescriptionAttribute? CustomDescription { get; } - public ToonTypeMetadata(Type type) : base(type) + public ToonTypeMetadata(Type type) : base(type, HasToonIgnoreAttribute) { TypeName = type.FullName ?? type.Name; ShortTypeName = type.Name; @@ -45,7 +45,7 @@ public static partial class AcToonSerializer // Build property accessors using shared GetSerializableProperties from base if (!IsCollection && !IsDictionary && !IsPrimitiveOrStringFast(type)) { - var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: false) + var props = GetSerializableProperties(type, requiresWrite: false) .Select(p => new ToonPropertyAccessor(p, type)) .ToArray(); @@ -67,7 +67,14 @@ public static partial class AcToonSerializer public PropertyInfo PropertyInfo { get; } public string TypeDisplayName { get; } public ToonDescriptionAttribute? CustomDescription { get; } - public AcNavigationPropertyInfo Navigation { get; } + + private AcNavigationPropertyInfo? _navigation; + + /// + /// Navigation metadata is lazily initialized to avoid circular reference issues + /// when types reference each other (e.g., OrderDto ? OrderItemDto). + /// + public AcNavigationPropertyInfo Navigation => _navigation ??= DetectNavigationMetadata(PropertyInfo, CustomDescription); public ToonPropertyAccessor(PropertyInfo prop, Type declaringType) : base(prop, declaringType) @@ -79,9 +86,6 @@ public static partial class AcToonSerializer // Get custom attribute if present CustomDescription = prop.GetCustomAttribute(); - - // Detect and cache navigation metadata once - Navigation = DetectNavigationMetadata(prop, CustomDescription); } /// diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.TopologicalSort.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.TopologicalSort.cs new file mode 100644 index 0000000..ee19bb5 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.TopologicalSort.cs @@ -0,0 +1,134 @@ +using System.Collections; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// 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.TypeDefinitions.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.TypeDefinitions.cs new file mode 100644 index 0000000..40e01a3 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.TypeDefinitions.cs @@ -0,0 +1,299 @@ +using System.Reflection; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Write type definition with property descriptions. + /// + private static void WriteTypeDefinition(Type type, ToonSerializationContext context) + { + // Handle enum types specially + if (type.IsEnum) + { + WriteEnumTypeDefinition(type, context); + return; + } + + var metadata = GetTypeMetadata(type); + + // Type description with fallback chain and placeholder resolution + var typeDescription = GetFinalTypeDescription(type, metadata); + context.WriteIndentedLine($"{type.Name}: \"{typeDescription}\""); + + // Növeljük az indent-et a type body-hoz (type-level metadata ÉS property-k) + context.CurrentIndentLevel++; + + // Type-level metadata (enhanced mode only) + if (context.Options.UseEnhancedMetadata) + { + // Table name + var tableName = DetectTableName(type, metadata.CustomDescription); + if (!string.IsNullOrEmpty(tableName)) + { + context.WriteIndentedLine($"table-name: \"{tableName}\""); + } + + // Related type + var typeRelation = metadata.CustomDescription?.TypeRelation; + var relatedTypes = metadata.CustomDescription?.RelatedTypes; + if (!string.IsNullOrEmpty(typeRelation) && relatedTypes != null && relatedTypes.Length > 0) + { + var relatedTypeStr = FormatRelatedType(typeRelation, relatedTypes); + if (!string.IsNullOrEmpty(relatedTypeStr)) + { + context.WriteIndentedLine($"related-type: \"{relatedTypeStr}\""); + } + } + + // Purpose + var typePurpose = GetFinalTypePurpose(type, metadata); + if (!string.IsNullOrEmpty(typePurpose)) + { + context.WriteIndentedLine($"purpose: \"{typePurpose}\""); + } + } + + if (metadata.Properties.Length == 0) + { + context.CurrentIndentLevel--; + return; + } + + // Property descriptions (már indent szint 1-en vagyunk, property-k is ide kerülnek) + foreach (var prop in metadata.Properties) + { + // Get final values with fallback chain + placeholder resolution + var propDescription = GetFinalPropertyDescription(prop, type); + var purpose = GetFinalPropertyPurpose(prop, type); + var constraints = GetFinalPropertyConstraints(prop, type); + var examples = GetFinalPropertyExamples(prop); + + // Use cached navigation metadata from property accessor + var nav = prop.Navigation; + + var typeHint = prop.TypeDisplayName; + + if (context.Options.UseEnhancedMetadata) + { + // Enhanced format with constraints and purpose + context.WriteIndentedLine($"{prop.Name}: {typeHint}"); + context.CurrentIndentLevel++; + + // Only write description if explicitly provided (not null/empty) + if (!string.IsNullOrEmpty(propDescription)) + context.WriteIndentedLine($"description: \"{propDescription}\""); + + if (!string.IsNullOrEmpty(purpose)) + context.WriteIndentedLine($"purpose: \"{purpose}\""); + if (!string.IsNullOrEmpty(constraints)) + context.WriteIndentedLine($"constraints: \"{constraints}\""); + if (!string.IsNullOrEmpty(examples)) + context.WriteIndentedLine($"examples: \"{examples}\""); + + // Add relationship metadata from cached navigation info + if (nav.IsPrimaryKey) + context.WriteIndentedLine($"primary-key: true"); + + // Only write foreign-key for navigation properties, not for FK properties themselves + if (!string.IsNullOrEmpty(nav.ForeignKey) && nav.NavigationType.HasValue) + context.WriteIndentedLine($"foreign-key: \"{nav.ForeignKey}\""); + + if (!string.IsNullOrEmpty(nav.OtherKey)) + context.WriteIndentedLine($"other-key: \"{nav.OtherKey}\""); + if (nav.NavigationType.HasValue) + { + var navType = nav.NavigationType.Value.ToString(); + var navTypeFormatted = string.Concat(navType.Select((c, i) => i > 0 && char.IsUpper(c) ? "-" + char.ToLower(c) : char.ToLower(c).ToString())); + context.WriteIndentedLine($"navigation: \"{navTypeFormatted}\""); + } + if (!string.IsNullOrEmpty(nav.InverseProperty)) + context.WriteIndentedLine($"inverse-property: \"{nav.InverseProperty}\""); + + context.CurrentIndentLevel--; + } + else + { + // Simple format + var relationshipHint = GetRelationshipHint(nav); + + // Only write description if there's explicit description OR relationship metadata + if (!string.IsNullOrEmpty(propDescription) || !string.IsNullOrEmpty(relationshipHint)) + { + var fullDescription = string.IsNullOrEmpty(relationshipHint) + ? propDescription + : string.IsNullOrEmpty(propDescription) + ? relationshipHint + : $"{propDescription} ({relationshipHint})"; + context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{fullDescription}\""); + } + else + { + // No description and no relationship metadata - just write type hint + context.WriteIndentedLine($"{prop.Name}: {typeHint}"); + } + } + } + context.CurrentIndentLevel--; + + // Token optimization: skip empty line when indentation is disabled + if (context.Options.UseIndentation) + { + context.WriteLine(); + } + } + + /// + /// Write enum type definition with values and metadata. + /// + private static void WriteEnumTypeDefinition(Type enumType, ToonSerializationContext context) + { + var customDescription = enumType.GetCustomAttribute(); + + // Type description with fallback + var typeDescription = GetFinalEnumDescription(enumType, customDescription); + context.WriteIndentedLine($"{enumType.Name}: enum"); + + if (context.Options.UseEnhancedMetadata) + { + context.CurrentIndentLevel++; + + // Description + if (!string.IsNullOrEmpty(typeDescription)) + { + context.WriteIndentedLine($"description: \"{typeDescription}\""); + } + + // Purpose + var typePurpose = customDescription?.Purpose; + if (!string.IsNullOrEmpty(typePurpose)) + { + context.WriteIndentedLine($"purpose: \"{typePurpose}\""); + } + + // Underlying type + var underlyingType = Enum.GetUnderlyingType(enumType); + var underlyingTypeName = AcSerializerCommon.GetCSharpTypeName(underlyingType, useShortNames: true); + context.WriteIndentedLine($"underlying-type: \"{underlyingTypeName}\""); + + // Default value (first enum member) + var enumValues = Enum.GetValues(enumType); + if (enumValues.Length > 0) + { + var firstValue = enumValues.GetValue(0); + var firstValueNumeric = Convert.ChangeType(firstValue, underlyingType); + context.WriteIndentedLine($"default-value: {firstValueNumeric}"); + } + + // Enum members with descriptions + context.WriteIndentedLine("values:"); + context.CurrentIndentLevel++; + + var names = Enum.GetNames(enumType); + foreach (var name in names) + { + var field = enumType.GetField(name); + var value = field?.GetValue(null); + var numericValue = Convert.ChangeType(value, underlyingType); + + // Get member description from ToonDescription attribute + var memberDescription = field?.GetCustomAttribute(); + var description = memberDescription?.Description; + + // Token optimization: format depends on indentation setting + var separator = context.Options.UseIndentation ? " = " : "="; + + if (!string.IsNullOrEmpty(description)) + { + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + context.CurrentIndentLevel++; + context.WriteIndentedLine($"description: \"{description}\""); + context.CurrentIndentLevel--; + } + else + { + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + } + } + + context.CurrentIndentLevel--; + context.CurrentIndentLevel--; + } + else + { + // Simple format - just show values + var names = Enum.GetNames(enumType); + var underlyingType = Enum.GetUnderlyingType(enumType); + context.CurrentIndentLevel++; + + // Token optimization: format depends on indentation setting + var separator = context.Options.UseIndentation ? " = " : "="; + + foreach (var name in names) + { + var field = enumType.GetField(name); + var value = field?.GetValue(null); + var numericValue = Convert.ChangeType(value, underlyingType); + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + } + + context.CurrentIndentLevel--; + } + + // Token optimization: skip empty line when indentation is disabled + if (context.Options.UseIndentation) + { + context.WriteLine(); + } + } + + /// + /// Get relationship hint string for simple format output. + /// + private static string GetRelationshipHint(AcNavigationPropertyInfo nav) + { + var hints = new List(); + + if (nav.IsPrimaryKey) + hints.Add("primary-key"); + + if (!string.IsNullOrEmpty(nav.ForeignKey)) + hints.Add($"fk->{nav.ForeignKey}"); + + if (!string.IsNullOrEmpty(nav.OtherKey)) + hints.Add($"other-key:{nav.OtherKey}"); + + if (nav.NavigationType.HasValue) + { + var navType = nav.NavigationType.Value switch + { + ToonRelationType.ManyToOne => "many-to-one", + ToonRelationType.OneToMany => "one-to-many", + ToonRelationType.OneToOne => "one-to-one", + ToonRelationType.ManyToMany => "many-to-many", + _ => null + }; + if (navType != null) + hints.Add(navType); + } + + if (!string.IsNullOrEmpty(nav.InverseProperty)) + hints.Add($"inverse:{nav.InverseProperty}"); + + return hints.Count > 0 ? string.Join(", ", hints) : string.Empty; + } + + /// + /// Format related type metadata for output. + /// Example: "dto-of Order, OrderModel" + /// + private static string FormatRelatedType(string typeRelation, Type[] relatedTypes) + { + if (string.IsNullOrEmpty(typeRelation) || relatedTypes == null || relatedTypes.Length == 0) + return string.Empty; + + var typeNames = string.Join(", ", relatedTypes.Select(t => t.Name)); + return $"{typeRelation} {typeNames}"; + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.Validation.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.Validation.cs new file mode 100644 index 0000000..1a155f3 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.Validation.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using static AyCode.Core.Helpers.JsonUtilities; + +namespace AyCode.Core.Serializers.Toons; + +public static partial class AcToonSerializer +{ + /// + /// Convention: Property named "Id" or "{TypeName}Id" is a primary key. + /// + private static bool IsConventionPrimaryKey(PropertyInfo property) + { + if (property.PropertyType != typeof(int) && + property.PropertyType != typeof(long) && + property.PropertyType != typeof(Guid)) + { + return false; + } + + return property.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals(property.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Check if a type has a primary key property. + /// First checks for IsPrimaryKey=true in ToonDescription, then falls back to "Id" property. + /// + private static bool HasPrimaryKeyProperty(Type type) + { + if (IsPrimitiveOrStringFast(type)) + { + return false; + } + + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + // First: check for ToonDescription with IsPrimaryKey = true + foreach (var prop in properties) + { + var customDescription = prop.GetCustomAttribute(); + if (customDescription?.IsPrimaryKey == true) + { + return true; + } + } + + // Fallback: check for "Id" or "{TypeName}Id" property + foreach (var prop in properties) + { + if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + prop.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase)) + { + // Must be a value type (int, long, Guid, etc.) + if (prop.PropertyType.IsValueType) + { + return true; + } + } + } + + return false; + } + + /// + /// 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; + } +} diff --git a/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs b/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs index a6ed182..ffc10fa 100644 --- a/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs +++ b/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs @@ -219,10 +219,11 @@ public enum ToonRelationType public sealed class ToonDescriptionAttribute : Attribute { /// - /// Gets the human-readable description of the property or type. + /// Gets or sets the human-readable description of the property or type. /// This appears in the @types section to help LLMs understand the data structure. + /// If not specified, fallback chain will be used (Microsoft [Description] -> Smart inference). /// - public string Description { get; } + public string? Description { get; set; } /// /// Gets or sets the purpose of this property (what it's used for). @@ -267,6 +268,19 @@ public sealed class ToonDescriptionAttribute : Attribute /// public string? InverseProperty { get; set; } + /// + /// Gets or sets the business rule or validation logic for this property. + /// Uses a pseudo-code expression format that is both LLM-readable and developer-friendly. + /// The expression uses "this" to refer to the current property value. + /// Examples: + /// - "this >= NetWeight" (comparison with other property) + /// - "this > 0" (simple constraint) + /// - "this.Length <= 100" (property constraint) + /// - "this != null && this.Count > 0" (compound rule) + /// Note: This is descriptive only, not executed. Actual validation should use FluentValidation or similar. + /// + public string? BusinessRule { get; set; } + /// /// Gets or sets the database table name for this entity (class-level only). /// If not explicitly set, will fallback to EF Core [Table] or Linq2Db [Table] attributes, then to class name. @@ -274,13 +288,35 @@ public sealed class ToonDescriptionAttribute : Attribute /// public string? TableName { get; set; } + /// + /// Gets or sets the type relationship string (class-level only). + /// Use constants from ToonTypeRelation class (e.g., ToonTypeRelation.DtoOf). + /// Example: TypeRelation = ToonTypeRelation.DtoOf + /// + public string? TypeRelation { get; set; } + + /// + /// Gets or sets the related types for the type relationship (class-level only). + /// Used together with TypeRelation to declare type relationships. + /// Example: RelatedTypes = new[] { typeof(Order), typeof(OrderModel) } + /// + public Type[]? RelatedTypes { get; set; } + + /// + /// Initializes a new instance of the ToonDescriptionAttribute. + /// All properties are optional and can be set using property initializers. + /// + public ToonDescriptionAttribute() + { + } + /// /// Initializes a new instance of the ToonDescriptionAttribute with the specified description. /// /// Human-readable description of the property or type. public ToonDescriptionAttribute(string description) { - Description = description ?? throw new ArgumentNullException(nameof(description)); + Description = description; } /// diff --git a/AyCode.Core/Serializers/Toons/ToonTypeRelation.cs b/AyCode.Core/Serializers/Toons/ToonTypeRelation.cs new file mode 100644 index 0000000..652398a --- /dev/null +++ b/AyCode.Core/Serializers/Toons/ToonTypeRelation.cs @@ -0,0 +1,49 @@ +namespace AyCode.Core.Serializers.Toons; + +/// +/// Defines type relationship constants for Toon serialization. +/// These string values are directly used in the serialized output. +/// +public static class ToonTypeRelation +{ + /// + /// No special type relationship. + /// + public const string None = ""; + + /// + /// This type is a base class/interface for other types. + /// Example: Order is base-of OrderDto + /// + public const string BaseOf = "base-of"; + + /// + /// This type is derived from another type. + /// Example: OrderDto is derived-from Order + /// + public const string DerivedFrom = "derived-from"; + + /// + /// This type is a Data Transfer Object representation of another type. + /// Example: OrderDto is dto-of Order + /// + public const string DtoOf = "dto-of"; + + /// + /// This type is a Model representation of another type. + /// Example: OrderModel is model-of Order + /// + public const string ModelOf = "model-of"; + + /// + /// This type is a ViewModel representation of another type. + /// Example: OrderViewModel is view-model-of Order + /// + public const string ViewModelOf = "view-model-of"; + + /// + /// This type is a projection/subset of another type. + /// Example: OrderSummary is projection-of Order + /// + public const string ProjectionOf = "projection-of"; +} diff --git a/AyCode.Core/Serializers/TypeMetadataBase.cs b/AyCode.Core/Serializers/TypeMetadataBase.cs index 81ebe6b..dde80b9 100644 --- a/AyCode.Core/Serializers/TypeMetadataBase.cs +++ b/AyCode.Core/Serializers/TypeMetadataBase.cs @@ -1,6 +1,10 @@ +using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using AyCode.Core.Helpers; using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers; @@ -32,16 +36,20 @@ public abstract class TypeMetadataBase /// Key: (Type, requiresWrite) - since requiresRead is always true in practice. /// Value: PropertyInfo[] ordered alphabetically by name for deterministic serialization. /// - private static readonly ConcurrentDictionary<(Type, bool), PropertyInfo[]> OrderedPropertiesCache = new(); + private static readonly ConcurrentDictionary<(Type, bool), List> UnfilteredPropertiesGlobalCache = new(); + private readonly ConcurrentDictionary<(Type, bool), PropertyInfo[]> _orderedPropertiesCache = new(); + + private readonly Func _ignorePropertyFilter; /// /// Compiled parameterless constructor for the type. /// Null if the type is abstract or has no parameterless constructor. /// public Func? CompiledConstructor { get; } - protected TypeMetadataBase(Type type) + protected TypeMetadataBase(Type type, Func ignorePropertyFilter) { + _ignorePropertyFilter = ignorePropertyFilter; CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type); } @@ -56,15 +64,19 @@ public abstract class TypeMetadataBase /// Results are cached per type and requiresWrite combination. /// /// The type to analyze. - /// Whether the property must be readable (always true in practice). /// Whether the property must be writable (true for deserialization). /// Array of properties with stable indices (cached). - public static PropertyInfo[] GetSerializableProperties( - Type type, - bool requiresRead = true, - bool requiresWrite = false) + public PropertyInfo[] GetSerializableProperties(Type type, bool requiresWrite = false) { - return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key => + return _orderedPropertiesCache.GetOrAdd((type, requiresWrite), _ => + { + return GetUnfilteredProperties(type, requiresWrite).Where(propertyInfo => !_ignorePropertyFilter(propertyInfo)).ToArray(); + }); + } + + private static List GetUnfilteredProperties(Type type, bool requiresWrite) + { + return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key => { var (t, needsWrite) = key; @@ -78,15 +90,15 @@ public abstract class TypeMetadataBase var levelProperties = currentType .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(p => p.CanRead && - (!needsWrite || p.CanWrite) && - p.GetIndexParameters().Length == 0 && - !HasJsonIgnoreAttribute(p)) + (!needsWrite || p.CanWrite) && + p.GetIndexParameters().Length == 0 && + !IsUnsupportedPropertyType(p.PropertyType)) .OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level allProperties.AddRange(levelProperties); } - return allProperties.ToArray(); + return allProperties; }); } } @@ -97,8 +109,7 @@ public abstract class TypeMetadataBase /// Each TMetadata type gets its own ThreadStatic cache instance automatically. /// /// The concrete metadata type (must inherit from this class). -public abstract class TypeMetadataBase : TypeMetadataBase - where TMetadata : TypeMetadataBase +public abstract class TypeMetadataBase : TypeMetadataBase where TMetadata : TypeMetadataBase { /// /// ThreadLocal cache for this specific metadata type. @@ -107,7 +118,7 @@ public abstract class TypeMetadataBase : TypeMetadataBase [ThreadStatic] private static Dictionary? t_localCache; - protected TypeMetadataBase(Type type) : base(type) + protected TypeMetadataBase(Type type, Func ignorePropertyFilter) : base(type, ignorePropertyFilter) { }