From d2caa2234d12536ad1923000c580a3a7495005b1 Mon Sep 17 00:00:00 2001 From: Loretta Date: Mon, 12 Jan 2026 07:11:57 +0100 Subject: [PATCH] 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) API for multi-type docs - Refactor and improve documentation throughout --- .claude/settings.local.json | 7 + .../Toons/AcToonSerializer.DataSection.cs | 74 ++- .../Toons/AcToonSerializer.MetaSection.cs | 280 ++++++++++- .../AcToonSerializer.RelationshipDetection.cs | 435 ++++++++++++++++++ ...ToonSerializer.ToonSerializationContext.cs | 12 +- .../Serializers/Toons/AcToonSerializer.cs | 40 ++ .../Toons/ToonDescriptionAttribute.cs | 111 ++++- 7 files changed, 915 insertions(+), 44 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..00fc07d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build:*)" + ] + } +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs index 32e0a52..69bbf51 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.DataSection.cs @@ -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 /// 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($" (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("}"); } + + /// + /// Get type display name for dictionary (e.g., "Dictionary<string, decimal>"). + /// + 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"; + } + + /// + /// Get simple type name for dictionary type parameters. + /// + 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; + } } diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs index 8f56683..8ed59d2 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.MetaSection.cs @@ -18,25 +18,62 @@ public static partial class AcToonSerializer } /// - /// Write @meta and @types sections. + /// Write @meta and @types sections for a single root type. /// private static void WriteMetaSection(Type type, ToonSerializationContext context) { if (!context.Options.UseMeta) return; + // Collect all types that need metadata + var typesToDocument = new HashSet(); + CollectTypes(type, typesToDocument); + + WriteMetaSectionCore(typesToDocument, context); + } + + /// + /// Write @meta and @types sections for a collection of types. + /// + private static void WriteMetaSection(IEnumerable types, ToonSerializationContext context) + { + if (!context.Options.UseMeta) return; + + // Collect all types that need metadata (including nested types) + var typesToDocument = new HashSet(); + foreach (var type in types) + { + CollectTypes(type, typesToDocument); + } + + WriteMetaSectionCore(typesToDocument, context); + } + + /// + /// Core logic for writing @meta and @types sections. + /// + private static void WriteMetaSectionCore(HashSet typesToDocument, ToonSerializationContext context) + { // @meta header context.WriteLine("@meta {"); context.CurrentIndentLevel++; context.WriteProperty("version", $"\"{FormatVersion}\""); context.WriteProperty("format", "\"toon\""); - // Collect all types that need metadata - var typesToDocument = new HashSet(); - 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 /// 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(); + } + } + + /// + /// Write enum type definition with values and metadata. + /// + private static void WriteEnumTypeDefinition(Type enumType, ToonSerializationContext context) + { + var customDescription = enumType.GetCustomAttribute(); + + // Type description with fallback + var typeDescription = GetFinalEnumDescription(enumType, customDescription); + context.WriteIndentedLine($"{enumType.Name}: enum"); + + if (context.Options.UseEnhancedMetadata) + { + context.CurrentIndentLevel++; + + // Description + if (!string.IsNullOrEmpty(typeDescription)) + { + context.WriteIndentedLine($"description: \"{typeDescription}\""); + } + + // Purpose + var typePurpose = customDescription?.Purpose; + if (!string.IsNullOrEmpty(typePurpose)) + { + context.WriteIndentedLine($"purpose: \"{typePurpose}\""); + } + + // Underlying type + var underlyingType = Enum.GetUnderlyingType(enumType); + var underlyingTypeName = 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(); + var description = memberDescription?.Description; + + // Token optimization: format depends on indentation setting + var separator = context.Options.UseIndentation ? " = " : "="; + + if (!string.IsNullOrEmpty(description)) + { + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + context.CurrentIndentLevel++; + context.WriteIndentedLine($"description: \"{description}\""); + context.CurrentIndentLevel--; + } + else + { + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + } + } + + context.CurrentIndentLevel--; + context.CurrentIndentLevel--; + } + else + { + // Simple format - just show values + var names = Enum.GetNames(enumType); + var underlyingType = Enum.GetUnderlyingType(enumType); + context.CurrentIndentLevel++; + + // Token optimization: format depends on indentation setting + var separator = context.Options.UseIndentation ? " = " : "="; + + foreach (var name in names) + { + var field = enumType.GetField(name); + var value = field?.GetValue(null); + var numericValue = Convert.ChangeType(value, underlyingType); + context.WriteIndentedLine($"{name}{separator}{numericValue}"); + } + + context.CurrentIndentLevel--; + } + + // Token optimization: skip empty line when indentation is disabled + if (context.Options.UseIndentation) + { + context.WriteLine(); + } + } + + /// + /// Get final enum description with fallback chain. + /// Priority: ToonDescription.Description -> Microsoft [Description] -> Smart inference + /// + private static string GetFinalEnumDescription(Type enumType, ToonDescriptionAttribute? customDescription) + { + // 1. ToonDescription.Description + if (!string.IsNullOrWhiteSpace(customDescription?.Description)) + { + return customDescription.Description; + } + + // 2. Microsoft [Description] attribute + var msDesc = enumType.GetCustomAttribute(); + if (msDesc != null && !string.IsNullOrWhiteSpace(msDesc.Description)) + { + return msDesc.Description; + } + + // 3. Smart inference + return $"Enum type with values: {string.Join(", ", Enum.GetNames(enumType))}"; } /// @@ -501,4 +724,37 @@ public static partial class AcToonSerializer return CleanupPlaceholders(result); } + + /// + /// Get relationship hint string for simple format output. + /// + private static string GetRelationshipHint(RelationshipMetadata metadata) + { + var hints = new List(); + + 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; + } } diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs new file mode 100644 index 0000000..c8f1982 --- /dev/null +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.RelationshipDetection.cs @@ -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 +{ + /// + /// Relationship metadata for a property. + /// + private sealed class RelationshipMetadata + { + public bool IsPrimaryKey { get; set; } + public string? ForeignKeyNavigationProperty { get; set; } + public ToonRelationType? NavigationType { get; set; } + public string? InverseProperty { get; set; } + } + + /// + /// Detects relationship metadata for a property with fallback chain. + /// Priority: ToonDescription -> EF Core/Linq2Db attributes -> Convention-based detection. + /// + 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; + } + + /// + /// Convention: Property named "Id" or "{TypeName}Id" is a primary key. + /// + private static bool IsConventionPrimaryKey(PropertyInfo property) + { + if (property.PropertyType != typeof(int) && + property.PropertyType != typeof(long) && + property.PropertyType != typeof(Guid)) + { + return false; + } + + return property.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals(property.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Convention: Property named "{TypeName}Id" with a corresponding "{TypeName}" navigation property is a foreign key. + /// Type-based validation: checks that navigation property is a complex type (not primitive/string). + /// Example: "CompanyId" + "Company" property exists and is complex type -> foreign key to "Company". + /// + private static string? DetectConventionForeignKey(PropertyInfo property) + { + // Skip non-value types (we don't check specific FK type, could be int/long/Guid/etc) + if (!property.PropertyType.IsValueType) + { + return null; + } + + // Check if property name ends with "Id" + if (!property.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Extract potential navigation property name + var navigationPropertyName = property.Name.Substring(0, property.Name.Length - 2); + + // Check if corresponding navigation property exists + var declaringType = property.DeclaringType; + if (declaringType == null) + { + return null; + } + + var navigationProperty = declaringType.GetProperty(navigationPropertyName, BindingFlags.Public | BindingFlags.Instance); + + // Type-based validation: navigation property must exist and be a complex type (not primitive/string) + if (navigationProperty == null || IsPrimitiveOrStringFast(navigationProperty.PropertyType)) + { + return null; + } + + // Additional validation: navigation property type should have a primary key (IsPrimaryKey or Id property) + if (!HasPrimaryKeyProperty(navigationProperty.PropertyType)) + { + return null; + } + + return navigationPropertyName; + } + + /// + /// Convention: Detect navigation type based on property type. + /// Type-based validation with FK lookup: + /// - ICollection<T> or List<T> -> OneToMany (if T has primary key) + /// - Complex object type -> ManyToOne (if has corresponding FK property and has primary key) + /// + private static ToonRelationType? DetectConventionNavigation(PropertyInfo property) + { + var propertyType = property.PropertyType; + + // Skip primitive types and strings + if (IsPrimitiveOrStringFast(propertyType)) + { + return null; + } + + // Check for collection types (OneToMany) + if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) + { + // Extract element type from collection + var elementType = GetCollectionElementType(propertyType); + if (elementType != null && HasPrimaryKeyProperty(elementType)) + { + // It's a collection of entities -> OneToMany + return ToonRelationType.OneToMany; + } + return null; + } + + // Complex object type -> check if it's a navigation property + // Type-based validation: must have a primary key + if (!HasPrimaryKeyProperty(propertyType)) + { + return null; + } + + // Look for a corresponding foreign key property to confirm it's a navigation + var declaringType = property.DeclaringType; + if (declaringType != null) + { + var foreignKeyPropertyName = property.Name + "Id"; + var foreignKeyProperty = declaringType.GetProperty(foreignKeyPropertyName, BindingFlags.Public | BindingFlags.Instance); + + // Type-based validation: FK must be a value type + if (foreignKeyProperty != null && foreignKeyProperty.PropertyType.IsValueType) + { + // Found corresponding foreign key -> ManyToOne + return ToonRelationType.ManyToOne; + } + } + + // No corresponding foreign key found + // Return null to avoid false positives + return null; + } + + /// + /// Check if a type has a primary key property. + /// First checks for IsPrimaryKey=true in ToonDescription, then falls back to "Id" property. + /// + private static bool HasPrimaryKeyProperty(Type type) + { + if (IsPrimitiveOrStringFast(type)) + { + return false; + } + + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + // First: check for ToonDescription with IsPrimaryKey = true + foreach (var prop in properties) + { + var customDescription = prop.GetCustomAttribute(); + if (customDescription?.IsPrimaryKey == true) + { + return true; + } + } + + // Fallback: check for "Id" or "{TypeName}Id" property + foreach (var prop in properties) + { + if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + prop.Name.Equals(type.Name + "Id", StringComparison.OrdinalIgnoreCase)) + { + // Must be a value type (int, long, Guid, etc.) + if (prop.PropertyType.IsValueType) + { + return true; + } + } + } + + return false; + } + + #region EF Core Attribute Detection (reflection-based, no dependency) + + /// + /// Detect EF Core [Key] attribute via reflection (no EF Core dependency). + /// + private static bool TryGetEFCoreKey(PropertyInfo property) + { + var keyAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "KeyAttribute"); + return keyAttr != null; + } + + /// + /// Detect EF Core [ForeignKey("NavigationPropertyName")] attribute via reflection. + /// + private static string? TryGetEFCoreForeignKey(PropertyInfo property) + { + var fkAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "ForeignKeyAttribute"); + + if (fkAttr != null) + { + // Get the Name property value (navigation property name) + var nameProp = fkAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(fkAttr) as string; + } + + return null; + } + + /// + /// Detect EF Core [InverseProperty("PropertyName")] attribute via reflection. + /// + private static string? TryGetEFCoreInverseProperty(PropertyInfo property) + { + var inversePropAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "InversePropertyAttribute"); + + if (inversePropAttr != null) + { + var propertyProp = inversePropAttr.GetType().GetProperty("Property"); + return propertyProp?.GetValue(inversePropAttr) as string; + } + + return null; + } + + #endregion + + #region Linq2Db Attribute Detection (reflection-based, no dependency) + + /// + /// Detect Linq2Db [PrimaryKey] attribute via reflection (no Linq2Db dependency). + /// + private static bool TryGetLinq2DbPrimaryKey(PropertyInfo property) + { + var pkAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "PrimaryKeyAttribute"); + return pkAttr != null; + } + + /// + /// Detect Linq2Db [Association] attribute and determine navigation type. + /// Supports simple ThisKey/OtherKey associations. Expression-based associations are skipped. + /// + private static ToonRelationType? TryGetLinq2DbNavigation(PropertyInfo property) + { + var assocAttr = property.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "AssociationAttribute"); + + if (assocAttr == null) + return null; + + // Check if it's expression-based (has QueryExpressionMethod property set) + var queryExprMethod = assocAttr.GetType().GetProperty("QueryExpressionMethod")?.GetValue(assocAttr) as string; + if (!string.IsNullOrEmpty(queryExprMethod)) + { + // Expression-based many-to-many - too complex, skip and fallback to convention + return null; + } + + // Simple association with ThisKey/OtherKey + var thisKey = assocAttr.GetType().GetProperty("ThisKey")?.GetValue(assocAttr) as string; + var otherKey = assocAttr.GetType().GetProperty("OtherKey")?.GetValue(assocAttr) as string; + + var propertyType = property.PropertyType; + + // Check if it's a collection (OneToMany or ManyToMany) + if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) + { + // Collection with ThisKey + OtherKey -> could be ManyToMany or OneToMany + // If both ThisKey and OtherKey are set -> likely ManyToMany + // If only ThisKey -> OneToMany + if (!string.IsNullOrEmpty(thisKey) && !string.IsNullOrEmpty(otherKey)) + { + // Could be ManyToMany, but without junction table info, treat as OneToMany + return ToonRelationType.OneToMany; + } + else + { + return ToonRelationType.OneToMany; + } + } + else + { + // Single object -> ManyToOne or OneToOne + // Typically ManyToOne + return ToonRelationType.ManyToOne; + } + } + + #endregion + + #region TableName Detection (class-level) + + /// + /// Detect table name for a type with fallback chain. + /// Priority: ToonDescription.TableName -> EF Core [Table] -> Linq2Db [Table] -> Convention (class name). + /// + internal static string? DetectTableName(Type type, ToonDescriptionAttribute? customDescription) + { + // 1. ToonDescription.TableName (explicit override) + if (!string.IsNullOrEmpty(customDescription?.TableName)) + { + return customDescription.TableName; + } + + // 2. EF Core [Table("name")] attribute (primary) + var efCoreTable = TryGetEFCoreTableName(type); + if (!string.IsNullOrEmpty(efCoreTable)) + { + return efCoreTable; + } + + // 3. Linq2Db [Table(Name = "name")] attribute (if EF Core not found) + var linq2dbTable = TryGetLinq2DbTableName(type); + if (!string.IsNullOrEmpty(linq2dbTable)) + { + return linq2dbTable; + } + + // 4. Convention: class name (fallback) + return type.Name; + } + + /// + /// Detect EF Core [Table("name")] or [Table(Name = "name", Schema = "schema")] attribute via reflection. + /// + private static string? TryGetEFCoreTableName(Type type) + { + var tableAttr = type.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); + + if (tableAttr != null) + { + // EF Core TableAttribute has "Name" property + var nameProp = tableAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(tableAttr) as string; + } + + return null; + } + + /// + /// Detect Linq2Db [Table(Name = "name")] attribute via reflection. + /// + private static string? TryGetLinq2DbTableName(Type type) + { + var tableAttr = type.GetCustomAttributes() + .FirstOrDefault(a => a.GetType().Name == "TableAttribute"); + + if (tableAttr != null) + { + // Linq2Db TableAttribute also has "Name" property + var nameProp = tableAttr.GetType().GetProperty("Name"); + return nameProp?.GetValue(tableAttr) as string; + } + + return null; + } + + #endregion +} diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonSerializationContext.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonSerializationContext.cs index 54c6e97..8dd49b9 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonSerializationContext.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.ToonSerializationContext.cs @@ -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) diff --git a/AyCode.Core/Serializers/Toons/AcToonSerializer.cs b/AyCode.Core/Serializers/Toons/AcToonSerializer.cs index 037bbcf..5913d41 100644 --- a/AyCode.Core/Serializers/Toons/AcToonSerializer.cs +++ b/AyCode.Core/Serializers/Toons/AcToonSerializer.cs @@ -106,6 +106,46 @@ public static partial class AcToonSerializer } } + /// + /// 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. + /// + /// Types to document + /// Serialization options (optional, defaults to MetaOnly preset) + /// Metadata-only Toon format string with @meta and @types sections + public static string SerializeMetadata(IEnumerable 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); + } + } + + /// + /// Serialize metadata only for multiple types (params array overload). + /// + /// Types to document + /// Metadata-only Toon format string with @meta and @types sections + public static string SerializeMetadata(params Type[] types) + { + return SerializeMetadata((IEnumerable)types); + } + #endregion #region Primitive Serialization diff --git a/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs b/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs index b8d92fe..a6ed182 100644 --- a/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs +++ b/AyCode.Core/Serializers/Toons/ToonDescriptionAttribute.cs @@ -1,5 +1,34 @@ +using System; +using System.Collections.Generic; + namespace AyCode.Core.Serializers.Toons; +/// +/// Defines the type of relationship between entities. +/// +public enum ToonRelationType +{ + /// + /// Many-to-one relationship (e.g., Person.Company -> Company). + /// + ManyToOne, + + /// + /// One-to-many relationship (e.g., Company.Employees -> Person[]). + /// + OneToMany, + + /// + /// One-to-one relationship. + /// + OneToOne, + + /// + /// Many-to-many relationship. + /// + ManyToMany +} + /// /// 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; /// /// /// +/// SUPPORTED PLACEHOLDERS: +/// +/// +/// Placeholder +/// Resolves To (Where: C=Class, D=Description, P=Purpose, C=Constraints, E=Examples) +/// +/// [#Description]Microsoft [Description] attribute (Class.D, Property.D) +/// [#DisplayName]Microsoft [DisplayName] attribute (Class.D, Property.D) +/// [#SmartDescription]Auto-inferred description (Class.D, Property.D) +/// [#SmartPurpose]Auto-inferred purpose (Property.P only, empty for classes) +/// [#Range]Microsoft [Range] attribute → "range: min-max" (Property.C) +/// [#Required]Microsoft [Required] attribute → "required" (Property.C) +/// [#MaxLength]Microsoft [MaxLength] attribute → "max-length: N" (Property.C) +/// [#MinLength]Microsoft [MinLength] attribute → "min-length: N" (Property.C) +/// [#StringLength]Microsoft [StringLength] attribute → "length: min-max" (Property.C) +/// [#EmailAddress]Microsoft [EmailAddress] attribute → "email-format" (Property.C) +/// [#Phone]Microsoft [Phone] attribute → "phone-format" (Property.C) +/// [#Url]Microsoft [Url] attribute → "url-format" (Property.C) +/// [#CreditCard]Microsoft [CreditCard] attribute → "credit-card-format" (Property.C) +/// [#RegularExpression]Microsoft [RegularExpression] → "pattern: ..." (Property.C) +/// [#SmartTypeConstraints]Type-derived constraints (nullable, numeric, etc.) (Property.C) +/// [#SmartInferenceConstraints]Auto-inferred constraints (email-format, range, etc.) (Property.C) +/// [#SmartGeneratedExample]Auto-generated example value (Property.E) +/// +/// /// USAGE MODES: /// /// 1. FULL CUSTOM (all properties specified): @@ -107,31 +161,6 @@ namespace AyCode.Core.Serializers.Toons; /// // Result: "Object of type GuestUser" (smart inference) /// /// -/// SUPPORTED PLACEHOLDERS: -/// -/// -/// Placeholder -/// Resolves To (Where: C=Class, D=Description, P=Purpose, C=Constraints, E=Examples) -/// -/// [#Description]Microsoft [Description] attribute (Class.D, Property.D) -/// [#DisplayName]Microsoft [DisplayName] attribute (Class.D, Property.D) -/// [#SmartDescription]Auto-inferred description (Class.D, Property.D) -/// [#SmartPurpose]Auto-inferred purpose (Property.P only, empty for classes) -/// [#Range]Microsoft [Range] attribute → "range: min-max" (Property.C) -/// [#Required]Microsoft [Required] attribute → "required" (Property.C) -/// [#MaxLength]Microsoft [MaxLength] attribute → "max-length: N" (Property.C) -/// [#MinLength]Microsoft [MinLength] attribute → "min-length: N" (Property.C) -/// [#StringLength]Microsoft [StringLength] attribute → "length: min-max" (Property.C) -/// [#EmailAddress]Microsoft [EmailAddress] attribute → "email-format" (Property.C) -/// [#Phone]Microsoft [Phone] attribute → "phone-format" (Property.C) -/// [#Url]Microsoft [Url] attribute → "url-format" (Property.C) -/// [#CreditCard]Microsoft [CreditCard] attribute → "credit-card-format" (Property.C) -/// [#RegularExpression]Microsoft [RegularExpression] → "pattern: ..." (Property.C) -/// [#SmartTypeConstraints]Type-derived constraints (nullable, numeric, etc.) (Property.C) -/// [#SmartInferenceConstraints]Auto-inferred constraints (email-format, range, etc.) (Property.C) -/// [#SmartGeneratedExample]Auto-generated example value (Property.E) -/// -/// /// BEST PRACTICES: /// /// Use placeholders ([#...]) when you want to MERGE with existing Microsoft attributes @@ -213,6 +242,38 @@ public sealed class ToonDescriptionAttribute : Attribute /// public string? Examples { get; set; } + /// + /// 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"). + /// + public bool? IsPrimaryKey { get; set; } + + /// + /// 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. + /// + public string? ForeignKey { get; set; } + + /// + /// Gets or sets the relationship type for navigation properties. + /// If not explicitly set, convention-based detection will be used based on property type. + /// + public ToonRelationType? Navigation { get; set; } + + /// + /// 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. + /// + public string? InverseProperty { get; set; } + + /// + /// Gets or sets the database table name for this entity (class-level only). + /// If not explicitly set, will fallback to EF Core [Table] or Linq2Db [Table] attributes, then to class name. + /// Example: TableName = "tbl_Persons" for custom table naming. + /// + public string? TableName { get; set; } + /// /// Initializes a new instance of the ToonDescriptionAttribute with the specified description. ///