436 lines
15 KiB
C#
436 lines
15 KiB
C#
using System.Collections;
|
|
using System.Reflection;
|
|
using static AyCode.Core.Helpers.JsonUtilities;
|
|
|
|
namespace AyCode.Core.Serializers.Toons;
|
|
|
|
public static partial class AcToonSerializer
|
|
{
|
|
/// <summary>
|
|
/// Relationship metadata for a property.
|
|
/// </summary>
|
|
private sealed class RelationshipMetadata
|
|
{
|
|
public bool IsPrimaryKey { get; set; }
|
|
public string? ForeignKeyNavigationProperty { get; set; }
|
|
public ToonRelationType? NavigationType { get; set; }
|
|
public string? InverseProperty { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detects relationship metadata for a property with fallback chain.
|
|
/// Priority: ToonDescription -> EF Core/Linq2Db attributes -> Convention-based detection.
|
|
/// </summary>
|
|
private static RelationshipMetadata DetectRelationshipMetadata(PropertyInfo property, ToonDescriptionAttribute? customDescription)
|
|
{
|
|
var metadata = new RelationshipMetadata();
|
|
|
|
// 1. IsPrimaryKey - Priority: ToonDescription -> EF Core [Key] -> Linq2Db [PrimaryKey] -> Convention
|
|
if (customDescription?.IsPrimaryKey.HasValue == true)
|
|
{
|
|
metadata.IsPrimaryKey = customDescription.IsPrimaryKey.Value;
|
|
}
|
|
else if (TryGetEFCoreKey(property) || TryGetLinq2DbPrimaryKey(property))
|
|
{
|
|
metadata.IsPrimaryKey = true;
|
|
}
|
|
else
|
|
{
|
|
metadata.IsPrimaryKey = IsConventionPrimaryKey(property);
|
|
}
|
|
|
|
// 2. ForeignKey - Priority: ToonDescription -> EF Core [ForeignKey] -> Linq2Db [Association] -> Convention
|
|
if (!string.IsNullOrEmpty(customDescription?.ForeignKey))
|
|
{
|
|
metadata.ForeignKeyNavigationProperty = customDescription.ForeignKey;
|
|
}
|
|
else
|
|
{
|
|
var efCoreFk = TryGetEFCoreForeignKey(property);
|
|
if (!string.IsNullOrEmpty(efCoreFk))
|
|
{
|
|
metadata.ForeignKeyNavigationProperty = efCoreFk;
|
|
}
|
|
else
|
|
{
|
|
metadata.ForeignKeyNavigationProperty = DetectConventionForeignKey(property);
|
|
}
|
|
}
|
|
|
|
// 3. Navigation - Priority: ToonDescription -> EF Core [InverseProperty] -> Linq2Db [Association] -> Convention
|
|
if (customDescription?.Navigation.HasValue == true)
|
|
{
|
|
metadata.NavigationType = customDescription.Navigation.Value;
|
|
}
|
|
else
|
|
{
|
|
var linq2dbNav = TryGetLinq2DbNavigation(property);
|
|
if (linq2dbNav.HasValue)
|
|
{
|
|
metadata.NavigationType = linq2dbNav.Value;
|
|
}
|
|
else
|
|
{
|
|
metadata.NavigationType = DetectConventionNavigation(property);
|
|
}
|
|
}
|
|
|
|
// 4. InverseProperty - Priority: ToonDescription -> EF Core [InverseProperty]
|
|
if (!string.IsNullOrEmpty(customDescription?.InverseProperty))
|
|
{
|
|
metadata.InverseProperty = customDescription.InverseProperty;
|
|
}
|
|
else
|
|
{
|
|
var efCoreInverse = TryGetEFCoreInverseProperty(property);
|
|
if (!string.IsNullOrEmpty(efCoreInverse))
|
|
{
|
|
metadata.InverseProperty = efCoreInverse;
|
|
}
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
/// <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
|
|
}
|