Enhance Toon serialization with relationship & table metadata

- Add automatic detection of primary/foreign keys, navigation types, and table names via ToonDescription, EF Core/Linq2Db attributes, or convention
- Extend ToonDescriptionAttribute with IsPrimaryKey, ForeignKey, Navigation, InverseProperty, and TableName
- Output relationship and table metadata in @types section (enhanced and simple modes)
- Document enums in @types with numeric values and descriptions
- Optimize token usage: compact output when indentation is off
- Show dictionary key/value types in output
- Add SerializeMetadata(IEnumerable<Type>) API for multi-type docs
- Refactor and improve documentation throughout
This commit is contained in:
Loretta 2026-01-12 07:11:57 +01:00
parent bbb21dbb67
commit d2caa2234d
7 changed files with 915 additions and 44 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)"
]
}
}

View File

@ -194,9 +194,11 @@ public static partial class AcToonSerializer
}
else if (underlyingType.IsEnum)
{
var enumValue = Convert.ToInt32(value);
var enumName = Enum.GetName(underlyingType, value);
valueStr = enumName != null ? $"\"{enumName}\"" : enumValue.ToString(CultureInfo.InvariantCulture);
// Serialize enum as numeric value (not name) for token efficiency
// The @types section contains the mapping
var enumUnderlyingType = Enum.GetUnderlyingType(underlyingType);
var numericValue = Convert.ChangeType(value, enumUnderlyingType);
valueStr = Convert.ToString(numericValue, CultureInfo.InvariantCulture)!;
typeHint = "enum";
}
else
@ -253,7 +255,16 @@ public static partial class AcToonSerializer
context.WriteIndent();
context.Write(prop.Name);
context.Write(" = ");
// Token optimization: no spaces around '=' when indentation is disabled
if (context.Options.UseIndentation)
{
context.Write(" = ");
}
else
{
context.Write('=');
}
if (propValue == null)
{
@ -385,10 +396,12 @@ public static partial class AcToonSerializer
/// </summary>
private static void WriteDictionary(IDictionary dictionary, ToonSerializationContext context, int depth)
{
// Write dictionary header with count
// Write dictionary header with count and type information
if (context.Options.ShowCollectionCount)
{
context.Write($"<dict> (count: {dictionary.Count}) ");
var dictType = dictionary.GetType();
var typeDisplayName = GetDictionaryTypeDisplayName(dictType);
context.Write($"<{typeDisplayName}> (count: {dictionary.Count}) ");
}
context.WriteLine("{");
@ -417,4 +430,53 @@ public static partial class AcToonSerializer
context.WriteIndent();
context.Write("}");
}
/// <summary>
/// Get type display name for dictionary (e.g., "Dictionary&lt;string, decimal&gt;").
/// </summary>
private static string GetDictionaryTypeDisplayName(Type dictType)
{
if (IsDictionaryType(dictType, out var keyType, out var valueType))
{
var keyTypeName = GetSimpleTypeName(keyType);
var valueTypeName = GetSimpleTypeName(valueType);
return $"Dictionary<{keyTypeName}, {valueTypeName}>";
}
return "dict";
}
/// <summary>
/// Get simple type name for dictionary type parameters.
/// </summary>
private static string GetSimpleTypeName(Type? type)
{
if (type == null) return "object";
var underlying = Nullable.GetUnderlyingType(type);
var isNullable = underlying != null;
var actualType = underlying ?? type;
var baseName = Type.GetTypeCode(actualType) switch
{
TypeCode.Int32 => "int",
TypeCode.Int64 => "long",
TypeCode.Double => "double",
TypeCode.Decimal => "decimal",
TypeCode.Single => "float",
TypeCode.Boolean => "bool",
TypeCode.String => "string",
TypeCode.DateTime => "DateTime",
TypeCode.Byte => "byte",
TypeCode.Int16 => "short",
TypeCode.UInt16 => "ushort",
TypeCode.UInt32 => "uint",
TypeCode.UInt64 => "ulong",
TypeCode.SByte => "sbyte",
TypeCode.Char => "char",
_ => actualType.Name
};
return isNullable ? baseName + "?" : baseName;
}
}

View File

