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