@ -18,25 +18,62 @@ public static partial class AcToonSerializer
}
/// <summary>
/// Write @meta and @types sections.
/// 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\"");
// Collect all types that need metadata
var typesToDocument = new HashSet<Type>();
CollectTypes(type, typesToDocument);
// Write type list
context.WriteIndent();
context.Write("types = [");
context.Write("types");
// Token optimization: no spaces around '=' when indentation is disabled
if (context.Options.UseIndentation)
{
context.Write(" = ");
}
else
{
context.Write('=');
}
context.Write("[");
var first = true;
foreach (var t in typesToDocument)
{
@ -48,7 +85,12 @@ public static partial class AcToonSerializer
context.CurrentIndentLevel--;
context.WriteLine("}");
context.WriteLine();
// Token optimization: skip empty line when indentation is disabled
if (context.Options.UseIndentation)
{
context.WriteLine();
}
// @types section with descriptions
context.WriteLine("@types {");
@ -103,22 +145,39 @@ public static partial class AcToonSerializer
/// </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}\"");
// Type-level purpose (if enhanced metadata is enabled)
// Type-level metadata (enhanced mode only)
if (context.Options.UseEnhancedMetadata)
{
context.CurrentIndentLevel++;
// Table name
var tableName = DetectTableName(type, metadata.CustomDescription);
if (!string.IsNullOrEmpty(tableName))
{
context.WriteIndentedLine($"table-name: \"{tableName}\"");
}
// Purpose
var typePurpose = GetFinalTypePurpose(type, metadata);
if (!string.IsNullOrEmpty(typePurpose))
{
context.CurrentIndentLevel++;
context.WriteIndentedLine($"purpose: \"{typePurpose}\"");
context.CurrentIndentLevel--;
}
context.CurrentIndentLevel--;
}
if (metadata.Properties.Length == 0) return;
@ -133,6 +192,9 @@ public static partial class AcToonSerializer
var constraints = GetFinalPropertyConstraints(prop, type);
var examples = GetFinalPropertyExamples(prop);
// Detect relationship metadata
var relationshipMetadata = DetectRelationshipMetadata(prop.PropertyInfo, prop.CustomDescription);
var typeHint = prop.TypeDisplayName;
if (context.Options.UseEnhancedMetadata)
@ -148,16 +210,177 @@ public static partial class AcToonSerializer
if (!string.IsNullOrEmpty(examples))
context.WriteIndentedLine($"examples: \"{examples}\"");
// Add relationship metadata
if (relationshipMetadata.IsPrimaryKey)
context.WriteIndentedLine($"primary-key: true");
if (!string.IsNullOrEmpty(relationshipMetadata.ForeignKeyNavigationProperty))
context.WriteIndentedLine($"foreign-key: \"{relationshipMetadata.ForeignKeyNavigationProperty}\"");
if (relationshipMetadata.NavigationType.HasValue)
{
var navType = relationshipMetadata.NavigationType.Value.ToString();
var navTypeFormatted = string.Concat(navType.Select((c, i) => i > 0 && char.IsUpper(c) ? "-" + char.ToLower(c) : char.ToLower(c).ToString()));
context.WriteIndentedLine($"navigation: \"{navTypeFormatted}\"");
}
if (!string.IsNullOrEmpty(relationshipMetadata.InverseProperty))
context.WriteIndentedLine($"inverse-property: \"{relationshipMetadata.InverseProperty}\"");
context.CurrentIndentLevel--;
}
else
{
// Simple format
context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{propDescription}\"");
var relationshipHint = GetRelationshipHint(relationshipMetadata);
var fullDescription = string.IsNullOrEmpty(relationshipHint)
? propDescription
: $"{propDescription} ({relationshipHint})";
context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{fullDescription}\"");
}
}
context.CurrentIndentLevel--;
context.WriteLine();
// 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 = Type.GetTypeCode(underlyingType) switch
{
TypeCode.Byte => "byte",
TypeCode.SByte => "sbyte",
TypeCode.Int16 => "int16",
TypeCode.UInt16 => "uint16",
TypeCode.Int32 => "int32",
TypeCode.UInt32 => "uint32",
TypeCode.Int64 => "int64",
TypeCode.UInt64 => "uint64",
_ => underlyingType.Name.ToLower()
};
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 final enum description with fallback chain.
/// Priority: ToonDescription.Description -> Microsoft [Description] -> Smart inference
/// </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. Smart inference
return $"Enum type with values: {string.Join(", ", Enum.GetNames(enumType))}";
}
/// <summary>
@ -501,4 +724,37 @@ public static partial class AcToonSerializer
return CleanupPlaceholders(result);
}
/// <summary>
/// Get relationship hint string for simple format output.
/// </summary>
private static string GetRelationshipHint(RelationshipMetadata metadata)
{
var hints = new List<string>();
if (metadata.IsPrimaryKey)
hints.Add("primary-key");
if (!string.IsNullOrEmpty(metadata.ForeignKeyNavigationProperty))
hints.Add($"fk->{metadata.ForeignKeyNavigationProperty}");
if (metadata.NavigationType.HasValue)
{
var navType = metadata.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(metadata.InverseProperty))
hints.Add($"inverse:{metadata.InverseProperty}");
return hints.Count > 0 ? string.Join(", ", hints) : string.Empty;
}
}

View File

@ -0,0 +1,435 @@
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&lt;T&gt; or List&lt;T&gt; -> 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
}

View File

@ -204,7 +204,17 @@ public static partial class AcToonSerializer
{
WriteIndent();
_builder.Append(name);
_builder.Append(" = ");
// Token optimization: no spaces around '=' when indentation is disabled
if (Options.UseIndentation)
{
_builder.Append(" = ");
}
else
{
_builder.Append('=');
}
_builder.Append(value);
if (inlineComment != null && Options.UseInlineComments)

View File

@ -106,6 +106,46 @@ public static partial class AcToonSerializer
}
}
/// <summary>
/// Serialize metadata only for a collection of types (no data instances needed).
/// Useful for documenting multiple types at once, or when you only have Type objects.
/// </summary>
/// <param name="types">Types to document</param>
/// <param name="options">Serialization options (optional, defaults to MetaOnly preset)</param>
/// <returns>Metadata-only Toon format string with @meta and @types sections</returns>
public static string SerializeMetadata(IEnumerable<Type> types, AcToonSerializerOptions? options = null)
{
// Return empty string if no types provided
var typesList = types?.ToList();
if (typesList == null || typesList.Count == 0)
{
return string.Empty;
}
options ??= AcToonSerializerOptions.MetaOnly;
var context = ToonSerializationContextPool.Get(options);
try
{
WriteMetaSection(typesList, context);
return context.GetResult();
}
finally
{
ToonSerializationContextPool.Return(context);
}
}
/// <summary>
/// Serialize metadata only for multiple types (params array overload).
/// </summary>
/// <param name="types">Types to document</param>
/// <returns>Metadata-only Toon format string with @meta and @types sections</returns>
public static string SerializeMetadata(params Type[] types)
{
return SerializeMetadata((IEnumerable<Type>)types);
}
#endregion
#region Primitive Serialization

View File

@ -1,5 +1,34 @@
using System;
using System.Collections.Generic;
namespace AyCode.Core.Serializers.Toons;
/// <summary>
/// Defines the type of relationship between entities.
/// </summary>
public enum ToonRelationType
{
/// <summary>
/// Many-to-one relationship (e.g., Person.Company -> Company).
/// </summary>
ManyToOne,
/// <summary>
/// One-to-many relationship (e.g., Company.Employees -> Person[]).
/// </summary>
OneToMany,
/// <summary>
/// One-to-one relationship.
/// </summary>
OneToOne,
/// <summary>
/// Many-to-many relationship.
/// </summary>
ManyToMany
}
/// <summary>
/// Provides custom description metadata for Toon serialization with flexible fallback and placeholder support.
/// This attribute can be applied to classes and properties to provide rich contextual information
@ -22,6 +51,31 @@ namespace AyCode.Core.Serializers.Toons;
/// </summary>
///
/// <remarks>
/// <para><b>SUPPORTED PLACEHOLDERS:</b></para>
/// <list type="table">
/// <listheader>
/// <term>Placeholder</term>
/// <description>Resolves To (Where: C=Class, D=Description, P=Purpose, C=Constraints, E=Examples)</description>
/// </listheader>
/// <item><term>[#Description]</term><description>Microsoft [Description] attribute (Class.D, Property.D)</description></item>
/// <item><term>[#DisplayName]</term><description>Microsoft [DisplayName] attribute (Class.D, Property.D)</description></item>
/// <item><term>[#SmartDescription]</term><description>Auto-inferred description (Class.D, Property.D)</description></item>
/// <item><term>[#SmartPurpose]</term><description>Auto-inferred purpose (Property.P only, empty for classes)</description></item>
/// <item><term>[#Range]</term><description>Microsoft [Range] attribute → "range: min-max" (Property.C)</description></item>
/// <item><term>[#Required]</term><description>Microsoft [Required] attribute → "required" (Property.C)</description></item>
/// <item><term>[#MaxLength]</term><description>Microsoft [MaxLength] attribute → "max-length: N" (Property.C)</description></item>
/// <item><term>[#MinLength]</term><description>Microsoft [MinLength] attribute → "min-length: N" (Property.C)</description></item>
/// <item><term>[#StringLength]</term><description>Microsoft [StringLength] attribute → "length: min-max" (Property.C)</description></item>
/// <item><term>[#EmailAddress]</term><description>Microsoft [EmailAddress] attribute → "email-format" (Property.C)</description></item>
/// <item><term>[#Phone]</term><description>Microsoft [Phone] attribute → "phone-format" (Property.C)</description></item>
/// <item><term>[#Url]</term><description>Microsoft [Url] attribute → "url-format" (Property.C)</description></item>
/// <item><term>[#CreditCard]</term><description>Microsoft [CreditCard] attribute → "credit-card-format" (Property.C)</description></item>
/// <item><term>[#RegularExpression]</term><description>Microsoft [RegularExpression] → "pattern: ..." (Property.C)</description></item>
/// <item><term>[#SmartTypeConstraints]</term><description>Type-derived constraints (nullable, numeric, etc.) (Property.C)</description></item>
/// <item><term>[#SmartInferenceConstraints]</term><description>Auto-inferred constraints (email-format, range, etc.) (Property.C)</description></item>
/// <item><term>[#SmartGeneratedExample]</term><description>Auto-generated example value (Property.E)</description></item>
/// </list>
///
/// <para><b>USAGE MODES:</b></para>
///
/// <para><b>1. FULL CUSTOM (all properties specified):</b></para>
@ -107,31 +161,6 @@ namespace AyCode.Core.Serializers.Toons;
/// // Result: "Object of type GuestUser" (smart inference)
/// </code>
///
/// <para><b>SUPPORTED PLACEHOLDERS:</b></para>
/// <list type="table">
/// <listheader>
/// <term>Placeholder</term>
/// <description>Resolves To (Where: C=Class, D=Description, P=Purpose, C=Constraints, E=Examples)</description>
/// </listheader>
/// <item><term>[#Description]</term><description>Microsoft [Description] attribute (Class.D, Property.D)</description></item>
/// <item><term>[#DisplayName]</term><description>Microsoft [DisplayName] attribute (Class.D, Property.D)</description></item>
/// <item><term>[#SmartDescription]</term><description>Auto-inferred description (Class.D, Property.D)</description></item>
/// <item><term>[#SmartPurpose]</term><description>Auto-inferred purpose (Property.P only, empty for classes)</description></item>
/// <item><term>[#Range]</term><description>Microsoft [Range] attribute → "range: min-max" (Property.C)</description></item>
/// <item><term>[#Required]</term><description>Microsoft [Required] attribute → "required" (Property.C)</description></item>
/// <item><term>[#MaxLength]</term><description>Microsoft [MaxLength] attribute → "max-length: N" (Property.C)</description></item>
/// <item><term>[#MinLength]</term><description>Microsoft [MinLength] attribute → "min-length: N" (Property.C)</description></item>
/// <item><term>[#StringLength]</term><description>Microsoft [StringLength] attribute → "length: min-max" (Property.C)</description></item>
/// <item><term>[#EmailAddress]</term><description>Microsoft [EmailAddress] attribute → "email-format" (Property.C)</description></item>
/// <item><term>[#Phone]</term><description>Microsoft [Phone] attribute → "phone-format" (Property.C)</description></item>
/// <item><term>[#Url]</term><description>Microsoft [Url] attribute → "url-format" (Property.C)</description></item>
/// <item><term>[#CreditCard]</term><description>Microsoft [CreditCard] attribute → "credit-card-format" (Property.C)</description></item>
/// <item><term>[#RegularExpression]</term><description>Microsoft [RegularExpression] → "pattern: ..." (Property.C)</description></item>
/// <item><term>[#SmartTypeConstraints]</term><description>Type-derived constraints (nullable, numeric, etc.) (Property.C)</description></item>
/// <item><term>[#SmartInferenceConstraints]</term><description>Auto-inferred constraints (email-format, range, etc.) (Property.C)</description></item>
/// <item><term>[#SmartGeneratedExample]</term><description>Auto-generated example value (Property.E)</description></item>
/// </list>
///
/// <para><b>BEST PRACTICES:</b></para>
/// <list type="bullet">
/// <item>Use placeholders ([#...]) when you want to MERGE with existing Microsoft attributes</item>
@ -213,6 +242,38 @@ public sealed class ToonDescriptionAttribute : Attribute
/// </summary>
public string? Examples { get; set; }
/// <summary>
/// Gets or sets whether this property is a primary key.
/// If not explicitly set, convention-based detection will be used (e.g., property named "Id").
/// </summary>
public bool? IsPrimaryKey { get; set; }
/// <summary>
/// Gets or sets the foreign key navigation property name.
/// If not explicitly set, convention-based detection will be used (e.g., "CompanyId" -> "Company").
/// Example: For a property "CompanyId", set ForeignKey = "Company" to indicate it references the Company navigation property.
/// </summary>
public string? ForeignKey { get; set; }
/// <summary>
/// Gets or sets the relationship type for navigation properties.
/// If not explicitly set, convention-based detection will be used based on property type.
/// </summary>
public ToonRelationType? Navigation { get; set; }
/// <summary>
/// Gets or sets the inverse navigation property name for bidirectional relationships.
/// Example: For Company.Employees, set InverseProperty = "Company" to indicate the inverse property on Person.
/// </summary>
public string? InverseProperty { get; set; }
/// <summary>
/// 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.
/// Example: TableName = "tbl_Persons" for custom table naming.
/// </summary>
public string? TableName { get; set; }
/// <summary>
/// Initializes a new instance of the ToonDescriptionAttribute with the specified description.
/// </summary>